From 0ac030e4606a9d2c3949bd33e097aedc976ef718 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Sun, 31 May 2026 03:24:46 +1000 Subject: [PATCH 01/21] refactoring using SOLID principles --- .github/workflows/ci.yml | 43 + README.md | 141 + .../src/main/scala/com/tibiabot/BotApp.scala | 3122 ++--------------- .../main/scala/com/tibiabot/BotListener.scala | 1840 +--------- .../scala/com/tibiabot/CreatureManager.scala | 1 + .../main/scala/com/tibiabot/TibiaBot.scala | 242 +- .../scala/com/tibiabot/WorldManager.scala | 1 + .../com/tibiabot/admin/AdminService.scala | 123 + .../scala/com/tibiabot/app/Bootstrap.scala | 16 + .../com/tibiabot/app/StreamSupervisor.scala | 57 + .../com/tibiabot/boosted/BoostedService.scala | 363 ++ .../com/tibiabot/commands/CommandRouter.scala | 18 + .../tibiabot/commands/CommandSchemas.scala | 273 ++ .../com/tibiabot/commands/Permissions.scala | 16 + .../commands/handlers/AdminCommands.scala | 63 + .../commands/handlers/AlliesCommands.scala | 132 + .../commands/handlers/BoostedCommands.scala | 38 + .../commands/handlers/ChannelCommands.scala | 24 + .../commands/handlers/ExivaCommands.scala | 20 + .../commands/handlers/FilterCommands.scala | 31 + .../commands/handlers/FullblessCommands.scala | 21 + .../commands/handlers/GalthenCommands.scala | 89 + .../commands/handlers/HelpCommands.scala | 17 + .../commands/handlers/HuntedCommands.scala | 139 + .../handlers/LeaderboardCommands.scala | 14 + .../commands/handlers/NeutralCommands.scala | 92 + .../handlers/OnlineListCommands.scala | 26 + .../tibiabot/commands/handlers/Options.scala | 13 + .../com/tibiabot/discord/DiscordGateway.scala | 26 + .../tibiabot/discord/JdaDiscordGateway.scala | 19 + .../tibiabot/discord/RateLimitedSender.scala | 45 + .../scala/com/tibiabot/domain/Cache.scala | 8 + .../scala/com/tibiabot/domain/Discord.scala | 4 + .../scala/com/tibiabot/domain/Player.scala | 6 + .../com/tibiabot/domain/Screenshot.scala | 6 + .../scala/com/tibiabot/domain/Stamps.scala | 6 + .../scala/com/tibiabot/domain/World.scala | 33 + .../com/tibiabot/domain/time/Clock.scala | 28 + .../tibiabot/domain/time/DreamScarCycle.scala | 27 + .../com/tibiabot/domain/time/DromeCycle.scala | 19 + .../com/tibiabot/galthen/GalthenService.scala | 78 + .../tibiabot/interactions/ButtonHandler.scala | 725 ++++ .../tibiabot/interactions/ModalHandler.scala | 230 ++ .../ScreenshotMessageHandler.scala | 245 ++ .../persistence/ActivityRepository.scala | 18 + .../persistence/BoostedRepository.scala | 15 + .../persistence/CacheRepository.scala | 30 + .../persistence/ConnectionProvider.scala | 18 + .../persistence/CustomSortRepository.scala | 13 + .../DeathScreenshotRepository.scala | 18 + .../persistence/DiscordConfigRepository.scala | 16 + .../persistence/GalthenRepository.scala | 18 + .../persistence/HuntedAlliedRepository.scala | 17 + .../persistence/JdbcConnectionProvider.scala | 16 + .../com/tibiabot/persistence/JdbcUrls.scala | 20 + .../persistence/SchemaInitializer.scala | 253 ++ .../persistence/WorldConfigRepository.scala | 23 + .../jdbc/JdbcActivityRepository.scala | 135 + .../jdbc/JdbcBoostedRepository.scala | 108 + .../jdbc/JdbcCacheRepository.scala | 365 ++ .../jdbc/JdbcCustomSortRepository.scala | 90 + .../jdbc/JdbcDeathScreenshotRepository.scala | 135 + .../jdbc/JdbcDiscordConfigRepository.scala | 125 + .../jdbc/JdbcGalthenRepository.scala | 120 + .../jdbc/JdbcHuntedAlliedRepository.scala | 133 + .../jdbc/JdbcWorldConfigRepository.scala | 254 ++ .../tibiabot/presentation/BoostedEmbeds.scala | 18 + .../tibiabot/presentation/DeathEmbeds.scala | 21 + .../com/tibiabot/presentation/Emojis.scala | 40 + .../tibiabot/presentation/GalthenEmbeds.scala | 25 + .../presentation/OnlineListEmbeds.scala | 22 + .../com/tibiabot/presentation/Urls.scala | 50 + .../scheduler/ServerSaveSchedule.scala | 26 + .../com/tibiabot/state/StreamState.scala | 31 + .../com/tibiabot/tibiadata/TibiaApi.scala | 25 + .../tibiabot/tibiadata/TibiaDataClient.scala | 3 +- .../tracking/BoundedMessageQueue.scala | 42 + .../com/tibiabot/tracking/LevelTracker.scala | 55 + .../tibiabot/tracking/MasslogDetector.scala | 38 + .../com/tibiabot/tracking/OnlineTracker.scala | 72 + .../com/tibiabot/wiki/FandomWikiClient.scala | 45 + .../com/tibiabot/wiki/FandomWikiParser.scala | 51 + .../scala/com/tibiabot/wiki/WikiClient.scala | 12 + .../resources/tibiadata/boostablebosses.json | 1 + .../test/resources/tibiadata/character.json | 1 + .../test/resources/tibiadata/creatures.json | 1 + .../src/test/resources/tibiadata/guild.json | 1 + .../tibiadata/highscores_antica.json | 1 + .../resources/tibiadata/world_antica.json | 1 + .../com/tibiabot/RealDataBehaviorSpec.scala | 73 + .../tibiabot/app/StreamSupervisorSpec.scala | 59 + .../tibiabot/commands/CommandRouterSpec.scala | 33 + .../commands/CommandSchemasSpec.scala | 37 + .../tibiabot/commands/PermissionsSpec.scala | 17 + .../handlers/CommandOptionsSpec.scala | 17 + .../handlers/NeutralCommandsSpec.scala | 20 + .../tibiabot/discord/DiscordGatewaySpec.scala | 31 + .../discord/RateLimitedSenderSpec.scala | 65 + .../com/tibiabot/domain/time/ClockSpec.scala | 28 + .../domain/time/DreamScarCycleSpec.scala | 33 + .../tibiabot/domain/time/DromeCycleSpec.scala | 34 + .../ActivityRepositoryIntegrationSpec.scala | 44 + .../BoostedRepositoryIntegrationSpec.scala | 36 + .../CacheRepositoryIntegrationSpec.scala | 115 + .../CustomSortRepositoryIntegrationSpec.scala | 41 + ...hScreenshotRepositoryIntegrationSpec.scala | 43 + ...scordConfigRepositoryIntegrationSpec.scala | 69 + .../GalthenRepositoryIntegrationSpec.scala | 44 + ...untedAlliedRepositoryIntegrationSpec.scala | 68 + .../tibiabot/persistence/JdbcUrlsSpec.scala | 20 + .../PostgresConnectivityIntegrationSpec.scala | 20 + .../persistence/PostgresSupport.scala | 21 + .../SchemaInitializerIntegrationSpec.scala | 41 + ...WorldConfigRepositoryIntegrationSpec.scala | 84 + .../presentation/BoostedEmbedsSpec.scala | 17 + .../presentation/CreatureUrlsSpec.scala | 35 + .../presentation/DeathEmbedsSpec.scala | 21 + .../tibiabot/presentation/EmojisSpec.scala | 29 + .../presentation/GalthenEmbedsSpec.scala | 33 + .../presentation/OnlineListEmbedsSpec.scala | 20 + .../com/tibiabot/presentation/UrlsSpec.scala | 22 + .../scheduler/ServerSaveScheduleSpec.scala | 31 + .../state/StreamStateConcurrencySpec.scala | 80 + .../tibiadata/TibiaDataDecodersSpec.scala | 84 + .../tracking/BoundedMessageQueueSpec.scala | 43 + .../tibiabot/tracking/LevelTrackerSpec.scala | 74 + .../tracking/MasslogDetectorSpec.scala | 37 + .../tibiabot/tracking/OnlineTrackerSpec.scala | 95 + .../tracking/TrackingBenchmarkSpec.scala | 70 + .../tibiabot/wiki/FandomWikiParserSpec.scala | 40 + 130 files changed, 7978 insertions(+), 4848 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/app/Bootstrap.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/app/StreamSupervisor.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/CommandRouter.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/CommandSchemas.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AdminCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ChannelCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ExivaCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FilterCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FullblessCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HelpCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/LeaderboardCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/OnlineListCommands.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/handlers/Options.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/discord/DiscordGateway.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/discord/JdaDiscordGateway.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/discord/RateLimitedSender.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/Cache.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/Discord.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/Player.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/Screenshot.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/Stamps.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/World.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/time/Clock.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/time/DromeCycle.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/interactions/ScreenshotMessageHandler.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/ActivityRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/BoostedRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/CacheRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/CustomSortRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/DeathScreenshotRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/DiscordConfigRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/GalthenRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/HuntedAlliedRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcConnectionProvider.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/WorldConfigRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDeathScreenshotRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/GalthenEmbeds.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/Urls.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/scheduler/ServerSaveSchedule.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/tracking/BoundedMessageQueue.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/tracking/LevelTracker.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/tracking/MasslogDetector.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/tracking/OnlineTracker.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiClient.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiParser.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/wiki/WikiClient.scala create mode 100644 tibia-bot/src/test/resources/tibiadata/boostablebosses.json create mode 100644 tibia-bot/src/test/resources/tibiadata/character.json create mode 100644 tibia-bot/src/test/resources/tibiadata/creatures.json create mode 100644 tibia-bot/src/test/resources/tibiadata/guild.json create mode 100644 tibia-bot/src/test/resources/tibiadata/highscores_antica.json create mode 100644 tibia-bot/src/test/resources/tibiadata/world_antica.json create mode 100644 tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/app/StreamSupervisorSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/commands/CommandRouterSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/commands/CommandSchemasSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/commands/PermissionsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/commands/handlers/CommandOptionsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/commands/handlers/NeutralCommandsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/discord/DiscordGatewaySpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/discord/RateLimitedSenderSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/time/ClockSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/time/DromeCycleSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/ActivityRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/CustomSortRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/DeathScreenshotRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/DiscordConfigRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/GalthenRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/HuntedAlliedRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/JdbcUrlsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresConnectivityIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresSupport.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/WorldConfigRepositoryIntegrationSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/CreatureUrlsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/GalthenEmbedsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/UrlsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tracking/BoundedMessageQueueSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tracking/LevelTrackerSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tracking/MasslogDetectorSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tracking/OnlineTrackerSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tracking/TrackingBenchmarkSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/wiki/FandomWikiParserSpec.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cacded4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "8" + cache: sbt + + - name: Set up sbt + uses: sbt/setup-sbt@v1 + + - name: Compile and test (unit + Postgres integration) + working-directory: tibia-bot + env: + PGHOST: localhost + PGPASSWORD: postgres + run: sbt -batch clean test diff --git a/README.md b/README.md index d9b6b8f..9513c2c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,147 @@ Current features include: - Server Save Notifications - Command Log +## Architecture + +The code is organised into focused packages under `com.tibiabot` rather than a few +god-objects. The top-level entry points stay thin: + +- `BotApp` — application state and orchestration (wires the collaborators below). +- `BotListener` — a thin JDA event dispatcher; routes each event to a handler. +- `TibiaBot` — the per-world Akka stream that polls TibiaData and detects deaths/levels. + +Supporting packages: + +| Package | Responsibility | +| --- | --- | +| `app/` | Startup wiring — `Bootstrap` (JDA session) and `StreamSupervisor` (per-world stream lifecycle). | +| `commands/` | Slash-command schemas, `CommandRouter`, `Permissions`; `commands/handlers/` has one object per command. | +| `interactions/` | Button, modal and message (screenshot-upload) interaction handlers. | +| `discord/` | `DiscordGateway` (the JDA read seam) and `RateLimitedSender` (outbound message queue). | +| `persistence/` | Repository ports + `ConnectionProvider`/`SchemaInitializer`; JDBC/Postgres impls in `persistence/jdbc/`. | +| `presentation/` | Pure embed/message builders (deaths, online list, boosted, galthen). | +| `scheduler/` | Server-save schedule decisions (window, Rashid location, Drome countdown). | +| `tracking/` | Death/level/online dedup state and masslog detection. | +| `tibiadata/` | TibiaData v4 API client; response models in `tibiadata/response/`. | +| `wiki/` | Fandom wiki client and HTML parser. | +| `domain/` | Core case classes; game-time cycles in `domain/time/`. | +| `galthen/`, `boosted/`, `admin/` | Feature services extracted from `BotApp`. | + +```mermaid +flowchart TB + Discord([Discord]) + + subgraph entry [Entry points] + BL["BotListener — thin event dispatcher"] + BA["BotApp — shared state + orchestration"] + TB["TibiaBot — per-world stream"] + end + + subgraph layer [commands + interactions] + RT[CommandRouter] + HD["handlers — one per slash command"] + IX["Button / Modal / Screenshot handlers"] + end + + subgraph svc [feature services] + FS["galthen · boosted · admin"] + end + + subgraph infra [infrastructure] + GW[DiscordGateway] + SN[RateLimitedSender] + ST["state/StreamState"] + PR[presentation] + TR[tracking] + SC[scheduler] + end + + subgraph data [data + external] + RP["persistence repositories"] + DB[(PostgreSQL)] + TD[tibiadata client] + WK[wiki client] + EXT{{"TibiaData v4 / Fandom"}} + end + + Discord --> BL + BL --> RT --> HD --> BA + BL --> IX --> BA + BA --> FS --> RP + BA --> ST + BA --> RP --> DB + HD --> PR + SC --> BA + TB --> TD --> EXT + BA --> WK --> EXT + TB --> ST + TB --> TR + TB --> PR + TB --> SN --> GW --> Discord +``` + +**Concurrency:** one independent Akka stream per world (held by `StreamSupervisor`), +all sharing a single `ActorSystem`/dispatcher and HTTP pool. Each world ticks every +60s through a back-pressured `mapAsync(1)` pipeline with a `mapAsyncUnordered(32)` +fan-out for per-character lookups, and per-stage `Supervision.Resume` so a single bad +response never kills the stream. Per-world dedup state is isolated to each stream; the +state shared across worlds (`state/StreamState`) is read lock-free on `@volatile` fields +and mutated only through synchronized `modify*` helpers, so concurrent per-guild updates +never clobber each other. + +```mermaid +flowchart TB + subgraph sup ["app/StreamSupervisor — one Akka stream per world"] + WA[world A] + WB[world B] + WN[world N] + end + + subgraph pipe ["the pipeline each world runs independently — tick 60s, back-pressured"] + direction LR + T["Source.tick 60s"] --> GWp["getWorld
mapAsync(1)"] + GWp --> GC["getCharacterData
mapAsyncUnordered(32)"] + GC --> SDp["scanForDeaths
mapAsync(1)"] + SDp --> PDp["postToDiscord
mapAsync(1)"] + end + + WA --> T + WB --> T + WN --> T + + GC -->|HTTP per online character| API{{TibiaData v4 API}} + SDp <-->|"@volatile read · synchronized modify*"| ST[("state/StreamState")] + PDp --> SN["RateLimitedSender
per-world queue"] --> JDA["JDA global rate limiter"] --> D([Discord]) + + WA -.->|run concurrently on| AS[/"shared ActorSystem dispatcher + akka-http pool"/] + WB -.-> AS + WN -.-> AS +``` + +The N world streams run concurrently on the shared dispatcher and HTTP pool; the only +points they contend on are `StreamState` (serialised writes) and the JDA rate limiter +(outbound sends). Startup staggers stream launches by ~5.5s so they don't all poll at once. + +## Building & Testing + +The project targets Java 8 and builds with sbt. If you don't have a JDK 8 / sbt +toolchain locally, build and test in Docker: + +```bash +docker run --rm -u "$(id -u):$(id -g)" -e HOME=/cache \ + -v "$HOME/.cache/tibiabot-build:/cache" -v "$PWD:/work" -w /work/tibia-bot \ + sbtscala/scala-sbt:eclipse-temurin-8u352-b08_1.8.2_2.13.10 sbt -batch test +``` + +Tests are hermetic by default: + +- **Unit tests** cover the pure logic (routing, permissions, embed builders, trackers, + schedule decisions, the rate-limited sender). +- **Decoder tests** parse frozen real TibiaData `/v4` responses + (`src/test/resources/tibiadata/`) with the production JSON formats, locking the API contract. +- **Postgres integration tests** self-cancel unless a database is provided; to run them, + add `--network -e PGHOST= -e PGPASSWORD=` to the command above. + ## Pre-requisites: #### Create the new bot in Discord diff --git a/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala b/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala index e0dbee0..6cad0d4 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala @@ -4,8 +4,8 @@ import akka.actor.ActorSystem import akka.stream.scaladsl.{Keep, Sink, Source} import com.tibiabot.tibiadata.TibiaDataClient import com.tibiabot.tibiadata.response.{CharacterResponse, GuildResponse, BoostedResponse, CreatureResponse, RaceResponse, Members, HighscoresResponse} +import com.tibiabot.scheduler.ServerSaveSchedule import com.typesafe.scalalogging.StrictLogging -import net.dv8tion.jda.api.entities.Activity import net.dv8tion.jda.api.entities.channel.concrete.TextChannel import net.dv8tion.jda.api.entities.{Guild, MessageEmbed} import net.dv8tion.jda.api.events.guild.GuildLeaveEvent @@ -16,7 +16,7 @@ import net.dv8tion.jda.api.interactions.commands.build.{Commands, OptionData, Sl import net.dv8tion.jda.api.interactions.commands.{DefaultMemberPermissions, OptionType} import net.dv8tion.jda.api.interactions.components.buttons._ import net.dv8tion.jda.api.requests.GatewayIntent -import net.dv8tion.jda.api.{EmbedBuilder, JDABuilder, Permission} +import net.dv8tion.jda.api.{EmbedBuilder, Permission} import org.postgresql.util.PSQLException import net.dv8tion.jda.api.entities.User import net.dv8tion.jda.api.entities.emoji.Emoji @@ -24,7 +24,7 @@ import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.utils.TimeFormat import java.awt.Color -import java.sql.{Connection, DriverManager, Timestamp} +import java.sql.{Connection, Timestamp} import java.time.{Instant, ZoneOffset, ZonedDateTime, DayOfWeek} import scala.collection.immutable.ListMap import scala.collection.mutable.ListBuffer @@ -50,100 +50,117 @@ import io.circe.HCursor object BotApp extends App with StrictLogging { - case class Worlds(name: String, - alliesChannel: String, - enemiesChannel: String, - neutralsChannel: String, - levelsChannel: String, - deathsChannel: String, - category: String, - fullblessRole: String, - nemesisRole: String, - allyPkRole: String, - masslogRole: String, - fullblessChannel: String, - nemesisChannel: String, - fullblessLevel: Int, - showNeutralLevels: String, - showNeutralDeaths: String, - showAlliesLevels: String, - showAlliesDeaths: String, - showEnemiesLevels: String, - showEnemiesDeaths: String, - detectHunteds: String, - levelsMin: Int, - deathsMin: Int, - exivaList: String, - activityChannel: String, - onlineCombined: String - ) - - private case class Streams(stream: akka.actor.Cancellable, usedBy: List[Discords]) - case class Discords(id: String, adminChannel: String, boostedChannel: String, boostedMessage: String) - case class Players(name: String, reason: String, reasonText: String, addedBy: String) - case class BoostedCache(boss: String, creature: String, bossChanged: String, creatureChanged: String) - case class PlayerCache(name: String, formerNames: List[String], guild: String, updatedTime: ZonedDateTime) - case class Guilds(name: String, reason: String, reasonText: String, addedBy: String) - case class DeathsCache(world: String, name: String, time: String) - case class LevelsCache(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String) - case class ListCache(name: String, formerNames: List[String], world: String, formerWorlds: List[String], guild: String, level: String, vocation: String, last_login: String, updatedTime: ZonedDateTime) - case class SatchelStamp(user: String, when: ZonedDateTime, tag: String) - case class BoostedStamp(user: String, boostedType: String, boostedName: String) - case class DeathScreenshot(guildId: String, world: String, characterName: String, deathTime: Long, screenshotUrl: String, addedBy: String, addedName: String, addedAt: ZonedDateTime, messageId: String) - case class CustomSort(entityType: String, name: String, label: String, emoji: String) - case class BossEntry(world: String, boss: String) + // Domain model extracted to com.tibiabot.domain. Aliased here (type + companion + // val) so every existing reference — bare within BotApp and BotApp.X elsewhere — + // resolves unchanged. Compile-only: no behaviour change. + type Worlds = domain.Worlds; val Worlds = domain.Worlds + type Discords = domain.Discords; val Discords = domain.Discords + type Players = domain.Players; val Players = domain.Players + type Guilds = domain.Guilds; val Guilds = domain.Guilds + type BoostedCache = domain.BoostedCache; val BoostedCache = domain.BoostedCache + type PlayerCache = domain.PlayerCache; val PlayerCache = domain.PlayerCache + type DeathsCache = domain.DeathsCache; val DeathsCache = domain.DeathsCache + type LevelsCache = domain.LevelsCache; val LevelsCache = domain.LevelsCache + type ListCache = domain.ListCache; val ListCache = domain.ListCache + type SatchelStamp = domain.SatchelStamp; val SatchelStamp = domain.SatchelStamp + type BoostedStamp = domain.BoostedStamp; val BoostedStamp = domain.BoostedStamp + type DeathScreenshot = domain.DeathScreenshot; val DeathScreenshot = domain.DeathScreenshot + type CustomSort = domain.CustomSort; val CustomSort = domain.CustomSort + type BossEntry = domain.BossEntry; val BossEntry = domain.BossEntry implicit private val actorSystem: ActorSystem = ActorSystem() implicit private val ex: ExecutionContextExecutor = actorSystem.dispatcher - private val tibiaDataClient = new TibiaDataClient() + private val tibiaDataClient: tibiadata.TibiaApi = new TibiaDataClient() + private val connectionProvider: persistence.ConnectionProvider = + new persistence.JdbcConnectionProvider(Config.postgresHost, Config.postgresPassword) + private val schemaInitializer = new persistence.SchemaInitializer(connectionProvider) + private val boostedRepository: persistence.BoostedRepository = + new persistence.jdbc.JdbcBoostedRepository(connectionProvider) + private lazy val wikiClient: wiki.WikiClient = new wiki.FandomWikiClient() + private val galthenRepository: persistence.GalthenRepository = + new persistence.jdbc.JdbcGalthenRepository(connectionProvider) + private val deathScreenshotRepository: persistence.DeathScreenshotRepository = + new persistence.jdbc.JdbcDeathScreenshotRepository(connectionProvider) + private val cacheRepository: persistence.CacheRepository = + new persistence.jdbc.JdbcCacheRepository(connectionProvider) + private val activityRepository: persistence.ActivityRepository = + new persistence.jdbc.JdbcActivityRepository(connectionProvider) + private val huntedAlliedRepository: persistence.HuntedAlliedRepository = + new persistence.jdbc.JdbcHuntedAlliedRepository(connectionProvider) + private val customSortRepository: persistence.CustomSortRepository = + new persistence.jdbc.JdbcCustomSortRepository(connectionProvider) + private val worldConfigRepository: persistence.WorldConfigRepository = + new persistence.jdbc.JdbcWorldConfigRepository(connectionProvider, Config.mergedWorlds) + private val discordConfigRepository: persistence.DiscordConfigRepository = + new persistence.jdbc.JdbcDiscordConfigRepository(connectionProvider) // Let the games begin logger.info("Starting up") - val jda = JDABuilder.createDefault(Config.token) - .addEventListeners(new BotListener()) - .build() - - jda.awaitReady() + val jda = app.Bootstrap.buildReadyJda(Config.token, new BotListener()) logger.info("JDA ready") + // single read-side seam over JDA (guild/user lookups, identity, presence) + val discordGateway: discord.DiscordGateway = new discord.JdaDiscordGateway(jda) + // get the discord servers the bot is in - private val guilds: List[Guild] = jda.getGuilds.asScala.toList + private val guilds: List[Guild] = discordGateway.guilds + + // per-world stream lifecycle + private val streamSupervisor = new app.StreamSupervisor + + // Galthen's Satchel cooldown tracking + val galthenService = new galthen.GalthenService(galthenRepository, connectionProvider, discordGateway) + + // Per-user boosted boss/creature notification subscriptions + val boostedService = new boosted.BoostedService(connectionProvider, boostedRepository, () => boostedBossesList) - // stream list - private var botStreams = Map[String, Streams]() + // Bot-creator-only /admin operations + val adminService = new admin.AdminService( + discordGateway, + botUser, + discordRetrieveConfig _, + () => { dreamScar = fetchDreamScarBosses().map(e => e.world -> e.boss).toMap } + ) // get bot userID (used to stamp automated enemy detection messages) - val botUser = jda.getSelfUser.getId - private val botName = jda.getSelfUser.getName - - // initialize core hunted/allied list - var customSortData: Map[String, List[CustomSort]] = Map.empty - var huntedPlayersData: Map[String, List[Players]] = Map.empty - var alliedPlayersData: Map[String, List[Players]] = Map.empty - var huntedGuildsData: Map[String, List[Guilds]] = Map.empty - var alliedGuildsData: Map[String, List[Guilds]] = Map.empty - var activityData: Map[String, List[PlayerCache]] = Map.empty - var activityCommandBlocker: Map[String, Boolean] = Map.empty - var characterCache: Map[String, ZonedDateTime] = Map.empty - val activityDataLock = new Object() - - var worldsData: Map[String, List[Worlds]] = Map.empty - var discordsData: Map[String, List[Discords]] = Map.empty + val botUser = discordGateway.selfUserId + private val botName = discordGateway.selfUserName + // the application owner = the bot creator (used to gate /admin) + val botOwner: String = discordGateway.applicationOwnerId + + // Core hunted/allied/world state, read every cycle by the per-world streams and + // written by command threads — @volatile gives cross-thread visibility. + @volatile var customSortData: Map[String, List[CustomSort]] = Map.empty + @volatile var huntedGuildsData: Map[String, List[Guilds]] = Map.empty + @volatile var alliedGuildsData: Map[String, List[Guilds]] = Map.empty + @volatile var activityCommandBlocker: Map[String, Boolean] = Map.empty + @volatile var characterCache: Map[String, ZonedDateTime] = Map.empty + + // The maps written by both streams and command threads live in StreamState, which + // serialises every read-modify-write. BotApp delegates so existing call sites + // (BotApp.activityData / modifyActivityData / ...) are unchanged. + val streamState = new state.StreamState + def activityData: Map[String, List[PlayerCache]] = streamState.activityData + def huntedPlayersData: Map[String, List[Players]] = streamState.huntedPlayersData + def alliedPlayersData: Map[String, List[Players]] = streamState.alliedPlayersData + def modifyActivityData(f: Map[String, List[PlayerCache]] => Map[String, List[PlayerCache]]): Unit = + streamState.modifyActivityData(f) + def modifyHuntedPlayersData(f: Map[String, List[Players]] => Map[String, List[Players]]): Unit = + streamState.modifyHuntedPlayersData(f) + def modifyAlliedPlayersData(f: Map[String, List[Players]] => Map[String, List[Players]]): Unit = + streamState.modifyAlliedPlayersData(f) + + @volatile var worldsData: Map[String, List[Worlds]] = Map.empty + @volatile var discordsData: Map[String, List[Discords]] = Map.empty var worlds: List[String] = Config.worldList - // https://tibia.fandom.com/wiki/Template:Dream_Scar_Boss/Offsets - val bossCycle = Vector( - "Plagueroot", - "Malofur Mangrinder", - "Maxxenius", - "Alptramun", - "Izcandar the Banished" - ) - val indexOfBoss: Map[String, Int] = bossCycle.zipWithIndex.toMap + // Dream Courts boss rotation extracted to domain.time.DreamScarCycle + val bossCycle = domain.time.DreamScarCycle.bossCycle + val indexOfBoss: Map[String, Int] = domain.time.DreamScarCycle.indexOfBoss var dreamScar: Map[String, String] = fetchDreamScarBosses().map(e => e.world -> e.boss).toMap var dreamScarLastCheck: String = System.currentTimeMillis().toString - var dromeTime = Instant.ofEpochSecond(1779868800L) // 27 May 2026 server save - increment 2 weeks from here + var dromeTime = domain.time.DromeCycle.initial // 27 May 2026 server save - increment 2 weeks from here // Boosted Boss val boostedBosses: Future[Either[String, BoostedResponse]] = tibiaDataClient.getBoostedBoss() @@ -165,260 +182,8 @@ object BotApp extends App with StrictLogging { val boostedBossesList: List[String] = Await.result(bossesFutures, 10.seconds) - // create the command to set up the bot - private val setupCommand: SlashCommandData = Commands.slash("setup", "Setup a world to be tracked") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addOptions(new OptionData(OptionType.STRING, "world", "The world you want to track") - .setRequired(true)) - - // remove world command - private val removeCommand: SlashCommandData = Commands.slash("remove", "Remove a world from being tracked") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addOptions(new OptionData(OptionType.STRING, "world", "The world you want to remove") - .setRequired(true)) - - // hunted command - private val huntedCommand: SlashCommandData = Commands.slash("hunted", "Manage the hunted list") - .addSubcommands( - new SubcommandData("guild", "Manage guilds in the hunted list") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to add or remove a guild?").setRequired(true) - .addChoices( - new Choice("add", "add"), - new Choice("remove", "remove") - ), - new OptionData(OptionType.STRING, "name", "The guild name you want to add to the hunted list").setRequired(true) - ), - new SubcommandData("player", "Manage players in the hunted list") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to add or remove a player?").setRequired(true) - .addChoices( - new Choice("add", "add"), - new Choice("remove", "remove") - ), - new OptionData(OptionType.STRING, "name", "The player name you want to add to the hunted list").setRequired(true), - new OptionData(OptionType.STRING, "reason", "You can add a reason when players are added to the hunted list") - ), - new SubcommandData("list", "List players & guilds in the hunted list"), - new SubcommandData("clear", "Remove all players and guilds from the hunted list"), - new SubcommandData("info", "Show detailed info on a hunted player") - .addOptions(new OptionData(OptionType.STRING, "name", "The player name you want to check").setRequired(true) - ), - new SubcommandData("autodetect", "Configure the auto-detection on or off") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to toggle it on or off?").setRequired(true) - .addChoices( - new Choice("on", "on"), - new Choice("off", "off") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ), - new SubcommandData("levels", "Show or hide hunted levels") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to show or hide hunted levels?").setRequired(true) - .addChoices( - new Choice("show", "show"), - new Choice("hide", "hide") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ), - new SubcommandData("deaths", "Show or hide hunted deaths") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to show or hide hunted deaths?").setRequired(true) - .addChoices( - new Choice("show", "show"), - new Choice("hide", "hide") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ) - ) - - // allies command - private val alliesCommand: SlashCommandData = Commands.slash("allies", "Manage the allies list") - .addSubcommands( - new SubcommandData("guild", "Manage guilds in the allies list") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to add or remove a guild?").setRequired(true) - .addChoices( - new Choice("add", "add"), - new Choice("remove", "remove") - ), - new OptionData(OptionType.STRING, "name", "The guild name you want to add to the allies list").setRequired(true) - ), - new SubcommandData("player", "Manage players in the allies list") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to add or remove a player?").setRequired(true) - .addChoices( - new Choice("add", "add"), - new Choice("remove", "remove") - ), - new OptionData(OptionType.STRING, "name", "The player name you want to add to the allies list").setRequired(true) - ), - new SubcommandData("list", "List players & guilds in the allies list"), - new SubcommandData("clear", "Remove all players and guilds from the allies list"), - new SubcommandData("info", "Show detailed info on a allied player") - .addOptions(new OptionData(OptionType.STRING, "name", "The player name you want to check").setRequired(true) - ), - new SubcommandData("levels", "Show or hide ally levels") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to show or hide ally levels?").setRequired(true) - .addChoices( - new Choice("show", "show"), - new Choice("hide", "hide") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ), - new SubcommandData("deaths", "Show or hide ally deaths") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to show or hide ally levels?").setRequired(true) - .addChoices( - new Choice("show", "show"), - new Choice("hide", "hide") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ) - ) - - // neutrals command - private val neutralsCommand: SlashCommandData = Commands.slash("neutral", "Configuration options for neutrals") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addSubcommands( - new SubcommandData("levels", "Show or hide neutral levels") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to show or hide neutral levels?").setRequired(true) - .addChoices( - new Choice("show", "show"), - new Choice("hide", "hide") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ), - new SubcommandData("deaths", "Show or hide neutral deaths") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to show or hide neutral levels?").setRequired(true) - .addChoices( - new Choice("show", "show"), - new Choice("hide", "hide") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ) - ) - - // fullbless command - private val fullblessCommand: SlashCommandData = Commands.slash("fullbless", "Modify the level at which enemy fullblesses poke") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addOptions( - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true), - new OptionData(OptionType.INTEGER, "level", "The minimum level you want to set for fullbless pokes").setRequired(true) - .setMinValue(1) - .setMaxValue(4000) - ) - - // leaderboards command - private val leaderboardsCommand: SlashCommandData = Commands.slash("leaderboards", "Modify the level at which enemy fullblesses poke") - .addOptions( - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ) - - // minimum levels/deaths command - private val filterCommand: SlashCommandData = Commands.slash("filter", "Set a minimum level for the levels or deaths channels") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addSubcommands( - new SubcommandData("levels", "Hide events in the levels channel if the character is below a certain level") - .addOptions( - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true), - new OptionData(OptionType.INTEGER, "level", "The minimum level you want to set for the levels channel").setRequired(true) - .setMinValue(1) - .setMaxValue(4000) - ), - new SubcommandData("deaths", "Hide events in the deaths channel if the character is below a certain level") - .addOptions( - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true), - new OptionData(OptionType.INTEGER, "level", "The minimum level you want to set for the deaths channel").setRequired(true) - .setMinValue(1) - .setMaxValue(4000) - ) - ) - - // remove world command - private val adminCommand: SlashCommandData = Commands.slash("admin", "Commands only available to the bot creator") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addSubcommands( - new SubcommandData("leave", "Force the bot to leave a specific discord") - .addOptions( - new OptionData(OptionType.STRING, "guildid", "The guild ID you want the bot to leave").setRequired(true), - new OptionData(OptionType.STRING, "reason", "What reason do you want to leave for the discord owner?").setRequired(true) - ), - new SubcommandData("info", "get discord info"), - new SubcommandData("dreamscar", "resync dreamscar wiki info"), - new SubcommandData("worldlist", "get discord info"), - new SubcommandData("message", "Send a message to a specific discord") - .addOptions( - new OptionData(OptionType.STRING, "guildid", "The guild ID you want the bot to leave").setRequired(true), - new OptionData(OptionType.STRING, "message", "What message do you want to leave for the discord owner?").setRequired(true) - ) - ) - - // exiva command - private val exivaCommand: SlashCommandData = Commands.slash("exiva", "Show or hide exiva lists on death posts") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addSubcommands( - new SubcommandData("deaths", "Show or hide the exiva list in the deaths channel") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to show or hide the exiva list?").setRequired(true) - .addChoices( - new Choice("show", "show"), - new Choice("hide", "hide") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ) - ) - - // exiva command - private val helpCommand: SlashCommandData = Commands.slash("help", "Resend the welcome message & basic getting started information") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - - // recreate channel command - private val repairCommand: SlashCommandData = Commands.slash("repair", "Repair & recreate channels that have been deleted for a specific world") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addOptions( - new OptionData(OptionType.STRING, "world", "What world are you trying to recreate channels for?").setRequired(true), - ) - - // set galthen satchel reminder - private val galthenCommand: SlashCommandData = Commands.slash("galthen", "Use this to set a galthen satchel cooldown timer") - .addSubcommands( - new SubcommandData("satchel", "Use this to set a galthen satchel cooldown timer") - .addOptions( - new OptionData(OptionType.STRING, "character", "What character/tag is this for?") - ) - ) - - // online list config command - private val onlineCombineCommand: SlashCommandData = Commands.slash("online", "Configure how the online list is displayed") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .addSubcommands( - new SubcommandData("list", "Configure the online list") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to combine the list into one channel or keep them separate?").setRequired(true) - .addChoices( - new Choice("separate", "separate"), - new Choice("combine", "combine") - ), - new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) - ) - ) - - // online list config command - private val boostedCommand: SlashCommandData = Commands.slash("boosted", "Turn off these notifications or filter them") - .addOptions( - new OptionData(OptionType.STRING, "option", "Would you like to add/remove a boss or creature?").setRequired(true) - .addChoices( - new Choice("list", "list"), - new Choice("disable", "disable") - ) - ) - - lazy val commands = List(setupCommand, removeCommand, huntedCommand, alliesCommand, neutralsCommand, fullblessCommand, filterCommand, exivaCommand, helpCommand, repairCommand, onlineCombineCommand, boostedCommand, galthenCommand) + // Slash command schemas live in commands.CommandSchemas + lazy val commands = com.tibiabot.commands.CommandSchemas.commands // create the deaths/levels cache db createCacheDatabase() @@ -426,7 +191,7 @@ object BotApp extends App with StrictLogging { // initialize the database guilds.foreach{g => if (g.getIdLong == 867319250708463628L || g.getIdLong == 1082484147492237515L) { // Violent Bot Discords - val adminCommands = List(setupCommand, removeCommand, huntedCommand, alliesCommand, neutralsCommand, fullblessCommand, filterCommand, exivaCommand, helpCommand, repairCommand, onlineCombineCommand, boostedCommand, galthenCommand, adminCommand) + val adminCommands = com.tibiabot.commands.CommandSchemas.adminCommands g.updateCommands().addCommands(adminCommands.asJava).complete() } else { // update the commands @@ -457,14 +222,14 @@ object BotApp extends App with StrictLogging { "another 50k spent on twist" ) val randomActivityFromList = Random.shuffle(randomActivity).headOption.getOrElse("people press buttons") - jda.getPresence().setActivity(Activity.of(Activity.ActivityType.WATCHING, randomActivityFromList)) + discordGateway.setWatchingActivity(randomActivityFromList) } catch { case _: Throwable => logger.info("Failed to update the bot's status counts") } removeDeathsCache(ZonedDateTime.now()) removeLevelsCache(ZonedDateTime.now()) cleanHuntedList() - cleanGalthenList() + galthenService.cleanExpired() cleanOnlineListCache(30) updateOnOdd = 0 // Toggle the flag } else { @@ -472,7 +237,7 @@ object BotApp extends App with StrictLogging { } // Updating boosted creature/boss at server save val currentTime = ZonedDateTime.now(ZoneId.of("Europe/Berlin")).toLocalTime() - if (currentTime.isAfter(LocalTime.of(10, 0)) && currentTime.isBefore(LocalTime.of(10, 45))) { + if (ServerSaveSchedule.isServerSaveWindow(currentTime)) { try{ val now = System.currentTimeMillis() if (now - dreamScarLastCheck.toLong > 60L * 60 * 1000) { @@ -540,7 +305,7 @@ object BotApp extends App with StrictLogging { boostedMonsterUpdate("", "", "0", "0") // Do something if at least one of the embeds changed val embeds: List[MessageEmbed] = boostedInfoList.map { case (embed, _, _) => embed }.toList - val notificationsList: List[BoostedStamp] = boostedAll() + val notificationsList: List[BoostedStamp] = boostedService.boostedAll() notificationsList.foreach { entry => var matchedNotification = false boostedInfoList.foreach { case (_, _, boostedName) => @@ -549,7 +314,7 @@ object BotApp extends App with StrictLogging { } } if (matchedNotification) { - val user: User = jda.retrieveUserById(entry.user).complete() + val user: User = discordGateway.retrieveUser(entry.user) if (user != null) { try { user.openPrivateChannel().queue { privateChannel => @@ -565,7 +330,7 @@ object BotApp extends App with StrictLogging { } } - jda.getGuilds.asScala.foreach { guild => + discordGateway.guilds.foreach { guild => if (checkConfigDatabase(guild)) { val discordInfo = discordRetrieveConfig(guild) val channelId = if (discordInfo.nonEmpty) discordInfo("boosted_channel") else "0" @@ -585,16 +350,7 @@ object BotApp extends App with StrictLogging { val dreamScarDaily = dreamScar.getOrElse(lastWorld, "World not found") - val rashidLocation = - Map( - DayOfWeek.MONDAY -> "Svargrond", - DayOfWeek.TUESDAY -> "Liberty Bay", - DayOfWeek.WEDNESDAY -> "Port Hope", - DayOfWeek.THURSDAY -> "Ankrahmun", - DayOfWeek.FRIDAY -> "Darashia", - DayOfWeek.SATURDAY -> "Edron", - DayOfWeek.SUNDAY -> "Carlin" - ).getOrElse(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek, "Unknown") + val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) val rashidEmbed = new EmbedBuilder() rashidEmbed.setDescription(s"Today Rashid can be found in:\n### ${Config.indentEmoji}${Config.goldEmoji} **[${rashidLocation}](https://tibia.fandom.com/wiki/Rashid)**") rashidEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Rashid.gif") @@ -602,8 +358,7 @@ object BotApp extends App with StrictLogging { // Drome Timer val now = Instant.now() - val isAfterNow = dromeTime.isAfter(now) - val dromeShow = isAfterNow && java.time.Duration.between(now, dromeTime).toDays <= 3 + val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) val dromeEmbed = new EmbedBuilder() .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") @@ -661,140 +416,11 @@ object BotApp extends App with StrictLogging { } //WIP - private def boostedMonsterUpdate(boss: String, creature: String, bossChanged: String, creatureChanged: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - - val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") + private def boostedMonsterUpdate(boss: String, creature: String, bossChanged: String, creatureChanged: String): Unit = + cacheRepository.updateBoosted(boss, creature, bossChanged, creatureChanged) - val results = new ListBuffer[BoostedCache]() - while (result.next()) { - val boss = Option(result.getString("boss")).getOrElse("None") - val creature = Option(result.getString("creature")).getOrElse("None") - val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") - val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") - - results += BoostedCache(boss, creature, bossChanged, creatureChanged) - } - statement.close() - - if (results.isEmpty) { - // If the result list is empty, insert default values - val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") - insertStatement.setString(1, "None") // Default value for boss - insertStatement.setString(2, "None") // Default value for creature - insertStatement.setString(3, "0") - insertStatement.setString(4, "0") - insertStatement.executeUpdate() - insertStatement.close() - } - - // update category if exists - if (boss != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET boss = ?;") - statement.setString(1, boss) - statement.executeUpdate() - statement.close() - } - if (creature != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET creature = ?;") - statement.setString(1, creature) - statement.executeUpdate() - statement.close() - } - if (bossChanged != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET bosschanged = ?;") - statement.setString(1, bossChanged) - statement.executeUpdate() - statement.close() - } - if (creatureChanged != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET creaturechanged = ?;") - statement.setString(1, creatureChanged) - statement.executeUpdate() - statement.close() - } - - conn.close() - } - - private def boostedMessages(): List[BoostedCache] = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - - val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_info'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE boosted_info ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |boss VARCHAR(255) NOT NULL, - |bosschanged VARCHAR(255) NOT NULL, - |creature VARCHAR(255) NOT NULL, - |creaturechanged VARCHAR(255) NOT NULL - );""".stripMargin - - statement.executeUpdate(createListTable) - } - - // Check if the column already exists in the table - val bossChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'bosschanged'") - val bossChangedExists = bossChangedExistsQuery.next() - bossChangedExistsQuery.close() - - // Check if the column already exists in the table - val creatureChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'creaturechanged'") - val creatureChangedExists = creatureChangedExistsQuery.next() - creatureChangedExistsQuery.close() - - // Add the column if it doesn't exist - if (!bossChangedExists) { - statement.execute("ALTER TABLE boosted_info ADD COLUMN bosschanged VARCHAR(255) DEFAULT '0'") - } - - // Add the column if it doesn't exist - if (!creatureChangedExists) { - statement.execute("ALTER TABLE boosted_info ADD COLUMN creaturechanged VARCHAR(255) DEFAULT '0'") - } - - val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") - val results = new ListBuffer[BoostedCache]() - while (result.next()) { - val boss = Option(result.getString("boss")).getOrElse("None") - val creature = Option(result.getString("creature")).getOrElse("None") - val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") - val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") - results += BoostedCache(boss, creature, bossChanged, creatureChanged) - } - - if (results.isEmpty) { - // If the result list is empty, insert default values - val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") - insertStatement.setString(1, "None") // Default value for boss - insertStatement.setString(2, "None") // Default value for creature - insertStatement.setString(3, "0") - insertStatement.setString(4, "0") - insertStatement.executeUpdate() - insertStatement.close() - - results += BoostedCache("None", "None", "0", "0") - } - - statement.close() - conn.close() - results.toList - } + private def boostedMessages(): List[BoostedCache] = + cacheRepository.getBoosted() private def startBot(guild: Option[Guild], world: Option[String]): Unit = { @@ -805,11 +431,11 @@ object BotApp extends App with StrictLogging { //if (Config.verifiedDiscords.contains(guildId)) { // get hunted Players val huntedPlayers = playerConfig(guild.get, "hunted_players") - huntedPlayersData += (guildId -> huntedPlayers) + modifyHuntedPlayersData(_ + (guildId -> huntedPlayers)) // get allied Players val alliedPlayers = playerConfig(guild.get, "allied_players") - alliedPlayersData += (guildId -> alliedPlayers) + modifyAlliedPlayersData(_ + (guildId -> alliedPlayers)) // get hunted guilds val huntedGuilds = guildConfig(guild.get, "hunted_guilds") @@ -825,7 +451,7 @@ object BotApp extends App with StrictLogging { // get tracked activity characters val activityInfo = activityConfig(guild.get, "tracked_activity") - activityData += (guildId -> activityInfo) + modifyActivityData(_ + (guildId -> activityInfo)) // get customSort Data val customSortInfo = customSortConfig(guild.get, "online_list_categories") @@ -848,18 +474,12 @@ object BotApp extends App with StrictLogging { boostedMessage = boostedMessageId ) discordsData = discordsData.updated(w.name, discords :: discordsData.getOrElse(w.name, Nil)) - val botStream = if (botStreams.contains(world.get)) { - // If the stream already exists, update its usedBy list - val existingStream = botStreams(world.get) - val updatedUsedBy = existingStream.usedBy :+ discords - botStreams += (world.get -> existingStream.copy(usedBy = updatedUsedBy)) - existingStream - } else { - // If the stream doesn't exist, create a new one with an empty usedBy list - val bot = new TibiaBot(world.get) - Streams(bot.stream.run(), List(discords)) + // Preserves prior behaviour: when the world stream already exists it was + // left unchanged (the usedBy append was overwritten and never took effect); + // only an absent world starts a new stream. + if (!streamSupervisor.contains(world.get)) { + streamSupervisor.put(world.get, new TibiaBot(world.get).stream.run(), List(discords)) } - botStreams = botStreams + (world.get -> botStream) } } //} @@ -873,11 +493,11 @@ object BotApp extends App with StrictLogging { if (checkConfigDatabase(g)) { // get hunted Players val huntedPlayers = playerConfig(g, "hunted_players") - huntedPlayersData += (guildId -> huntedPlayers) + modifyHuntedPlayersData(_ + (guildId -> huntedPlayers)) // get allied Players val alliedPlayers = playerConfig(g, "allied_players") - alliedPlayersData += (guildId -> alliedPlayers) + modifyAlliedPlayersData(_ + (guildId -> alliedPlayers)) // get hunted guilds val huntedGuilds = guildConfig(g, "hunted_guilds") @@ -893,7 +513,7 @@ object BotApp extends App with StrictLogging { // get tracked activity characters val activityInfo = activityConfig(g, "tracked_activity") - activityData += (guildId -> activityInfo) + modifyActivityData(_ + (guildId -> activityInfo)) // get customSort Data val customSortInfo = customSortConfig(g, "online_list_categories") @@ -921,8 +541,7 @@ object BotApp extends App with StrictLogging { //} } discordsData.foreach { case (worldName, discordsList) => - val botStream = new TibiaBot(worldName) - botStreams += (worldName -> Streams(botStream.stream.run(), discordsList)) + streamSupervisor.put(worldName, new TibiaBot(worldName).stream.run(), discordsList) Thread.sleep(5500) // space each stream out 3 seconds } startUpComplete = true @@ -1146,8 +765,8 @@ object BotApp extends App with StrictLogging { val playerNamesToRemove = listPlayers.map(_.name.toLowerCase).toSet if (listGuilds.nonEmpty) { - activityDataLock.synchronized { - activityData = activityData.mapValues { + modifyActivityData { m => + m.mapValues { _.filterNot(pc => guildNamesToRemove.contains(pc.guild.toLowerCase)) }.toMap } @@ -1159,11 +778,11 @@ object BotApp extends App with StrictLogging { } if (listPlayers.nonEmpty) { - activityDataLock.synchronized { - val updatedList = activityData.getOrElse(guildId, List.empty) + modifyActivityData { m => + val updatedList = m.getOrElse(guildId, List.empty) .filterNot(player => playerNamesToRemove.contains(player.name.toLowerCase)) - activityData = activityData.updated(guildId, updatedList) + m.updated(guildId, updatedList) } listPlayers.foreach { filterPlayer => @@ -1189,8 +808,8 @@ object BotApp extends App with StrictLogging { val playerNamesToRemove = listPlayers.map(_.name.toLowerCase).toSet if (listGuilds.nonEmpty) { // Filter out activityData in one pass by using a Set for efficient lookup - activityDataLock.synchronized { - activityData = activityData.mapValues { + modifyActivityData { m => + m.mapValues { _.filterNot(pc => guildNamesToRemove.contains(pc.guild.toLowerCase)) }.toMap } @@ -1202,11 +821,11 @@ object BotApp extends App with StrictLogging { } if (listPlayers.nonEmpty) { // Efficiently update activityData by using Set lookups for player names - activityDataLock.synchronized { - val updatedList = activityData.getOrElse(guildId, List.empty) + modifyActivityData { m => + val updatedList = m.getOrElse(guildId, List.empty) .filterNot(player => playerNamesToRemove.contains(player.name.toLowerCase)) - activityData = activityData.updated(guildId, updatedList) + m.updated(guildId, updatedList) } // Perform database removal in a batch operation listPlayers.foreach { filterPlayer => @@ -1222,307 +841,14 @@ object BotApp extends App with StrictLogging { // } - private def getListTable(world: String): List[ListCache] = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'list'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE list ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |former_worlds VARCHAR(255), - |name VARCHAR(255) NOT NULL, - |former_names VARCHAR(1000), - |level VARCHAR(255) NOT NULL, - |guild_name VARCHAR(255), - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - statement.executeUpdate(createListTable) - } - - val result = statement.executeQuery(s"SELECT name,former_names,world,former_worlds,guild_name,level,vocation,last_login,time FROM list WHERE world = '$world';") - - val results = new ListBuffer[ListCache]() - while (result.next()) { - - val guildName = Option(result.getString("guild_name")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val formerNames = Option(result.getString("former_names")).getOrElse("") - val formerNamesList = formerNames.split(",").toList - val world = Option(result.getString("world")).getOrElse("") - val formerWorlds = Option(result.getString("former_worlds")).getOrElse("") - val formerWorldsList = formerWorlds.split(",").toList - val level = Option(result.getString("level")).getOrElse("") - val vocation = Option(result.getString("vocation")).getOrElse("") - val lastLogin = Option(result.getString("last_login")).getOrElse("") - val updatedTimeTemporal = Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z")) - val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) - - // ListCache(name: String, formerNames: List[String], world: String, formerWorlds: List[String], guild: String, level: String, vocation: String, last_login: String, updatedTime: ZonedDateTime) - results += ListCache(name, formerNamesList, world, formerWorldsList, guildName, level, vocation, lastLogin, updatedTime) - } - - statement.close() - conn.close() - results.toList - } - - // V1.6 Galthen Satchel Command - def getGalthenTable(userId: String): Option[List[SatchelStamp]] = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword + private def getListTable(world: String): List[ListCache] = + cacheRepository.getList(world) - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = - statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'satchel'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE satchel ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL, - |tag VARCHAR(255) - |);""".stripMargin - - statement.executeUpdate(createListTable) - } - - val result = statement.executeQuery(s"SELECT time,tag FROM satchel WHERE userid = '$userId';") - - val satchelStampList: ListBuffer[SatchelStamp] = ListBuffer() - - while (result.next()) { - val updatedTimeTemporal = - Try(Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z"))) - .getOrElse(Instant.parse("2022-01-01T01:00:00Z")) - val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) - val tag = Option(result.getString("tag")).getOrElse("") - - val satchelStamp = SatchelStamp(userId, updatedTime, tag) - satchelStampList += satchelStamp - } - - statement.close() - conn.close() - Some(satchelStampList.toList) - } - - def delGalthen(user: String, tag: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - - val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ? AND COALESCE(tag, '') = ?;") - deleteStatement.setString(1, user) - deleteStatement.setString(2, tag) - deleteStatement.executeUpdate() - - deleteStatement.close() - conn.close() - } - - def delAllGalthen(user: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - - val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ?;") - deleteStatement.setString(1, user) - deleteStatement.executeUpdate() - - deleteStatement.close() - conn.close() - } - - def addGalthen(user: String, when: ZonedDateTime, tag: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - val conn = DriverManager.getConnection(url, username, password) - val selectStatement = conn.prepareStatement("SELECT time FROM satchel WHERE userid = ? AND tag = ?;") - selectStatement.setString(1, user) - selectStatement.setString(2, tag) - val resultSet = selectStatement.executeQuery() - - if (resultSet.next()) { - // Update existing row - val updateStatement = conn.prepareStatement( - s""" - |UPDATE satchel - |SET time = ? - |WHERE userid = ? AND tag = ?; - |""".stripMargin - ) - updateStatement.setTimestamp(1, Timestamp.from(when.toInstant)) - updateStatement.setString(2, user) - updateStatement.setString(3, tag) - updateStatement.executeUpdate() - updateStatement.close() - } else { - // Insert new row - val insertStatement = conn.prepareStatement( - s""" - |INSERT INTO satchel(userid, time, tag) - |VALUES (?,?,?); - |""".stripMargin - ) - insertStatement.setString(1, user) - insertStatement.setTimestamp(2, Timestamp.from(when.toInstant)) - insertStatement.setString(3, tag) - insertStatement.executeUpdate() - insertStatement.close() - } - - selectStatement.close() - conn.close() - } - - def addListToCache(name: String, formerNames: List[String], world: String, formerWorlds: List[String], guild: String, level: String, vocation: String, lastLogin: String, updatedTime: ZonedDateTime): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val selectStatement = conn.prepareStatement("SELECT name FROM list WHERE LOWER(name) = LOWER(?);") - selectStatement.setString(1, name) - val resultSet = selectStatement.executeQuery() - - if (resultSet.next()) { - // Update existing row - val updateStatement = conn.prepareStatement( - s""" - |UPDATE list - |SET former_names = ?, world = ?, former_worlds = ?, guild_name = ?, level = ?, vocation = ?, last_login = ?, time = ? - |WHERE LOWER(name) = LOWER(?); - |""".stripMargin - ) - updateStatement.setString(1, formerNames.mkString(",")) - updateStatement.setString(2, world.capitalize) - updateStatement.setString(3, formerWorlds.mkString(",")) - updateStatement.setString(4, guild) - updateStatement.setString(5, level) - updateStatement.setString(6, vocation) - updateStatement.setString(7, lastLogin) - updateStatement.setTimestamp(8, Timestamp.from(updatedTime.toInstant)) - updateStatement.setString(9, name) - updateStatement.executeUpdate() - updateStatement.close() - } else { - // Insert new row - val insertStatement = conn.prepareStatement( - s""" - |INSERT INTO list(name, former_names, world, former_worlds, guild_name, level, vocation, last_login, time) - |VALUES (?,?,?,?,?,?,?,?,?); - |""".stripMargin - ) - insertStatement.setString(1, name) - insertStatement.setString(2, formerNames.mkString(",")) - insertStatement.setString(3, world.capitalize) - insertStatement.setString(4, formerWorlds.mkString(",")) - insertStatement.setString(5, guild) - insertStatement.setString(6, level) - insertStatement.setString(7, vocation) - insertStatement.setString(8, lastLogin) - insertStatement.setTimestamp(9, Timestamp.from(updatedTime.toInstant)) - insertStatement.executeUpdate() - insertStatement.close() - } - - selectStatement.close() - conn.close() - } - - private def cleanHuntedList(): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword + def addListToCache(name: String, formerNames: List[String], world: String, formerWorlds: List[String], guild: String, level: String, vocation: String, lastLogin: String, updatedTime: ZonedDateTime): Unit = + cacheRepository.addToList(name, formerNames, world, formerWorlds, guild, level, vocation, lastLogin, updatedTime) - val conn = DriverManager.getConnection(url, username, password) - - // Modify the DELETE statement to include a WHERE clause with the condition for time - val deleteStatement = conn.prepareStatement("DELETE FROM list WHERE time < ?;") - deleteStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(7, ChronoUnit.DAYS).toInstant)) - deleteStatement.executeUpdate() - deleteStatement.close() - conn.close() - } - - private def cleanGalthenList(): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - - // Retrieve the data before deletion - val selectStatement = conn.prepareStatement("SELECT userid,time,tag FROM satchel WHERE time < ?;") - selectStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(30, ChronoUnit.DAYS).toInstant)) - val resultSet = selectStatement.executeQuery() - - // Retrieve the data from the result set - while (resultSet.next()) { - val userId = resultSet.getString("userid") - val tagId = Option(resultSet.getString("tag")).getOrElse("") - val user: User = jda.retrieveUserById(userId).complete() - val userTimeStamp = resultSet.getTimestamp("time").toInstant() - val cooldown = userTimeStamp.plus(30, ChronoUnit.DAYS).getEpochSecond.toString() - - if (user != null) { - try { - user.openPrivateChannel().queue { privateChannel => - val embed = new EmbedBuilder() - if (tagId.nonEmpty) embed.setFooter(s"Tag: ${tagId.toLowerCase}") - val displayTag = if (tagId.nonEmpty) s"**`$tagId`**" else s"<@$userId>" - embed.setColor(178877) - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - embed.setDescription(s"<:satchel:1030348072577945651> cooldown for $displayTag expired \n\nMark it as **Collected** and I will message you when the 30 day cooldown expires.") - privateChannel.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenRemind", "Collected"), - Button.secondary("galthenClear", "Dismiss") - ).queue() - } - } catch { - case ex: Exception => // - } - } - } - - selectStatement.close() - - // Now you have the list of userids and time before deletion, you can proceed with deletion - val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE time < ?;") - deleteStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(30, ChronoUnit.DAYS).toInstant)) - deleteStatement.executeUpdate() - deleteStatement.close() - - conn.close() - } + private def cleanHuntedList(): Unit = + cacheRepository.removeExpiredList(ZonedDateTime.now()) def dateStringToEpochSeconds(dateString: String): String = { if (dateString != "") { @@ -1754,17 +1080,8 @@ object BotApp extends App with StrictLogging { } } - def vocEmoji(char: CharacterResponse): String = { - val voc = char.character.character.vocation.toLowerCase.split(' ').last - voc match { - case "knight" => ":shield:" - case "druid" => ":snowflake:" - case "sorcerer" => ":fire:" - case "paladin" => ":bow_and_arrow:" - case "none" => ":hatching_chick:" - case _ => "" - } - } + def vocEmoji(char: CharacterResponse): String = + presentation.Emojis.vocEmojiWithoutMonk(char.character.character.vocation) private def createWorldList(worlds: Map[String, List[String]]): List[String] = { val sortedWorlds = worlds.toList.sortBy(_._1) @@ -1779,15 +1096,9 @@ object BotApp extends App with StrictLogging { } } - def charUrl(char: String): String = { - val encodedString = URLEncoder.encode(char, StandardCharsets.UTF_8.toString) - s"https://www.tibia.com/community/?name=${encodedString}" - } + def charUrl(char: String): String = presentation.Urls.charUrl(char) - def guildUrl(guild: String): String = { - val encodedString = URLEncoder.encode(guild, StandardCharsets.UTF_8.toString) - s"https://www.tibia.com/community/?subtopic=guilds&page=view&GuildName=${encodedString}" - } + def guildUrl(guild: String): String = presentation.Urls.guildUrl(guild) def updateAdminChannel(inputId: String, channelId: String): Unit = { discordsData = discordsData.view.mapValues(_.map { @@ -1863,7 +1174,7 @@ object BotApp extends App with StrictLogging { val guildPlayers = activityData.getOrElse(guildId, List()) if (!guildPlayers.exists(_.name == member.name)) { val updatedTime = ZonedDateTime.now() - activityData = activityData + (guildId -> (PlayerCache(member.name, List(""), guildName, updatedTime) :: guildPlayers)) + modifyActivityData(m => m + (guildId -> (PlayerCache(member.name, List(""), guildName, updatedTime) :: guildPlayers))) addActivityToDatabase(guild, member.name, List(""), guildName, updatedTime) } } @@ -1897,7 +1208,7 @@ object BotApp extends App with StrictLogging { if (playerName != "") { if (!huntedPlayersData.getOrElse(guildId, List()).exists(g => g.name == subOptionValueLower)) { // add player to hunted list and database - huntedPlayersData = huntedPlayersData + (guildId -> (Players(subOptionValueLower, reason, subOptionReason, commandUser) :: huntedPlayersData.getOrElse(guildId, List()))) + modifyHuntedPlayersData(m => m + (guildId -> (Players(subOptionValueLower, reason, subOptionReason, commandUser) :: m.getOrElse(guildId, List())))) addHuntedToDatabase(guild, "player", subOptionValueLower, reason, subOptionReason, commandUser) embedText = s":gear: The player **[$playerName](${charUrl(playerName)})** has been added to the hunted list." @@ -1986,7 +1297,7 @@ object BotApp extends App with StrictLogging { guildMembers.foreach { member => val guildPlayers = alliedPlayersData.getOrElse(guildId, List()) if (!guildPlayers.exists(_.name == member.name)) { - alliedPlayersData = alliedPlayersData + (guildId -> (Players(member.name, "false", "this players guild was added to the hunted list", commandUser) :: guildPlayers)) + modifyAlliedPlayersData(m => m + (guildId -> (Players(member.name, "false", "this players guild was added to the hunted list", commandUser) :: guildPlayers))) addAllyToDatabase(guild, "player", member.name, "false", "this players guild was added to the allies list", commandUser) } } @@ -1997,7 +1308,7 @@ object BotApp extends App with StrictLogging { val guildPlayers = activityData.getOrElse(guildId, List()) if (!guildPlayers.exists(_.name == member.name)) { val updatedTime = ZonedDateTime.now() - activityData = activityData + (guildId -> (PlayerCache(member.name, List(""), guildName, updatedTime) :: guildPlayers)) + modifyActivityData(m => m + (guildId -> (PlayerCache(member.name, List(""), guildName, updatedTime) :: guildPlayers))) addActivityToDatabase(guild, member.name, List(""), guildName, updatedTime) } } @@ -2030,7 +1341,7 @@ object BotApp extends App with StrictLogging { }.map { case (playerName, world, vocation, level) => if (playerName != "") { if (!alliedPlayersData.getOrElse(guildId, List()).exists(g => g.name == subOptionValueLower)) { - alliedPlayersData = alliedPlayersData + (guildId -> (Players(subOptionValueLower, reason, subOptionReason, commandUser) :: alliedPlayersData.getOrElse(guildId, List()))) + modifyAlliedPlayersData(m => m + (guildId -> (Players(subOptionValueLower, reason, subOptionReason, commandUser) :: m.getOrElse(guildId, List())))) addAllyToDatabase(guild, "player", subOptionValueLower, reason, subOptionReason, commandUser) embedText = s":gear: The player **[$playerName](${charUrl(playerName)})** has been added to the allies list." @@ -2105,7 +1416,7 @@ object BotApp extends App with StrictLogging { huntedGuildsData = huntedGuildsData.updated(guildId, updatedList) removeHuntedFromDatabase(guild, "guild", subOptionValueLower) - activityData = activityData + (guildId -> activityData.getOrElse(guildId, List()).filterNot(_.guild.equalsIgnoreCase(subOptionValueLower))) + modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(_.guild.equalsIgnoreCase(subOptionValueLower)))) removeGuildActivityfromDatabase(guild, subOptionValueLower) // Remove players that the bot auto-hunted due to being in that guild from cache and db @@ -2114,9 +1425,9 @@ object BotApp extends App with StrictLogging { } val huntedPlayersList = huntedPlayersData.getOrElse(guildId, List()) val updatedHuntedPlayersList = huntedPlayersList.filterNot(player => filteredPlayers.exists(_.name == player.name)) - huntedPlayersData = huntedPlayersData.updated(guildId, updatedHuntedPlayersList) + modifyHuntedPlayersData(m => m.updated(guildId, updatedHuntedPlayersList)) - activityData = activityData + (guildId -> activityData.getOrElse(guildId, List()).filterNot(player => filteredPlayers.map(_.name.toLowerCase).contains(player.name.toLowerCase))) + modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(player => filteredPlayers.map(_.name.toLowerCase).contains(player.name.toLowerCase)))) filteredPlayers.foreach { filterPlayer => removeHuntedFromDatabase(guild, "player", filterPlayer.name) removePlayerActivityfromDatabase(guild, filterPlayer.name) @@ -2147,9 +1458,9 @@ object BotApp extends App with StrictLogging { if (filteredPlayers.nonEmpty){ val huntedPlayersList = huntedPlayersData.getOrElse(guildId, List()) val updatedHuntedPlayersList = huntedPlayersList.filterNot(player => filteredPlayers.exists(_.name == player.name)) - huntedPlayersData = huntedPlayersData.updated(guildId, updatedHuntedPlayersList) + modifyHuntedPlayersData(m => m.updated(guildId, updatedHuntedPlayersList)) - activityData = activityData + (guildId -> activityData.getOrElse(guildId, List()).filterNot(player => filteredPlayers.map(_.name.toLowerCase).contains(player.name.toLowerCase))) + modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(player => filteredPlayers.map(_.name.toLowerCase).contains(player.name.toLowerCase)))) filteredPlayers.foreach { filterPlayer => removeHuntedFromDatabase(guild, "player", filterPlayer.name) removePlayerActivityfromDatabase(guild, filterPlayer.name) @@ -2180,10 +1491,10 @@ object BotApp extends App with StrictLogging { case Some(_) => val updatedList = huntedPlayersList.filterNot(_.name.toLowerCase == subOptionValueLower) - huntedPlayersData = huntedPlayersData.updated(guildId, updatedList) + modifyHuntedPlayersData(m => m.updated(guildId, updatedList)) removeHuntedFromDatabase(guild, "player", subOptionValueLower) - activityData = activityData + (guildId -> activityData.getOrElse(guildId, List()).filterNot(_.name.equalsIgnoreCase(subOptionValueLower))) + modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(_.name.equalsIgnoreCase(subOptionValueLower)))) removePlayerActivityfromDatabase(guild, subOptionValueLower) // send embed to admin channel @@ -2249,7 +1560,7 @@ object BotApp extends App with StrictLogging { alliedGuildsData = alliedGuildsData.updated(guildId, updatedList) removeAllyFromDatabase(guild, "guild", subOptionValueLower) - activityData = activityData + (guildId -> activityData.getOrElse(guildId, List()).filterNot(_.guild.equalsIgnoreCase(subOptionValueLower))) + modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(_.guild.equalsIgnoreCase(subOptionValueLower)))) removeGuildActivityfromDatabase(guild, subOptionValueLower) // send embed to admin channel @@ -2293,10 +1604,10 @@ object BotApp extends App with StrictLogging { alliedPlayersList.find(_.name.toLowerCase == subOptionValueLower) match { case Some(_) => val updatedList = alliedPlayersList.filterNot(_.name.toLowerCase == subOptionValueLower) - alliedPlayersData = alliedPlayersData.updated(guildId, updatedList) + modifyAlliedPlayersData(m => m.updated(guildId, updatedList)) removeAllyFromDatabase(guild, "player", subOptionValueLower) - activityData = activityData + (guildId -> activityData.getOrElse(guildId, List()).filterNot(_.name.equalsIgnoreCase(subOptionValueLower))) + modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(_.name.equalsIgnoreCase(subOptionValueLower)))) removePlayerActivityfromDatabase(guild, subOptionValueLower) // send embed to admin channel @@ -2330,970 +1641,89 @@ object BotApp extends App with StrictLogging { } } - def addHuntedToDatabase(guild: Guild, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = { - val conn = getConnection(guild) - val table = (if (option == "guild") "hunted_guilds" else if (option == "player") "hunted_players").toString - val statement = conn.prepareStatement(s"INSERT INTO $table(name, reason, reason_text, added_by) VALUES (?,?,?,?) ON CONFLICT (name) DO NOTHING;") - statement.setString(1, name) - statement.setString(2, reason) - statement.setString(3, reasonText) - statement.setString(4, addedBy) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def addActivityToDatabase(guild: Guild, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement( - s""" - |INSERT INTO tracked_activity(name, former_names, guild_name, updated) - |VALUES (?,?,?,?) - |ON CONFLICT (name) - |DO UPDATE SET - | former_names = excluded.former_names, - | guild_name = excluded.guild_name, - | updated = excluded.updated; - |""".stripMargin - ) - statement.setString(1, name) - statement.setString(2, formerNames.mkString(",")) - statement.setString(3, guildName) - statement.setTimestamp(4, Timestamp.from(updatedTime.toInstant)) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def updateActivityToDatabase(guild: Guild, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime, newName: String): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement("UPDATE tracked_activity SET name = ?, former_names = ?, guild_name = ?, updated = ? WHERE LOWER(name) = LOWER(?);") - statement.setString(1, newName) - statement.setString(2, formerNames.mkString(",")) - statement.setString(3, guildName) - statement.setTimestamp(4, Timestamp.from(updatedTime.toInstant)) - statement.setString(5, name) - - try { - statement.executeUpdate() - } catch { - case e: PSQLException if e.getMessage.contains("duplicate key value") => - val deleteStatement = conn.prepareStatement("DELETE FROM tracked_activity WHERE LOWER(name) = LOWER(?);") - deleteStatement.setString(1, newName) - deleteStatement.executeUpdate() - deleteStatement.close() - - // Retry the update - val retryStatement = conn.prepareStatement("UPDATE tracked_activity SET name = ?, former_names = ?, guild_name = ?, updated = ? WHERE LOWER(name) = LOWER(?);") - retryStatement.setString(1, newName) - retryStatement.setString(2, formerNames.mkString(",")) - retryStatement.setString(3, guildName) - retryStatement.setTimestamp(4, Timestamp.from(updatedTime.toInstant)) - retryStatement.setString(5, name) - retryStatement.executeUpdate() - retryStatement.close() - } finally { - statement.close() - conn.close() - } - } - - def updateHuntedOrAllyNameToDatabase(guild: Guild, option: String, oldName: String, newName: String): Unit = { - val conn = getConnection(guild) - val table = if (option == "hunted") "hunted_players" else if (option == "allied") "allied_players" - - val statement = conn.prepareStatement(s"UPDATE $table SET name = ? WHERE LOWER(name) = LOWER(?);") - statement.setString(1, newName) - statement.setString(2, oldName) - - try { - statement.executeUpdate() - } catch { - case e: PSQLException if e.getMessage.contains("duplicate key value") => - // Handle duplicate key error - val deleteStatement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") - deleteStatement.setString(1, newName) - deleteStatement.executeUpdate() - deleteStatement.close() - - // Retry the update within the same transaction - val retryStatement = conn.prepareStatement(s"UPDATE $table SET name = ? WHERE LOWER(name) = LOWER(?);") - retryStatement.setString(1, newName) - retryStatement.setString(2, oldName) - retryStatement.executeUpdate() - retryStatement.close() - } finally { - statement.close() - conn.close() - } - } - - private def addAllyToDatabase(guild: Guild, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = { - val conn = getConnection(guild) - val table = (if (option == "guild") "allied_guilds" else if (option == "player") "allied_players").toString - val statement = conn.prepareStatement(s"INSERT INTO $table(name, reason, reason_text, added_by) VALUES (?,?,?,?) ON CONFLICT (name) DO NOTHING;") - statement.setString(1, name) - statement.setString(2, reason) - statement.setString(3, reasonText) - statement.setString(4, addedBy) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def removeHuntedFromDatabase(guild: Guild, option: String, name: String): Unit = { - val conn = getConnection(guild) - val table = (if (option == "guild") "hunted_guilds" else if (option == "player") "hunted_players").toString - val statement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") - statement.setString(1, name) - statement.executeUpdate() - - statement.close() - conn.close() - } - - private def removeGuildActivityfromDatabase(guild: Guild, guildName: String): Unit = { - val conn = getConnection(guild) - - val statement = conn.prepareStatement(s"DELETE FROM tracked_activity WHERE LOWER(guild_name) = LOWER(?);") - statement.setString(1, guildName) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def removePlayerActivityfromDatabase(guild: Guild, playerName: String): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement(s"DELETE FROM tracked_activity WHERE LOWER(name) = LOWER(?);") - statement.setString(1, playerName) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def removeAllyFromDatabase(guild: Guild, option: String, name: String): Unit = { - val conn = getConnection(guild) - val table = (if (option == "guild") "allied_guilds" else if (option == "player") "allied_players").toString - val statement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") - statement.setString(1, name) - statement.executeUpdate() - - statement.close() - conn.close() - } - - private def checkConfigDatabase(guild: Guild): Boolean = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/postgres" - val username = "postgres" - val password = Config.postgresPassword - val guildId = guild.getId - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") - val exist = result.next() - - statement.close() - conn.close() - - // check if database for discord exists - if (exist) { - true - } else { - false - } - } - - private def createPremiumDatabase(): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/postgres" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'premium'") - val exist = result.next() - - // if bot_configuration doesn't exist - if (!exist) { - statement.executeUpdate(s"CREATE DATABASE bot_cache;") - logger.info(s"Database 'bot_cache' created successfully") - statement.close() - conn.close() - - val newUrl = s"jdbc:postgresql://${Config.postgresHost}:5432/premium" - val newConn = DriverManager.getConnection(newUrl, username, password) - val newStatement = newConn.createStatement() - // create the tables in bot_configuration - val createPaymentsTable = - s"""CREATE TABLE payments ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |discord_id VARCHAR(255) NOT NULL, - |discord_name VARCHAR(255) NOT NULL, - |user_id VARCHAR(255) NOT NULL, - |user_name VARCHAR(255) NOT NULL, - |expiry VARCHAR(255) NOT NULL - |);""".stripMargin - - newStatement.executeUpdate(createPaymentsTable) - logger.info("Table 'payments' created successfully") - newStatement.close() - newConn.close() - } else { - statement.close() - conn.close() - } - } - - private def createCacheDatabase(): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/postgres" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'bot_cache'") - val exist = result.next() - - // if bot_configuration doesn't exist - if (!exist) { - statement.executeUpdate(s"CREATE DATABASE bot_cache;") - logger.info(s"Database 'bot_cache' created successfully") - statement.close() - conn.close() - - val newUrl = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val newConn = DriverManager.getConnection(newUrl, username, password) - val newStatement = newConn.createStatement() - // create the tables in bot_configuration - val createDeathsTable = - s"""CREATE TABLE deaths ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createLevelsTable = - s"""CREATE TABLE levels ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |level VARCHAR(255) NOT NULL, - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createListTable = - s"""CREATE TABLE list ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |former_worlds VARCHAR(255), - |name VARCHAR(255) NOT NULL, - |former_names VARCHAR(1000), - |level VARCHAR(255) NOT NULL, - |guild_name VARCHAR(255), - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createGalthenTable = - s"""CREATE TABLE satchel ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL, - |tag VARCHAR(255) - |);""".stripMargin - - newStatement.executeUpdate(createDeathsTable) - logger.info("Table 'deaths' created successfully") - newStatement.executeUpdate(createLevelsTable) - logger.info("Table 'levels' created successfully") - newStatement.executeUpdate(createListTable) - logger.info("Table 'list' created successfully") - newStatement.executeUpdate(createGalthenTable) - logger.info("Table 'galthen' created successfully") - newStatement.close() - newConn.close() - } else { - statement.close() - conn.close() - } - } - - def getDeathsCache(world: String): List[DeathsCache] = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT world,name,time FROM deaths WHERE world = '$world';") - - val results = new ListBuffer[DeathsCache]() - while (result.next()) { - val world = Option(result.getString("world")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val time = Option(result.getString("time")).getOrElse("") - results += DeathsCache(world, name, time) - } - - statement.close() - conn.close() - results.toList - } - - def addDeathsCache(world: String, name: String, time: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword + def addHuntedToDatabase(guild: Guild, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = + huntedAlliedRepository.addHunted(guild.getId, option, name, reason, reasonText, addedBy) - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.prepareStatement("INSERT INTO deaths(world,name,time) VALUES (?, ?, ?);") - statement.setString(1, world) - statement.setString(2, name) - statement.setString(3, time) - statement.executeUpdate() + def addActivityToDatabase(guild: Guild, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime): Unit = + activityRepository.add(guild.getId, name, formerNames, guildName, updatedTime) - statement.close() - conn.close() - } + def updateActivityToDatabase(guild: Guild, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime, newName: String): Unit = + activityRepository.update(guild.getId, name, formerNames, guildName, updatedTime, newName) - private def removeDeathsCache(time: ZonedDateTime): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword + def updateHuntedOrAllyNameToDatabase(guild: Guild, option: String, oldName: String, newName: String): Unit = + huntedAlliedRepository.rename(guild.getId, option, oldName, newName) - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT id,time from deaths;") - val results = new ListBuffer[Long]() - while (result.next()) { - val id = Option(result.getLong("id")).getOrElse(0L) - val timeDb = Option(result.getString("time")).getOrElse("") - val timeToDate = ZonedDateTime.parse(timeDb) - if (time.isAfter(timeToDate.plusMinutes(30)) && id != 0L) { - results += id - } - } - results.foreach { uid => - statement.executeUpdate(s"DELETE from deaths where id = $uid;") - } - statement.close() - conn.close() - } + private def addAllyToDatabase(guild: Guild, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = + huntedAlliedRepository.addAllied(guild.getId, option, name, reason, reasonText, addedBy) - def getLevelsCache(world: String): List[LevelsCache] = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword + def removeHuntedFromDatabase(guild: Guild, option: String, name: String): Unit = + huntedAlliedRepository.removeHunted(guild.getId, option, name) - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT world,name,level,vocation,last_login,time FROM levels WHERE world = '$world';") - - val results = new ListBuffer[LevelsCache]() - while (result.next()) { - val world = Option(result.getString("world")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val level = Option(result.getString("level")).getOrElse("") - val vocation = Option(result.getString("vocation")).getOrElse("") - val lastLogin = Option(result.getString("last_login")).getOrElse("") - val time = Option(result.getString("time")).getOrElse("") - results += LevelsCache(world, name, level, vocation, lastLogin, time) - } + private def removeGuildActivityfromDatabase(guild: Guild, guildName: String): Unit = + activityRepository.removeByGuild(guild.getId, guildName) - statement.close() - conn.close() - results.toList - } + def removePlayerActivityfromDatabase(guild: Guild, playerName: String): Unit = + activityRepository.removeByName(guild.getId, playerName) - def addLevelsCache(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.prepareStatement("INSERT INTO levels(world,name,level,vocation,last_login,time) VALUES (?, ?, ?, ?, ?, ?);") - statement.setString(1, world) - statement.setString(2, name) - statement.setString(3, level) - statement.setString(4, vocation) - statement.setString(5, lastLogin) - statement.setString(6, time) - statement.executeUpdate() - - statement.close() - conn.close() - } + def removeAllyFromDatabase(guild: Guild, option: String, name: String): Unit = + huntedAlliedRepository.removeAllied(guild.getId, option, name) - private def removeLevelsCache(time: ZonedDateTime): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword + private def checkConfigDatabase(guild: Guild): Boolean = schemaInitializer.guildDatabaseExists(guild.getId) - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT id,time from levels;") - val results = new ListBuffer[Long]() - while (result.next()) { - val id = Option(result.getLong("id")).getOrElse(0L) - val timeDb = Option(result.getString("time")).getOrElse("") - val timeToDate = ZonedDateTime.parse(timeDb) - if (time.isAfter(timeToDate.plusHours(25)) && id != 0L) { - results += id - } - } - results.foreach { uid => - statement.executeUpdate(s"DELETE from levels where id = $uid;") - } - statement.close() - conn.close() - } + private def createPremiumDatabase(): Unit = schemaInitializer.initPremium() - private def createConfigDatabase(guild: Guild): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/postgres" - val username = "postgres" - val password = Config.postgresPassword - val guildId = guild.getId - val guildName = guild.getName + private def createCacheDatabase(): Unit = schemaInitializer.initCache() - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") - val exist = result.next() + def getDeathsCache(world: String): List[DeathsCache] = cacheRepository.getDeaths(world) - // if bot_configuration doesn't exist - if (!exist) { - statement.executeUpdate(s"CREATE DATABASE _$guildId;") - logger.info(s"Database '$guildId' for discord '$guildName' created successfully") - statement.close() - conn.close() + def addDeathsCache(world: String, name: String, time: String): Unit = + cacheRepository.addDeath(world, name, time) - val newUrl = s"jdbc:postgresql://${Config.postgresHost}:5432/_$guildId" - val newConn = DriverManager.getConnection(newUrl, username, password) - val newStatement = newConn.createStatement() - // create the tables in bot_configuration - val createDiscordInfoTable = - s"""CREATE TABLE discord_info ( - |guild_name VARCHAR(255) NOT NULL, - |guild_owner VARCHAR(255) NOT NULL, - |admin_category VARCHAR(255) NOT NULL, - |admin_channel VARCHAR(255) NOT NULL, - |boosted_channel VARCHAR(255) NOT NULL, - |boosted_messageid VARCHAR(255) NOT NULL, - |flags VARCHAR(255) NOT NULL, - |created TIMESTAMP NOT NULL, - |PRIMARY KEY (guild_name) - |);""".stripMargin - - val createHuntedPlayersTable = - s"""CREATE TABLE hunted_players ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin - - val createHuntedGuildsTable = - s"""CREATE TABLE hunted_guilds ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin - - val createAlliedPlayersTable = - s"""CREATE TABLE allied_players ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin - - val createAlliedGuildsTable = - s"""CREATE TABLE allied_guilds ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin - - val createWorldsTable = - s"""CREATE TABLE worlds ( - |name VARCHAR(255) NOT NULL, - |allies_channel VARCHAR(255) NOT NULL, - |enemies_channel VARCHAR(255) NOT NULL, - |neutrals_channel VARCHAR(255) NOT NULL, - |levels_channel VARCHAR(255) NOT NULL, - |deaths_channel VARCHAR(255) NOT NULL, - |category VARCHAR(255) NOT NULL, - |fullbless_role VARCHAR(255) NOT NULL, - |nemesis_role VARCHAR(255) NOT NULL, - |allypk_role VARCHAR(255) NOT NULL, - |masslog_role VARCHAR(255) NOT NULL, - |fullbless_channel VARCHAR(255) NOT NULL, - |nemesis_channel VARCHAR(255) NOT NULL, - |fullbless_level INT NOT NULL, - |show_neutral_levels VARCHAR(255) NOT NULL, - |show_neutral_deaths VARCHAR(255) NOT NULL, - |show_allies_levels VARCHAR(255) NOT NULL, - |show_allies_deaths VARCHAR(255) NOT NULL, - |show_enemies_levels VARCHAR(255) NOT NULL, - |show_enemies_deaths VARCHAR(255) NOT NULL, - |detect_hunteds VARCHAR(255) NOT NULL, - |levels_min INT NOT NULL, - |deaths_min INT NOT NULL, - |exiva_list VARCHAR(255) NOT NULL, - |online_combined VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin - - newStatement.executeUpdate(createDiscordInfoTable) - logger.info("Table 'discord_info' created successfully") - newStatement.executeUpdate(createHuntedPlayersTable) - logger.info("Table 'hunted_players' created successfully") - newStatement.executeUpdate(createHuntedGuildsTable) - logger.info("Table 'hunted_guilds' created successfully") - newStatement.executeUpdate(createAlliedPlayersTable) - logger.info("Table 'allied_players' created successfully") - newStatement.executeUpdate(createAlliedGuildsTable) - logger.info("Table 'allied_guilds' created successfully") - newStatement.executeUpdate(createWorldsTable) - logger.info("Table 'worlds' created successfully") - newStatement.close() - newConn.close() - } else { - logger.info(s"Database '$guildId' already exists") - statement.close() - conn.close() - } - } + private def removeDeathsCache(time: ZonedDateTime): Unit = + cacheRepository.removeExpiredDeaths(time) - private def getConnection(guild: Guild): Connection = { - val guildId = guild.getId - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/_$guildId" - val username = "postgres" - val password = Config.postgresPassword - DriverManager.getConnection(url, username, password) - } - - private def playerConfig(guild: Guild, query: String): List[Players] = { - val conn = getConnection(guild) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT name,reason,reason_text,added_by FROM $query") - - val results = new ListBuffer[Players]() - while (result.next()) { - val name = Option(result.getString("name")).getOrElse("") - val reason = Option(result.getString("reason")).getOrElse("") - val reasonText = Option(result.getString("reason_text")).getOrElse("") - val addedBy = Option(result.getString("added_by")).getOrElse("") - results += Players(name, reason, reasonText, addedBy) - } - - statement.close() - conn.close() - results.toList - } - - private def guildConfig(guild: Guild, query: String): List[Guilds] = { - val conn = getConnection(guild) - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT name,reason,reason_text,added_by FROM $query") - - val results = new ListBuffer[Guilds]() - while (result.next()) { - val name = Option(result.getString("name")).getOrElse("") - val reason = Option(result.getString("reason")).getOrElse("") - val reasonText = Option(result.getString("reason_text")).getOrElse("") - val addedBy = Option(result.getString("added_by")).getOrElse("") - results += Guilds(name, reason, reasonText, addedBy) - } - - statement.close() - conn.close() - results.toList - } - - private def activityConfig(guild: Guild, query: String): List[PlayerCache] = { - val conn = getConnection(guild) - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'tracked_activity'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createActivityTable = - s"""CREATE TABLE tracked_activity ( - |name VARCHAR(255) NOT NULL, - |former_names VARCHAR(255) NOT NULL, - |guild_name VARCHAR(255) NOT NULL, - |updated TIMESTAMP NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin - - statement.executeUpdate(createActivityTable) - } + def getLevelsCache(world: String): List[LevelsCache] = cacheRepository.getLevels(world) - val result = statement.executeQuery(s"SELECT name,former_names,guild_name,updated FROM $query") + def addLevelsCache(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String): Unit = + cacheRepository.addLevel(world, name, level, vocation, lastLogin, time) - val results = new ListBuffer[PlayerCache]() - while (result.next()) { - val name = Option(result.getString("name")).getOrElse("") - val formerNames = Option(result.getString("former_names")).getOrElse("") - val guildName = Option(result.getString("guild_name")).getOrElse("") - val formerNamesList = formerNames.split(",").toList - val updatedTimeTemporal = Option(result.getTimestamp("updated").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z")) - val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) + private def removeLevelsCache(time: ZonedDateTime): Unit = + cacheRepository.removeExpiredLevels(time) - results += PlayerCache(name, formerNamesList, guildName, updatedTime) - } + private def createConfigDatabase(guild: Guild): Unit = schemaInitializer.initGuild(guild.getId, guild.getName) - statement.close() - conn.close() - results.toList - } + private def getConnection(guild: Guild): Connection = + connectionProvider.guild(guild.getId) - def discordRetrieveConfig(guild: Guild): Map[String, String] = { - val conn = getConnection(guild) - val statement = conn.createStatement() + private def playerConfig(guild: Guild, query: String): List[Players] = + huntedAlliedRepository.getPlayers(guild.getId, query) - val channelExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_channel'") - val channelExists = channelExistsQuery.next() - channelExistsQuery.close() + private def guildConfig(guild: Guild, query: String): List[Guilds] = + huntedAlliedRepository.getGuilds(guild.getId, query) - // Add the column if it doesn't exist - if (!channelExists) { - statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_channel VARCHAR(255) DEFAULT '0'") - } + private def activityConfig(guild: Guild, query: String): List[PlayerCache] = + activityRepository.getActivity(guild.getId) - val lastWorldExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'last_world'") - val lastWorldExists = lastWorldExistsQuery.next() - lastWorldExistsQuery.close() + def discordRetrieveConfig(guild: Guild): Map[String, String] = + discordConfigRepository.getConfig(guild.getId) - // Add the column if it doesn't exist - if (!lastWorldExists) { - statement.execute("ALTER TABLE discord_info ADD COLUMN last_world VARCHAR(255) DEFAULT '0'") - } + private def worldConfig(guild: Guild): List[Worlds] = + worldConfigRepository.listWorlds(guild.getId) - val messageExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_messageid'") - val messageExists = messageExistsQuery.next() - messageExistsQuery.close() + private def worldCreateConfig(guild: Guild, world: String, alliesChannel: String, enemiesChannel: String, neutralsChannels: String, levelsChannel: String, deathsChannel: String, category: String, fullblessRole: String, nemesisRole: String, allyPkRole: String, masslogRole: String, fullblessChannel: String, nemesisChannel: String, activityChannel: String): Unit = + worldConfigRepository.createWorld(guild.getId, world, alliesChannel, enemiesChannel, neutralsChannels, levelsChannel, deathsChannel, category, fullblessRole, nemesisRole, allyPkRole, masslogRole, fullblessChannel, nemesisChannel, activityChannel) - // Add the column if it doesn't exist - if (!messageExists) { - statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_messageid VARCHAR(255) DEFAULT '0'") - } + private def discordCreateConfig(guild: Guild, guildName: String, guildOwner: String, adminCategory: String, adminChannel: String, boostedChannel: String, boostedMessageId: String, created: ZonedDateTime): Unit = + discordConfigRepository.create(guild.getId, guildName, guildOwner, adminCategory, adminChannel, boostedChannel, boostedMessageId, created) - val result = statement.executeQuery(s"SELECT * FROM discord_info") - var configMap = Map[String, String]() - while (result.next()) { - configMap += ("guild_name" -> result.getString("guild_name")) - configMap += ("guild_owner" -> result.getString("guild_owner")) - configMap += ("admin_category" -> result.getString("admin_category")) - configMap += ("admin_channel" -> result.getString("admin_channel")) - configMap += ("boosted_channel" -> result.getString("boosted_channel")) - configMap += ("boosted_messageid" -> result.getString("boosted_messageid")) - configMap += ("last_world" -> result.getString("last_world")) - configMap += ("flags" -> result.getString("flags")) - configMap += ("created" -> result.getString("created")) - } + private def discordUpdateConfig(guild: Guild, adminCategory: String, adminChannel: String, boostedChannel: String, boostedMessage: String, lastWorld: String): Unit = + discordConfigRepository.update(guild.getId, adminCategory, adminChannel, boostedChannel, boostedMessage, lastWorld) - statement.close() - conn.close() - configMap - } - - private def worldConfig(guild: Guild): List[Worlds] = { - val conn = getConnection(guild) - val statement = conn.createStatement() - - - // Check if the column already exists in the table - val columnExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'exiva_list'") - val columnExists = columnExistsQuery.next() - columnExistsQuery.close() - - // Add the column if it doesn't exist - if (!columnExists) { - statement.execute("ALTER TABLE worlds ADD COLUMN exiva_list VARCHAR(255) DEFAULT 'false'") - } + def worldRetrieveConfig(guild: Guild, world: String): Map[String, String] = + worldConfigRepository.retrieveWorld(guild.getId, world) - // Check if the column already exists in the table - val allyPkExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'allypk_role'") - val allyPkExists = allyPkExistsQuery.next() - allyPkExistsQuery.close() - - // Add the allyPk if it doesn't exist - if (!allyPkExists) { - statement.execute("ALTER TABLE worlds ADD COLUMN allypk_role VARCHAR(255) DEFAULT '0'") - } - - // Check if the column already exists in the table - val masslogExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'masslog_role'") - val masslogExists = masslogExistsQuery.next() - masslogExistsQuery.close() - - // Add the allyPk if it doesn't exist - if (!masslogExists) { - statement.execute("ALTER TABLE worlds ADD COLUMN masslog_role VARCHAR(255) DEFAULT '0'") - } - - // Check if the column already exists in the table - val activityExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'activity_channel'") - val activityExists = activityExistsQuery.next() - activityExistsQuery.close() - - // Add the column if it doesn't exist - if (!activityExists) { - statement.execute("ALTER TABLE worlds ADD COLUMN activity_channel VARCHAR(255) DEFAULT '0'") - } - - // Check if the column already exists in the table - val onlineCombinedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'online_combined'") - val onlineCombinedExists = onlineCombinedExistsQuery.next() - onlineCombinedExistsQuery.close() - - // Add the column if it doesn't exist - if (!onlineCombinedExists) { - statement.execute("ALTER TABLE worlds ADD COLUMN online_combined VARCHAR(255) DEFAULT 'false'") - } - - val result = statement.executeQuery(s"SELECT name,allies_channel,enemies_channel,neutrals_channel,levels_channel,deaths_channel,category,fullbless_role,nemesis_role,allypk_role,masslog_role,fullbless_channel,nemesis_channel,fullbless_level,show_neutral_levels,show_neutral_deaths,show_allies_levels,show_allies_deaths,show_enemies_levels,show_enemies_deaths,detect_hunteds,levels_min,deaths_min,exiva_list,activity_channel,online_combined FROM worlds") - - val results = new ListBuffer[Worlds]() - while (result.next()) { - val name = Option(result.getString("name")).getOrElse("") - val alliesChannel = Option(result.getString("allies_channel")).getOrElse(null) - val enemiesChannel = Option(result.getString("enemies_channel")).getOrElse(null) - val neutralsChannel = Option(result.getString("neutrals_channel")).getOrElse(null) - val levelsChannel = Option(result.getString("levels_channel")).getOrElse(null) - val deathsChannel = Option(result.getString("deaths_channel")).getOrElse(null) - val category = Option(result.getString("category")).getOrElse(null) - val fullblessRole = Option(result.getString("fullbless_role")).getOrElse(null) - val nemesisRole = Option(result.getString("nemesis_role")).getOrElse(null) - val allyPkRole = Option(result.getString("allypk_role")).getOrElse(null) - val masslogRole = Option(result.getString("masslog_role")).getOrElse(null) - val fullblessChannel = Option(result.getString("fullbless_channel")).getOrElse(null) - val nemesisChannel = Option(result.getString("nemesis_channel")).getOrElse(null) - val fullblessLevel = Option(result.getInt("fullbless_level")).getOrElse(250) - val showNeutralLevels = Option(result.getString("show_neutral_levels")).getOrElse("true") - val showNeutralDeaths = Option(result.getString("show_neutral_deaths")).getOrElse("true") - val showAlliesLevels = Option(result.getString("show_allies_levels")).getOrElse("true") - val showAlliesDeaths = Option(result.getString("show_allies_deaths")).getOrElse("true") - val showEnemiesLevels = Option(result.getString("show_enemies_levels")).getOrElse("true") - val showEnemiesDeaths = Option(result.getString("show_enemies_deaths")).getOrElse("true") - val detectHunteds = Option(result.getString("detect_hunteds")).getOrElse("on") - val levelsMin = Option(result.getInt("levels_min")).getOrElse(8) - val deathsMin = Option(result.getInt("deaths_min")).getOrElse(8) - val exivaList = Option(result.getString("exiva_list")).getOrElse("false") - val activityChannel = Option(result.getString("activity_channel")).getOrElse(null) - val onlineCombined = Option(result.getString("online_combined")).getOrElse(null) - - // Ignore merged worlds (they are now effectively inactive and ignored but their data still exists in the db) - if (!Config.mergedWorlds.exists(_.equalsIgnoreCase(name))) { - results += Worlds(name, alliesChannel, enemiesChannel, neutralsChannel, levelsChannel, deathsChannel, category, fullblessRole, nemesisRole, allyPkRole, masslogRole, fullblessChannel, nemesisChannel, fullblessLevel, showNeutralLevels, showNeutralDeaths, showAlliesLevels, showAlliesDeaths, showEnemiesLevels, showEnemiesDeaths, detectHunteds, levelsMin, deathsMin, exivaList, activityChannel, onlineCombined) - } - } - - statement.close() - conn.close() - results.toList - } - - private def worldCreateConfig(guild: Guild, world: String, alliesChannel: String, enemiesChannel: String, neutralsChannels: String, levelsChannel: String, deathsChannel: String, category: String, fullblessRole: String, nemesisRole: String, allyPkRole: String, masslogRole: String, fullblessChannel: String, nemesisChannel: String, activityChannel: String): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement("INSERT INTO worlds(name, allies_channel, enemies_channel, neutrals_channel, levels_channel, deaths_channel, category, fullbless_role, nemesis_role, allypk_role, masslog_role, fullbless_channel, nemesis_channel, fullbless_level, show_neutral_levels, show_neutral_deaths, show_allies_levels, show_allies_deaths, show_enemies_levels, show_enemies_deaths, detect_hunteds, levels_min, deaths_min, exiva_list, activity_channel, online_combined) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO UPDATE SET allies_channel = ?, enemies_channel = ?, neutrals_channel = ?, levels_channel = ?, deaths_channel = ?, category = ?, fullbless_role = ?, nemesis_role = ?, allypk_role = ?, masslog_role = ?, fullbless_channel = ?, nemesis_channel = ?, fullbless_level = ?, show_neutral_levels = ?, show_neutral_deaths = ?, show_allies_levels = ?, show_allies_deaths = ?, show_enemies_levels = ?, show_enemies_deaths = ?, detect_hunteds = ?, levels_min = ?, deaths_min = ?, exiva_list = ?, activity_channel = ?, online_combined = ?;") - val formalQuery = world.toLowerCase().capitalize - statement.setString(1, formalQuery) - statement.setString(2, alliesChannel) - statement.setString(3, enemiesChannel) - statement.setString(4, neutralsChannels) - statement.setString(5, levelsChannel) - statement.setString(6, deathsChannel) - statement.setString(7, category) - statement.setString(8, fullblessRole) - statement.setString(9, nemesisRole) - statement.setString(10, allyPkRole) - statement.setString(11, masslogRole) - statement.setString(12, fullblessChannel) - statement.setString(13, nemesisChannel) - statement.setInt(14, 250) - statement.setString(15, "true") - statement.setString(16, "true") - statement.setString(17, "true") - statement.setString(18, "true") - statement.setString(19, "true") - statement.setString(20, "true") - statement.setString(21, "on") - statement.setInt(22, 8) - statement.setInt(23, 8) - statement.setString(24, "false") - statement.setString(25, activityChannel) - statement.setString(26, "true") - statement.setString(27, alliesChannel) - statement.setString(28, enemiesChannel) - statement.setString(29, neutralsChannels) - statement.setString(30, levelsChannel) - statement.setString(31, deathsChannel) - statement.setString(32, category) - statement.setString(33, fullblessRole) - statement.setString(34, nemesisRole) - statement.setString(35, allyPkRole) - statement.setString(36, masslogRole) - statement.setString(37, fullblessChannel) - statement.setString(38, nemesisChannel) - statement.setInt(39, 250) - statement.setString(40, "true") - statement.setString(41, "true") - statement.setString(42, "true") - statement.setString(43, "true") - statement.setString(44, "true") - statement.setString(45, "true") - statement.setString(46, "on") - statement.setInt(47, 8) - statement.setInt(48, 8) - statement.setString(49, "false") - statement.setString(50, activityChannel) - statement.setString(51, "true") - statement.executeUpdate() - - statement.close() - conn.close() - } - - private def discordCreateConfig(guild: Guild, guildName: String, guildOwner: String, adminCategory: String, adminChannel: String, boostedChannel: String, boostedMessageId: String, created: ZonedDateTime): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement("INSERT INTO discord_info(guild_name, guild_owner, admin_category, admin_channel, boosted_channel, boosted_messageid, flags, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(guild_name) DO UPDATE SET guild_owner = EXCLUDED.guild_owner, admin_category = EXCLUDED.admin_category, admin_channel = EXCLUDED.admin_channel, boosted_channel = EXCLUDED.boosted_channel, boosted_messageid = EXCLUDED.boosted_messageid, flags = EXCLUDED.flags, created = EXCLUDED.created;") - statement.setString(1, guildName) - statement.setString(2, guildOwner) - statement.setString(3, adminCategory) - statement.setString(4, adminChannel) - statement.setString(5, boostedChannel) - statement.setString(6, boostedMessageId) - statement.setString(7, "none") - statement.setTimestamp(8, Timestamp.from(created.toInstant)) - statement.executeUpdate() - - statement.close() - conn.close() - } - - private def discordUpdateConfig(guild: Guild, adminCategory: String, adminChannel: String, boostedChannel: String, boostedMessage: String, lastWorld: String): Unit = { - val conn = getConnection(guild) - // update category if exists - if (adminCategory != "") { - val statement = conn.prepareStatement("UPDATE discord_info SET admin_category = ?;") - statement.setString(1, adminCategory) - statement.executeUpdate() - statement.close() - } - if (adminChannel != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET admin_channel = ?;") - statement.setString(1, adminChannel) - statement.executeUpdate() - statement.close() - } - - if (boostedChannel != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET boosted_channel = ?;") - statement.setString(1, boostedChannel) - statement.executeUpdate() - statement.close() - } - - if (boostedMessage != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET boosted_messageid = ?;") - statement.setString(1, boostedMessage) - statement.executeUpdate() - statement.close() - } - - if (lastWorld != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET last_world = ?;") - statement.setString(1, lastWorld) - statement.executeUpdate() - statement.close() - } - - conn.close() - } - - def worldRetrieveConfig(guild: Guild, world: String): Map[String, String] = { - val conn = getConnection(guild) - val statement = conn.prepareStatement("SELECT * FROM worlds WHERE name = ?;") - val formalWorld = world.toLowerCase().capitalize - statement.setString(1, formalWorld) - val result = statement.executeQuery() - - var configMap = Map[String, String]() - while(result.next()) { - configMap += ("name" -> result.getString("name")) - configMap += ("allies_channel" -> result.getString("allies_channel")) - configMap += ("enemies_channel" -> result.getString("enemies_channel")) - configMap += ("neutrals_channel" -> result.getString("neutrals_channel")) - configMap += ("levels_channel" -> result.getString("levels_channel")) - configMap += ("deaths_channel" -> result.getString("deaths_channel")) - configMap += ("category" -> result.getString("category")) - configMap += ("fullbless_role" -> result.getString("fullbless_role")) - configMap += ("nemesis_role" -> result.getString("nemesis_role")) - configMap += ("allypk_role" -> result.getString("allypk_role")) - configMap += ("masslog_role" -> result.getString("masslog_role")) - configMap += ("fullbless_channel" -> result.getString("fullbless_channel")) - configMap += ("nemesis_channel" -> result.getString("nemesis_channel")) - configMap += ("fullbless_level" -> result.getInt("fullbless_level").toString) - configMap += ("show_neutral_levels" -> result.getString("show_neutral_levels")) - configMap += ("show_neutral_deaths" -> result.getString("show_neutral_deaths")) - configMap += ("show_allies_levels" -> result.getString("show_allies_levels")) - configMap += ("show_allies_deaths" -> result.getString("show_allies_deaths")) - configMap += ("show_enemies_levels" -> result.getString("show_enemies_levels")) - configMap += ("show_enemies_deaths" -> result.getString("show_enemies_deaths")) - configMap += ("detect_hunteds" -> result.getString("detect_hunteds")) - configMap += ("levels_min" -> result.getInt("levels_min").toString) - configMap += ("deaths_min" -> result.getInt("deaths_min").toString) - configMap += ("exiva_list" -> result.getString("exiva_list")) - configMap += ("activity_channel" -> result.getString("activity_channel")) - - val combinedOnlineValue: String = Try(result.getString("combined_online")) match { - case Success(value) => value // Column exists, use the retrieved value - case Failure(_) => "false" // Column doesn't exist, use the default value - } - configMap += ("combined_online" -> combinedOnlineValue) - } - statement.close() - conn.close() - configMap - } - - private def worldRemoveConfig(guild: Guild, query: String): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement("DELETE FROM worlds WHERE name = ?") - val formalName = query.toLowerCase().capitalize - statement.setString(1, formalName) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def worldRemoveConfig(guild: Guild, query: String): Unit = + worldConfigRepository.removeWorld(guild.getId, query) def createChannels(event: SlashCommandInteractionEvent): MessageEmbed = { // get guild & world information from the slash interaction @@ -3334,7 +1764,7 @@ object BotApp extends App with StrictLogging { .grant(Permission.MESSAGE_SEND) .complete() adminCategory.upsertPermissionOverride(guild.getPublicRole).grant(Permission.VIEW_CHANNEL).queue() - val adminChannel = guild.createTextChannel("🖥️・ᴄᴏᴍᴍᴀɴᴅ᲼ʟᴏɢ", adminCategory).complete() + val adminChannel = guild.createTextChannel("🖥️・ᴄᴏᴍᴍᴀɴᴅ ʟᴏɢ", adminCategory).complete() // restrict the channel so only roles with Permission.MANAGE_MESSAGES can write to the channels adminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_SEND).complete() adminChannel.upsertPermissionOverride(botRole).grant(Permission.VIEW_CHANNEL).complete() @@ -3392,22 +1822,7 @@ object BotApp extends App with StrictLogging { val dreamScarDaily = dreamScar.getOrElse(world, "World not found") - val rashidLocation = - Map( - DayOfWeek.MONDAY -> "Svargrond", - DayOfWeek.TUESDAY -> "Liberty Bay", - DayOfWeek.WEDNESDAY -> "Port Hope", - DayOfWeek.THURSDAY -> "Ankrahmun", - DayOfWeek.FRIDAY -> "Darashia", - DayOfWeek.SATURDAY -> "Edron", - DayOfWeek.SUNDAY -> "Carlin" - ).getOrElse( - ZonedDateTime - .now(ZoneId.of("Europe/Berlin")) - .minusHours(10) - .getDayOfWeek, - "Unknown" - ) + val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) val rashidEmbed = new EmbedBuilder() .setDescription( @@ -3427,8 +1842,7 @@ object BotApp extends App with StrictLogging { // Drome Timer val now = Instant.now() - val isAfterNow = dromeTime.isAfter(now) - val dromeShow = isAfterNow && java.time.Duration.between(now, dromeTime).toDays <= 3 + val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) val dromeEmbed = new EmbedBuilder() .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") @@ -3484,7 +1898,7 @@ object BotApp extends App with StrictLogging { } if (adminChannelCheck == null) { // admin channel has been deleted - val adminChannel = guild.createTextChannel("🖥️・ᴄᴏᴍᴍᴀɴᴅ᲼ʟᴏɢ", adminCategoryCheck).complete() + val adminChannel = guild.createTextChannel("🖥️・ᴄᴏᴍᴍᴀɴᴅ ʟᴏɢ", adminCategoryCheck).complete() adminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_SEND).complete() adminChannel.upsertPermissionOverride(botRole).grant(Permission.VIEW_CHANNEL).complete() adminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_EMBED_LINKS).complete() @@ -3542,22 +1956,7 @@ object BotApp extends App with StrictLogging { val dreamScarDaily = dreamScar.getOrElse(world, "World not found") - val rashidLocation = - Map( - DayOfWeek.MONDAY -> "Svargrond", - DayOfWeek.TUESDAY -> "Liberty Bay", - DayOfWeek.WEDNESDAY -> "Port Hope", - DayOfWeek.THURSDAY -> "Ankrahmun", - DayOfWeek.FRIDAY -> "Darashia", - DayOfWeek.SATURDAY -> "Edron", - DayOfWeek.SUNDAY -> "Carlin" - ).getOrElse( - ZonedDateTime - .now(ZoneId.of("Europe/Berlin")) - .minusHours(10) - .getDayOfWeek, - "Unknown" - ) + val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) val rashidEmbed = new EmbedBuilder() .setDescription( @@ -3577,8 +1976,7 @@ object BotApp extends App with StrictLogging { // Drome Timer val now = Instant.now() - val isAfterNow = dromeTime.isAfter(now) - val dromeShow = isAfterNow && java.time.Duration.between(now, dromeTime).toDays <= 3 + val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) val dromeEmbed = new EmbedBuilder() .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") @@ -3779,17 +2177,8 @@ object BotApp extends App with StrictLogging { } } - private def detectHuntedsToDatabase(guild: Guild, world: String, detectSetting: String): Unit = { - val worldFormal = world.toLowerCase().capitalize - val conn = getConnection(guild) - val statement = conn.prepareStatement("UPDATE worlds SET detect_hunteds = ? WHERE name = ?;") - statement.setString(1, detectSetting) - statement.setString(2, worldFormal) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def detectHuntedsToDatabase(guild: Guild, world: String, detectSetting: String): Unit = + worldConfigRepository.updateWorldString(guild.getId, world.toLowerCase().capitalize, "detect_hunteds", detectSetting) def deathsLevelsHideShow(event: SlashCommandInteractionEvent, world: String, setting: String, playerType: String, channelType: String): MessageEmbed = { val worldFormal = world.toLowerCase().capitalize @@ -3938,17 +2327,8 @@ object BotApp extends App with StrictLogging { } } - private def exivaListToDatabase(guild: Guild, world: String, detectSetting: String): Unit = { - val worldFormal = world.toLowerCase().capitalize - val conn = getConnection(guild) - val statement = conn.prepareStatement("UPDATE worlds SET exiva_list = ? WHERE name = ?;") - statement.setString(1, detectSetting) - statement.setString(2, worldFormal) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def exivaListToDatabase(guild: Guild, world: String, detectSetting: String): Unit = + worldConfigRepository.updateWorldString(guild.getId, world.toLowerCase().capitalize, "exiva_list", detectSetting) def onlineListConfig(event: SlashCommandInteractionEvent, world: String, setting: String): MessageEmbed = { val worldFormal = world.toLowerCase().capitalize @@ -4257,58 +2637,11 @@ object BotApp extends App with StrictLogging { } } - private def onlineListConfigToDatabase(guild: Guild, world: String, setting: String): Unit = { - val worldFormal = world.toLowerCase().capitalize - val conn = getConnection(guild) - val statement = conn.prepareStatement(s"UPDATE worlds SET online_combined = ? WHERE name = ?;") - statement.setString(1, setting) - statement.setString(2, worldFormal) - statement.executeUpdate() - - statement.close() - conn.close() - } - - private def customSortConfig(guild: Guild, query: String): List[CustomSort] = { - val conn = getConnection(guild) - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'online_list_categories'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createCustomSortTable = - s"""CREATE TABLE online_list_categories ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |entity VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |label VARCHAR(255) NOT NULL, - |emoji VARCHAR(255) NOT NULL, - |added VARCHAR(255) NOT NULL - |);""".stripMargin - - statement.executeUpdate(createCustomSortTable) - } - - val result = statement.executeQuery(s"SELECT entity,name,label,emoji FROM $query") - - val results = new ListBuffer[CustomSort]() - while (result.next()) { - val entity = Option(result.getString("entity")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val label = Option(result.getString("label")).getOrElse("") - val emoji = Option(result.getString("emoji")).getOrElse("") - - results += CustomSort(entity, name, label, emoji) - } + private def onlineListConfigToDatabase(guild: Guild, world: String, setting: String): Unit = + worldConfigRepository.updateWorldString(guild.getId, world.toLowerCase().capitalize, "online_combined", setting) - statement.close() - conn.close() - results.toList - } + private def customSortConfig(guild: Guild, query: String): List[CustomSort] = + customSortRepository.getAll(guild.getId) def addOnlineListCategory(event: SlashCommandInteractionEvent, guildOrPlayer: String, name: String, label: String, emoji: String, callback: MessageEmbed => Unit): Unit = { // get command information @@ -4432,20 +2765,8 @@ object BotApp extends App with StrictLogging { } } - private def addOnlineListCategoryToDatabase(guild: Guild, guildOrPlayer: String, name: String, label: String, emoji: String): Unit = { - val conn = getConnection(guild) - val query = "INSERT INTO online_list_categories(entity, name, label, emoji, added) VALUES (?, ?, ?, ?, ?);" - val statement = conn.prepareStatement(query) - statement.setString(1, guildOrPlayer) - statement.setString(2, name) - statement.setString(3, label) - statement.setString(4, emoji) - statement.setString(5, ZonedDateTime.now().toEpochSecond().toString) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def addOnlineListCategoryToDatabase(guild: Guild, guildOrPlayer: String, name: String, label: String, emoji: String): Unit = + customSortRepository.add(guild.getId, guildOrPlayer, name, label, emoji) def removeOnlineListCategory(event: SlashCommandInteractionEvent, guildOrPlayer: String, name: String): MessageEmbed = { // get command information @@ -4514,16 +2835,8 @@ object BotApp extends App with StrictLogging { embedBuild.build() } - private def removeOnlineListCategoryFromDatabase(guild: Guild, guildOrPlayer: String, name: String): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE name = ? AND entity = ?;") - statement.setString(1, name) - statement.setString(2, guildOrPlayer) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def removeOnlineListCategoryFromDatabase(guild: Guild, guildOrPlayer: String, name: String): Unit = + customSortRepository.removeByNameEntity(guild.getId, guildOrPlayer, name) def clearOnlineListCategory(event: SlashCommandInteractionEvent, label: String): MessageEmbed = { // get command information @@ -4568,15 +2881,8 @@ object BotApp extends App with StrictLogging { embedBuild.build() } - private def clearOnlineListCategoryFromDatabase(guild: Guild, label: String): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE LOWER(label) = LOWER(?);") - statement.setString(1, label) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def clearOnlineListCategoryFromDatabase(guild: Guild, label: String): Unit = + customSortRepository.removeByLabel(guild.getId, label) def listOnlineListCategory(event: SlashCommandInteractionEvent): List[MessageEmbed] = { // get command information @@ -4634,7 +2940,6 @@ object BotApp extends App with StrictLogging { private def deathsLevelsHideShowToDatabase(guild: Guild, world: String, setting: String, playerType: String, channelType: String): Unit = { val worldFormal = world.toLowerCase().capitalize - val conn = getConnection(guild) val tablePrefix = playerType match { case "allies" => "show_allies_" case "neutrals" => "show_neutral_" @@ -4642,13 +2947,7 @@ object BotApp extends App with StrictLogging { case _ => "" } val tableName = s"$tablePrefix$channelType" - val statement = conn.prepareStatement(s"UPDATE worlds SET $tableName = ? WHERE name = ?;") - statement.setString(1, setting) - statement.setString(2, worldFormal) - statement.executeUpdate() - - statement.close() - conn.close() + worldConfigRepository.updateWorldString(guild.getId, worldFormal, tableName, setting) } def fullblessLevel(event: SlashCommandInteractionEvent, world: String, level: Int): MessageEmbed = { @@ -4977,21 +3276,7 @@ object BotApp extends App with StrictLogging { combinedFutures.map { embeds => val dreamScarDaily = dreamScar.getOrElse(worldFormal, "World not found") - val rashidLocation = - Map( - DayOfWeek.MONDAY -> "Svargrond", - DayOfWeek.TUESDAY -> "Liberty Bay", - DayOfWeek.WEDNESDAY -> "Port Hope", - DayOfWeek.THURSDAY -> "Ankrahmun", - DayOfWeek.FRIDAY -> "Darashia", - DayOfWeek.SATURDAY -> "Edron", - DayOfWeek.SUNDAY -> "Carlin" - ).getOrElse( - ZonedDateTime.now(ZoneId.of("Europe/Berlin")) - .minusHours(10) - .getDayOfWeek, - "Unknown" - ) + val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) val rashidEmbed = new EmbedBuilder() .setDescription( @@ -5011,8 +3296,7 @@ object BotApp extends App with StrictLogging { // Drome Timer val now = Instant.now() - val isAfterNow = dromeTime.isAfter(now) - val dromeShow = isAfterNow && java.time.Duration.between(now, dromeTime).toDays <= 3 + val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) val dromeEmbed = new EmbedBuilder() .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") @@ -5269,22 +3553,7 @@ object BotApp extends App with StrictLogging { val dreamScarDaily = dreamScar.getOrElse(world, "World not found") - val rashidLocation = - Map( - DayOfWeek.MONDAY -> "Svargrond", - DayOfWeek.TUESDAY -> "Liberty Bay", - DayOfWeek.WEDNESDAY -> "Port Hope", - DayOfWeek.THURSDAY -> "Ankrahmun", - DayOfWeek.FRIDAY -> "Darashia", - DayOfWeek.SATURDAY -> "Edron", - DayOfWeek.SUNDAY -> "Carlin" - ).getOrElse( - ZonedDateTime - .now(ZoneId.of("Europe/Berlin")) - .minusHours(10) - .getDayOfWeek, - "Unknown" - ) + val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) val rashidEmbed = new EmbedBuilder() .setDescription( @@ -5304,8 +3573,7 @@ object BotApp extends App with StrictLogging { // Drome Timer val now = Instant.now() - val isAfterNow = dromeTime.isAfter(now) - val dromeShow = isAfterNow && java.time.Duration.between(now, dromeTime).toDays <= 3 + val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) val dromeEmbed = new EmbedBuilder() .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") @@ -5453,7 +3721,7 @@ object BotApp extends App with StrictLogging { adminCategory = newAdminCategory } // create the channel - val newAdminChannel = guild.createTextChannel("🖥️・ᴄᴏᴍᴍᴀɴᴅ᲼ʟᴏɢ", adminCategory).complete() + val newAdminChannel = guild.createTextChannel("🖥️・ᴄᴏᴍᴍᴀɴᴅ ʟᴏɢ", adminCategory).complete() // restrict the channel so only roles with Permission.MANAGE_MESSAGES can write to the channels newAdminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_SEND).complete() newAdminChannel.upsertPermissionOverride(botRole).grant(Permission.VIEW_CHANNEL).complete() @@ -5481,16 +3749,8 @@ object BotApp extends App with StrictLogging { embedBuild.build() } - private def worldRepairConfig(guild: Guild, world: String, tableName: String, newValue: String): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement(s"UPDATE worlds SET $tableName = ? WHERE name = ?;") - statement.setString(1, newValue) - statement.setString(2, world) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def worldRepairConfig(guild: Guild, world: String, tableName: String, newValue: String): Unit = + worldConfigRepository.updateWorldString(guild.getId, world, tableName, newValue) def minLevel(event: SlashCommandInteractionEvent, world: String, level: Int, levelsOrDeaths: String): MessageEmbed = { val worldFormal = world.toLowerCase().capitalize @@ -5544,27 +3804,12 @@ object BotApp extends App with StrictLogging { } } - private def fullblessLevelToDatabase(guild: Guild, world: String, level: Int): Unit = { - val conn = getConnection(guild) - val statement = conn.prepareStatement("UPDATE worlds SET fullbless_level = ? WHERE name = ?;") - statement.setInt(1, level) - statement.setString(2, world) - statement.executeUpdate() - - statement.close() - conn.close() - } + private def fullblessLevelToDatabase(guild: Guild, world: String, level: Int): Unit = + worldConfigRepository.updateWorldInt(guild.getId, world, "fullbless_level", level) private def minLevelToDatabase(guild: Guild, world: String, level: Int, levelOrDeath: String): Unit = { - val conn = getConnection(guild) val columnName = if (levelOrDeath == "levels") "levels_min" else "deaths_min" - val statement = conn.prepareStatement(s"UPDATE worlds SET $columnName = ? WHERE name = ?;") - statement.setInt(1, level) - statement.setString(2, world) - statement.executeUpdate() - - statement.close() - conn.close() + worldConfigRepository.updateWorldInt(guild.getId, world, columnName, level) } def discordLeave(event: GuildLeaveEvent): Unit = { @@ -5590,24 +3835,8 @@ object BotApp extends App with StrictLogging { discordsData = updatedDiscordsData } - // Remove from botStreams if exists - val updatedBotStreams = botStreams.map { case (world, streams) => - val updatedUsedBy = streams.usedBy.filterNot(_.id == guildId) - if (updatedUsedBy.isEmpty) { - streams.stream.cancel() - None // Return None to indicate that this entry should be removed from the map - } else if (streams.usedBy != updatedUsedBy) { - // Only update the streams if the usedBy list has changed - Some(world -> streams.copy(usedBy = updatedUsedBy)) // Return the updated entry wrapped in Some - } else { - Some(world -> streams) // Return the existing entry wrapped in Some - } - }.flatten.toMap // Convert the resulting Iterable[(String, Streams)] back into a Map - - // Only update botStreams if any changes were made - if (updatedBotStreams != botStreams) { - botStreams = updatedBotStreams - } + // Remove this guild from every world stream, cancelling any left unused + streamSupervisor.removeGuild(guildId) logger.info(guildId) @@ -5641,11 +3870,7 @@ object BotApp extends App with StrictLogging { } private def removeConfigDatabase(guildId: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/postgres" - val username = "postgres" - val password = Config.postgresPassword - - val conn = DriverManager.getConnection(url, username, password) + val conn = connectionProvider.admin() val statement = conn.createStatement() val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") val exist = result.next() @@ -5733,23 +3958,8 @@ object BotApp extends App with StrictLogging { } } - // remove the guild from the world stream - val getWorldStream = botStreams.get(world) - getWorldStream match { - case Some(streams) => - // remove the guild from the usedBy list - val updatedUsedBy = streams.usedBy.filterNot(_.id == guild.getId) - // if there are no more guilds in the usedBy list - if (updatedUsedBy.isEmpty) { - streams.stream.cancel() - botStreams -= world - } else { - // update the botStreams map with the updated usedBy list - botStreams += (world -> streams.copy(usedBy = updatedUsedBy)) - } - case None => - logger.info(s"No stream found for guild '${guild.getName} - ${guild.getId}' and world '$world'.") - } + // remove the guild from the world stream, cancelling it if now unused + streamSupervisor.removeGuildFromWorld(world, guild.getId) // delete the channels & category channelIds.foreach { channelId => @@ -5795,111 +4005,6 @@ object BotApp extends App with StrictLogging { .build() } - def adminLeave(event: SlashCommandInteractionEvent, guildId: String, reason: String): MessageEmbed = { - // get guild & world information from the slash interaction - val guildL: Long = java.lang.Long.parseLong(guildId) - val guild = jda.getGuildById(guildL) - val discordInfo = discordRetrieveConfig(guild) - var embedMessage = "" - - if (discordInfo.isEmpty) { - embedMessage = s":gear: The bot has left the Guild: **${guild.getName()}** without leaving a message for the owner." - } else { - val adminChannel = guild.getTextChannelById(discordInfo("admin_channel")) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - try { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} The creator of the bot has run a command:") - adminEmbed.setDescription(s"<@$botUser> has left your discord because of the following reason:\n> ${reason}") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Abacus.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Throwable => logger.info(s"Failed to send admin message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - } - } - } - embedMessage = s":gear: The bot has left the Guild: **${guild.getName()}** and left a message for the owner." - } - - guild.leave().queue() - // embed reply - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedMessage) - .build() - } - - def adminDreamScar(event: SlashCommandInteractionEvent): MessageEmbed = { - dreamScar = fetchDreamScarBosses().map(e => e.world -> e.boss).toMap - var embedMessage = s":gear: The dreamcourts bosses for each world have been resynced." - // embed reply - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedMessage) - .build() - } - - def adminMessage(event: SlashCommandInteractionEvent, guildId: String, message: String): MessageEmbed = { - // get guild & world information from the slash interaction - val guildL: Long = java.lang.Long.parseLong(guildId) - val guild = jda.getGuildById(guildL) - val discordInfo = discordRetrieveConfig(guild) - var embedMessage = "" - - if (discordInfo.isEmpty) { - embedMessage = s"${Config.noEmoji} The Guild: **${guild.getName()}** doesn't have any worlds setup yet, so a message cannot be sent." - } else { - val adminChannel = guild.getTextChannelById(discordInfo("admin_channel")) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - try { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} The creator of the bot has run a command:") - adminEmbed.setDescription(s"<@$botUser> has forwarded a message from the bot's creator:\n> ${message}") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Letter.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Throwable => logger.info(s"Failed to send admin message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } else { - embedMessage = s"${Config.noEmoji} The Guild: **${guild.getName()}** has deleted the `command-log` channel, so a message cannot be sent." - } - embedMessage = s":gear: The bot has left a message for the Guild: **${guild.getName()}**." - } - // embed reply - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedMessage) - .build() - } - - def adminInfo(event: SlashCommandInteractionEvent, callback: List[MessageEmbed] => Unit): Unit = { - val allGuilds = jda.getGuilds.asScala.toList - val allGuildsCleaned: List[String] = allGuilds.map(guild => s"**${guild.getName}** - `${guild.getId}`") - logger.info(allGuildsCleaned.toString) - // build the embed - val embedBuffer = ListBuffer[MessageEmbed]() - var field = "" - allGuildsCleaned.foreach { v => - val currentField = field + "\n" + v - if (currentField.length <= 3000) { // don't add field yet, there is still room - field = currentField - } else { // it's full, add the field - val interimEmbed = new EmbedBuilder() - interimEmbed.setDescription(field) - embedBuffer += interimEmbed.build() - field = v - } - } - val finalEmbed = new EmbedBuilder() - finalEmbed.setDescription(field) - embedBuffer += finalEmbed.build() - callback(embedBuffer.toList) - } private def creatureImageUrl(creature: String): String = { val key = creature.toLowerCase @@ -5917,734 +4022,41 @@ object BotApp extends App with StrictLogging { "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Red_Sparkles_Effect.gif" case _ => - val finalCreature = Config.creatureUrlMappings.getOrElse(key, { - val rx1 = """([^\w]\w)""".r - val parsed1 = rx1.replaceAllIn(creature, m => m.group(1).toUpperCase) - - val rx2 = """( A| Of| The| In| On| To| And| With| From)(?=( ))""".r - val parsed2 = rx2.replaceAllIn(parsed1, m => m.group(1).toLowerCase) - - parsed2.replaceAll(" ", "_").capitalize - }) - - s"https://www.tibiawiki.com.br/wiki/Special:Redirect/file/$finalCreature.gif" + presentation.Urls.creatureImageUrl(creature, Config.creatureUrlMappings) } } - def creatureWikiUrl(creature: String): String = { - val finalCreature = Config.creatureUrlMappings.getOrElse(creature.toLowerCase, { - // Capitalise the start of each word, including after punctuation e.g. "Mooh'Tah Warrior", "Two-Headed Turtle" - val rx1 = """([^\w]\w)""".r - val parsed1 = rx1.replaceAllIn(creature, m => m.group(1).toUpperCase) - - // Lowercase the articles, prepositions etc., e.g. "The Voice of Ruin" - val rx2 = """( A| Of| The| In| On| To| And| With| From)(?=( ))""".r - val parsed2 = rx2.replaceAllIn(parsed1, m => m.group(1).toLowerCase) - - // Replace spaces with underscores and make sure the first letter is capitalised - parsed2.replaceAll(" ", "_").capitalize - }) - s"https://www.tibiawiki.com.br/wiki/$finalCreature" - } + def creatureWikiUrl(creature: String): String = + presentation.Urls.creatureWikiUrl(creature, Config.creatureUrlMappings) // V1.9 Boosted Command - def createBoostedEmbed(name: String, emoji: String, wikiUrl: String, thumbnail: String, embedText: String): MessageEmbed = { - val embed = new EmbedBuilder() - //embed.setTitle(s"$emoji $name $emoji", wikiUrl) - embed.setThumbnail(thumbnail) - embed.setColor(3092790) - embed.setDescription(embedText) - embed.build() - } - - def capitalizeAllWords(s: String): String = { - s.split(" ").map(_.capitalize).mkString(" ") - } - - def boostedAll(): List[BoostedStamp] = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = - statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_notifications'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE boosted_notifications ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |type VARCHAR(255), - |CONSTRAINT unique_user_name_constraint UNIQUE (userid, name) - |);""".stripMargin - - statement.executeUpdate(createListTable) - } - - val result = statement.executeQuery(s"SELECT userid,name,type FROM boosted_notifications;") - val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() - - while (result.next()) { - val boostedUserSql = Option(result.getString("userid")).getOrElse("") - val boostedNameSql = Option(result.getString("name")).getOrElse("") - val boostedTypeSql = Option(result.getString("type")).getOrElse("") - - val boostedStamp = BoostedStamp(boostedUserSql, boostedTypeSql, boostedNameSql) - boostedStampList += boostedStamp - } - - statement.close() - conn.close() - - boostedStampList.toList - } - - def boostedList(userId: String): Boolean = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - val conn = DriverManager.getConnection(url, username, password) - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = - statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_notifications'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE boosted_notifications ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |type VARCHAR(255), - |CONSTRAINT unique_user_name_constraint UNIQUE (userid, name) - |);""".stripMargin - - statement.executeUpdate(createListTable) - } - - val result = statement.executeQuery(s"SELECT name,type FROM boosted_notifications WHERE userid = '$userId';") - val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() - - while (result.next()) { - val boostedNameSql = Option(result.getString("name")).getOrElse("") - val boostedTypeSql = Option(result.getString("type")).getOrElse("") - - val boostedStamp = BoostedStamp(userId, boostedTypeSql, boostedNameSql) - boostedStampList += boostedStamp - } - - statement.close() - conn.close() - - val existingNames = boostedStampList.toList - existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") - } - - def boosted(userId: String, boostedOption: String, boostedName: String): MessageEmbed = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/bot_cache" - val username = "postgres" - val password = Config.postgresPassword - val conn = DriverManager.getConnection(url, username, password) - var embedMessage = s"${Config.noEmoji} This command failed to run, try again?" - - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = - statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_notifications'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE boosted_notifications ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |type VARCHAR(255), - |CONSTRAINT unique_user_name_constraint UNIQUE (userid, name) - |);""".stripMargin - - statement.executeUpdate(createListTable) - } - - val result = statement.executeQuery(s"SELECT name,type FROM boosted_notifications WHERE userid = '$userId';") - val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() - - while (result.next()) { - val boostedNameSql = Option(result.getString("name")).getOrElse("") - val boostedTypeSql = Option(result.getString("type")).getOrElse("") - - val boostedStamp = BoostedStamp(userId, boostedTypeSql, boostedNameSql) - boostedStampList += boostedStamp - } - statement.close() - - val sanitizedName = boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase - val existingNames = boostedStampList.toList - - val replyEmbed = new EmbedBuilder() - replyEmbed.setColor(3092790) - if (boostedOption == "list") { // UNFINISHED - if (existingNames.size > 0) { - val listSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") - val groupedAndSorted = existingNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - embedMessage = if (listSetting) s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." else s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" - val combinedMessage = embedMessage - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = embedMessage.lastIndexOf('\n', (4090 - (substituteText.size))) - val truncatedMessage = embedMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText - } else { - embedMessage = combinedMessage - } - } else { - embedMessage = s"${Config.letterEmoji} Your notification list is *empty*." - } - } else if (boostedOption == "add"){ - if (sanitizedName != "") { - if (existingNames.exists(_.boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase == sanitizedName)) { - embedMessage = s"${Config.noEmoji} **$sanitizedName** already exists." - } else { - if (sanitizedName == "all") { - val query = - "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" - val preparedStatement = conn.prepareStatement(query) - preparedStatement.setString(1, userId) - preparedStatement.setString(2, sanitizedName) - preparedStatement.setString(3, "all") - preparedStatement.executeUpdate() - preparedStatement.close() - embedMessage = s"${Config.yesEmoji} you have enabled notifications for **all** bosses and creatures." - } else { - // Check if sanitizedName exists in boostedBossesList - val isBoostedBoss = boostedBossesList.exists(_.equalsIgnoreCase(sanitizedName)) - - // Check if sanitizedName is a valid creature - //val boostedCreature: Future[Either[String, RaceResponse]] = tibiaDataClient.getCreature(sanitizedName) - - val dreamcourtCheck: Boolean = if (List("plagueroot","malofur mangrinder","maxxenius","alptramun","izcandar the banished").contains(sanitizedName.toLowerCase)) true else false - val creatureCheck: Boolean = if (Config.creaturesList.contains(sanitizedName.toLowerCase)) true else false - val monsterType = if (isBoostedBoss) "boss" else if (creatureCheck) "creature" else "all" - if (dreamcourtCheck){ - embedMessage = s"${Config.noEmoji} dreamcourt bosses arn't supported yet." - } else { - if (monsterType == "all") { - val groupedAndSorted = existingNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - val listMessage = if (groupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." - val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not a valid `boss` or `creature`." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } - } else { - val query = "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" - val preparedStatement = conn.prepareStatement(query) - preparedStatement.setString(1, userId) - preparedStatement.setString(2, sanitizedName) - preparedStatement.setString(3, monsterType) - preparedStatement.executeUpdate() - preparedStatement.close() - - val newNames = existingNames :+ BoostedStamp(userId, monsterType, sanitizedName) - val groupedAndSorted = newNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - val listMessage = if (groupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" else s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." - val commandMessage = s"${Config.yesEmoji} **$sanitizedName** was added." - //WIP - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } - } - } - } - } - } else { - // Check if sanitizedName exists in boostedBossesList - val isBoostedBoss = boostedBossesList.exists(_.equalsIgnoreCase(sanitizedName)) - - // Check if sanitizedName is a valid creature - /** - val boostedCreature: Future[Either[String, RaceResponse]] = tibiaDataClient.getCreature(sanitizedName) - val creatureCheck: Future[Boolean] = boostedCreature.map { - case Right(raceResponse) => - raceResponse.creature.isDefined - case Left(errorMessage) => false - } - **/ - val creatureCheck: Boolean = if (Config.creaturesList.contains(sanitizedName.toLowerCase)) true else false - val monsterType = if (isBoostedBoss) "boss" else if (creatureCheck) "creature" else "all" - val listSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") - val newNames = existingNames :+ BoostedStamp(userId, monsterType, boostedName) - val groupedAndSorted = newNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - val listMessage = if (listSetting) s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." else s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" - val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not a valid `boss` or `creature`." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } - } - } else if (boostedOption == "remove"){ - val filteredGroupedAndSorted = existingNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - val filteredNames = names.filterNot(bs => bs.boostedName.toLowerCase == sanitizedName) - - filteredNames.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - if (sanitizedName == "all") { - var query = "DELETE FROM boosted_notifications WHERE userid = ?" - val preparedStatement = conn.prepareStatement(query) - preparedStatement.setString(1, userId) - preparedStatement.executeUpdate() - preparedStatement.close() - - embedMessage = s"${Config.yesEmoji} you have disabled notifications for **all** bosses and creatures." - } else if (existingNames.exists(_.boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase == sanitizedName)) { - var query = "DELETE FROM boosted_notifications WHERE userid = ? AND LOWER(name) = LOWER(?)" - val preparedStatement = conn.prepareStatement(query) - preparedStatement.setString(1, userId) - preparedStatement.setString(2, sanitizedName) - preparedStatement.executeUpdate() - preparedStatement.close() - - val listMessage = if (filteredGroupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$filteredGroupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." - val commandMessage = s"${Config.yesEmoji} you removed **$sanitizedName** from the list." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } - - } else { - - val listMessage = if (filteredGroupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$filteredGroupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." - val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not on your list." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } - } - // - } else if (boostedOption == "toggle"){ - val existingSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") - if (existingSetting) { - var query = "DELETE FROM boosted_notifications WHERE userid = ?" - val preparedStatement = conn.prepareStatement(query) - preparedStatement.setString(1, userId) - preparedStatement.executeUpdate() - preparedStatement.close() - // WIP Message - embedMessage = s"${Config.letterEmoji} Your notification list is *empty*." - } else { - val query = "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" - val preparedStatement = conn.prepareStatement(query) - preparedStatement.setString(1, userId) - preparedStatement.setString(2, "all") - preparedStatement.setString(3, "all") - preparedStatement.executeUpdate() - preparedStatement.close() - embedMessage = s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." - } - // - } else if (boostedOption == "disable") { - var query = "DELETE FROM boosted_notifications WHERE userid = ?" - val preparedStatement = conn.prepareStatement(query) - preparedStatement.setString(1, userId) - preparedStatement.executeUpdate() - preparedStatement.close() - - embedMessage = s"${Config.yesEmoji} you have **disabled** notifications for **all** bosses and creatures." - } - - conn.close() - replyEmbed.setDescription(embedMessage).build() - } + def createBoostedEmbed(name: String, emoji: String, wikiUrl: String, thumbnail: String, embedText: String): MessageEmbed = + presentation.BoostedEmbeds.create(name, emoji, wikiUrl, thumbnail, embedText) // Death screenshot database methods - def storeDeathScreenshot(guildId: String, world: String, characterName: String, deathTime: Long, screenshotUrl: String, addedBy: String, addedName: String, messageId: String): Unit = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/_$guildId" - val username = "postgres" - val password = Config.postgresPassword - val conn = DriverManager.getConnection(url, username, password) - try { - // Create table if it doesn't exist - val createTableStatement = conn.createStatement() - createTableStatement.execute( - s"""CREATE TABLE IF NOT EXISTS death_screenshots ( - | guild_id VARCHAR(100) NOT NULL, - | world VARCHAR(50) NOT NULL, - | character_name VARCHAR(255) NOT NULL, - | death_time BIGINT NOT NULL, - | screenshot_url TEXT NOT NULL, - | added_by VARCHAR(100) NOT NULL, - | added_name VARCHAR(100) NOT NULL, - | added_at TIMESTAMP NOT NULL, - | message_id VARCHAR(100) NOT NULL, - | PRIMARY KEY (guild_id, world, character_name, death_time, screenshot_url) - |)""".stripMargin) - createTableStatement.close() - - // Insert screenshot - val insertStatement = conn.prepareStatement( - "INSERT INTO death_screenshots (guild_id, world, character_name, death_time, screenshot_url, added_by, added_name, added_at, message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - insertStatement.setString(1, guildId) - insertStatement.setString(2, world) - insertStatement.setString(3, characterName) - insertStatement.setLong(4, deathTime) - insertStatement.setString(5, screenshotUrl) - insertStatement.setString(6, addedBy) - insertStatement.setString(7, addedName) - insertStatement.setTimestamp(8, Timestamp.from(Instant.now())) - insertStatement.setString(9, messageId) - insertStatement.executeUpdate() - insertStatement.close() - } catch { - case ex: Exception => logger.error(s"Failed to store death screenshot: ${ex.getMessage}") - } finally { - conn.close() - } - } + def storeDeathScreenshot(guildId: String, world: String, characterName: String, deathTime: Long, screenshotUrl: String, addedBy: String, addedName: String, messageId: String): Unit = + deathScreenshotRepository.store(guildId, world, characterName, deathTime, screenshotUrl, addedBy, addedName, messageId) - def getDeathScreenshots(guildId: String, world: String, characterName: String, deathTime: Long): List[DeathScreenshot] = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/_$guildId" - val username = "postgres" - val password = Config.postgresPassword - val conn = DriverManager.getConnection(url, username, password) - val screenshots = ListBuffer[DeathScreenshot]() - try { - val selectStatement = conn.prepareStatement( - "SELECT * FROM death_screenshots WHERE guild_id = ? AND character_name = ? AND death_time = ? ORDER BY added_at ASC" - ) - selectStatement.setString(1, guildId) - selectStatement.setString(2, characterName) - selectStatement.setLong(3, deathTime) - val resultSet = selectStatement.executeQuery() - - while (resultSet.next()) { - screenshots += DeathScreenshot( - guildId = resultSet.getString("guild_id"), - world = resultSet.getString("world"), - characterName = resultSet.getString("character_name"), - deathTime = resultSet.getLong("death_time"), - screenshotUrl = resultSet.getString("screenshot_url"), - addedBy = resultSet.getString("added_by"), - addedName = resultSet.getString("added_name"), - addedAt = ZonedDateTime.ofInstant(resultSet.getTimestamp("added_at").toInstant, ZoneOffset.UTC), - messageId = resultSet.getString("message_id") - ) - } - resultSet.close() - selectStatement.close() - } catch { - case ex: Exception => - logger.error(s"Failed to get death screenshots: ${ex.getMessage}") - } finally { - conn.close() - } - screenshots.toList - } + def getDeathScreenshots(guildId: String, world: String, characterName: String, deathTime: Long): List[DeathScreenshot] = + deathScreenshotRepository.get(guildId, world, characterName, deathTime) def deleteDeathScreenshot(guildId: String, world: String, characterName: String, deathTime: Long, screenshotUrl: String, userId: String): Boolean = { - val url = s"jdbc:postgresql://${Config.postgresHost}:5432/_$guildId" - val username = "postgres" - val password = Config.postgresPassword - val conn = DriverManager.getConnection(url, username, password) - var deleted = false - val guild = jda.getGuildById(guildId) + val guild = discordGateway.guildById(guildId) val member = guild.retrieveMemberById(userId).complete() val admin = member != null && (member.hasPermission(Permission.MANAGE_SERVER) || member.hasPermission(Permission.MESSAGE_MANAGE)) - try { - // First check if the user is the one who added the screenshot or is an admin - val checkStatement = conn.prepareStatement( - "SELECT added_by FROM death_screenshots WHERE guild_id = ? AND character_name = ? AND death_time = ? AND screenshot_url = ?" - ) - checkStatement.setString(1, guildId) - checkStatement.setString(2, characterName) - checkStatement.setLong(3, deathTime) - checkStatement.setString(4, screenshotUrl) - val resultSet = checkStatement.executeQuery() - - if (resultSet.next()) { - val addedBy = resultSet.getString("added_by") - if (addedBy == userId || admin) { // User can delete their own screenshots - val deleteStatement = conn.prepareStatement( - "DELETE FROM death_screenshots WHERE guild_id = ? AND character_name = ? AND death_time = ? AND screenshot_url = ?" - ) - deleteStatement.setString(1, guildId) - deleteStatement.setString(2, characterName) - deleteStatement.setLong(3, deathTime) - deleteStatement.setString(4, screenshotUrl) - val rowsDeleted = deleteStatement.executeUpdate() - deleted = rowsDeleted > 0 - deleteStatement.close() - } - } - resultSet.close() - checkStatement.close() - } catch { - case ex: Exception => logger.error(s"Failed to delete death screenshot: ${ex.getMessage}") - } finally { - conn.close() + deathScreenshotRepository.deleteIfPermitted(guildId, characterName, deathTime, screenshotUrl) { addedBy => + addedBy == userId || admin } - deleted - } - - def fetchDreamScarBosses(): List[BossEntry] = { - val backend = HttpURLConnectionBackend() - val apiUrl = - "https://tibia.fandom.com/api.php" + - "?action=parse" + - "&page=Dream_Scar/Boss_of_the_Day" + - "&prop=text" + - "&format=json" - val response = basicRequest - .get(uri"$apiUrl") - .header("User-Agent", "Mozilla/5.0") - .send(backend) - val jsonStr = response.body.getOrElse( - throw new RuntimeException("Empty response from API") - ) - // Parse JSON - val parsed = parse(jsonStr).getOrElse( - throw new RuntimeException("Invalid JSON from API") - ) - val html = parsed.hcursor - .downField("parse") - .downField("text") - .downField("*") - .as[String] - .getOrElse( - throw new RuntimeException("Could not extract HTML from API response") - ) - // Parse HTML table - val doc = Jsoup.parse(html) - val table = doc.select("table.wikitable").first() - if (table == null) return Nil - table.select("tr") - .asScala - .drop(1) - .flatMap { row => - val cols = row.select("td").asScala - if (cols.size >= 2) { - Some(BossEntry(cols(0).text().trim, cols(1).text().trim)) - } else None - } - .toList } - def fetchCreatureNames(): List[String] = { - - val backend = HttpURLConnectionBackend() - - val apiUrl = - "https://tibia.fandom.com/api.php" + - "?action=parse" + - "&page=List_of_Creatures_(Ordered)" + - "&prop=text" + - "&format=json" - - val response = - basicRequest - .get(uri"$apiUrl") - .header("User-Agent", "Mozilla/5.0") - .send(backend) - - val jsonStr = response.body.getOrElse( - throw new RuntimeException("Empty API response") - ) - - val parsed = parse(jsonStr).getOrElse( - throw new RuntimeException("Invalid JSON") - ) - - val html = - parsed.hcursor - .downField("parse") - .downField("text") - .downField("*") - .as[String] - .getOrElse( - throw new RuntimeException("Could not extract HTML") - ) - - val doc = Jsoup.parse(html) - - // grab all creature links - val creatures = - doc.select("a") - .asScala - .flatMap { link => - - val href = link.attr("href") - val text = link.text().trim - - // creature pages are /wiki/Creature_Name - if ( - href.startsWith("/wiki/") && - text.nonEmpty && - !text.contains(":") && - !href.contains("List_of_Creatures") - ) { - Some(text) - } else { - None - } - } - .distinct - .toList - - creatures - } + def fetchDreamScarBosses(): List[BossEntry] = wikiClient.dreamScarBosses() - def advanceDromeTime(inputTime: Instant): Unit = { - val berlin = ZoneId.of("Europe/Berlin") + def fetchCreatureNames(): List[String] = wikiClient.creatureNames() - val updatedDromeTime = - Iterator - .iterate(dromeTime)(t => t.atZone(berlin).plusWeeks(2).toInstant) - .dropWhile(_.isBefore(inputTime)) - .next() + def advanceDromeTime(inputTime: Instant): Unit = + dromeTime = domain.time.DromeCycle.advanceFrom(dromeTime, inputTime) - dromeTime = updatedDromeTime - } - - def shiftAllBossesUp(current: Map[String, String]): Map[String, String] = { - current.map { case (world, boss) => - - val nextBoss = indexOfBoss.get(boss) match { - case Some(idx) => - bossCycle((idx + 1) % bossCycle.length) - case None => - boss // fallback: keep unchanged - } - - world -> nextBoss - } - } + def shiftAllBossesUp(current: Map[String, String]): Map[String, String] = + domain.time.DreamScarCycle.shiftAllBossesUp(current) } diff --git a/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala b/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala index 6e5feaa..c57d399 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala @@ -1,72 +1,48 @@ package com.tibiabot import com.tibiabot.BotApp.commands -import com.tibiabot.BotApp.{SatchelStamp, worldsData} import net.dv8tion.jda.api.EmbedBuilder -import net.dv8tion.jda.api.Permission import net.dv8tion.jda.api.events.guild.GuildJoinEvent import net.dv8tion.jda.api.events.guild.GuildLeaveEvent import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent -import net.dv8tion.jda.api.interactions.commands.CommandAutoCompleteInteraction -import net.dv8tion.jda.api.entities.emoji.Emoji import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent import net.dv8tion.jda.api.events.message.MessageReceivedEvent import net.dv8tion.jda.api.hooks.ListenerAdapter import com.typesafe.scalalogging.StrictLogging -import net.dv8tion.jda.api.interactions.components.buttons._ -import java.time.ZonedDateTime -import net.dv8tion.jda.api.interactions.components.ActionRow import scala.jdk.CollectionConverters._ -import net.dv8tion.jda.api.interactions.modals.Modal -import net.dv8tion.jda.api.interactions.components.text.{TextInput, TextInputStyle} import scala.collection.mutable -import net.dv8tion.jda.api.entities.{Guild, Member, Role} - -case class PendingScreenshot(charName: String, deathTime: Long, messageId: String, guildId: String, world: String, userId: String, channelId: String) +import com.tibiabot.domain.PendingScreenshot +import com.tibiabot.commands.CommandRouter +import com.tibiabot.commands.handlers.{AdminCommands, AlliesCommands, BoostedCommands, ChannelCommands, ExivaCommands, FilterCommands, FullblessCommands, GalthenCommands, HelpCommands, HuntedCommands, LeaderboardCommands, NeutralCommands, OnlineListCommands} class BotListener extends ListenerAdapter with StrictLogging { private val pendingScreenshots = mutable.Map[String, PendingScreenshot]() + // Slash-command dispatch table. Adding a command means adding one entry here. + private val slashRouter = new CommandRouter[SlashCommandInteractionEvent](Map( + "setup" -> (ChannelCommands.setup _), + "remove" -> (ChannelCommands.remove _), + "hunted" -> (HuntedCommands.handle _), + "allies" -> (AlliesCommands.handle _), + "neutral" -> (NeutralCommands.handle _), + "fullbless" -> (FullblessCommands.handle _), + "filter" -> (FilterCommands.handle _), + "admin" -> (AdminCommands.handle _), + "exiva" -> (ExivaCommands.handle _), + "help" -> (HelpCommands.handle _), + "repair" -> (ChannelCommands.repair _), + "galthen" -> (GalthenCommands.handle _), + "online" -> (OnlineListCommands.handle _), + "boosted" -> (BoostedCommands.handle _), + "leaderboards" -> (LeaderboardCommands.handle _) + )) + override def onSlashCommandInteraction(event: SlashCommandInteractionEvent): Unit = { event.deferReply(true).queue() if (BotApp.startUpComplete) { - event.getName match { - //case "reload" => - // handleReload(event) - case "setup" => - handleSetup(event) - case "remove" => - handleRemove(event) - case "hunted" => - handleHunted(event) - case "allies" => - handleAllies(event) - case "neutral" => - handleNeutrals(event) - case "fullbless" => - handleFullbless(event) - case "filter" => - handleFilter(event) - case "admin" => - handleAdmin(event) - case "exiva" => - handleExiva(event) - case "help" => - handleHelp(event) - case "repair" => - handleRepair(event) - case "galthen" => - handleGalthen(event) - case "online" => - handleOnlineList(event) - case "boosted" => - handleBoosted(event) - case "leaderboards" => - handleLeaderboards(event) - case _ => - } + slashRouter.route(event.getName, event) } else { val responseText = s"${Config.noEmoji} The bot is still starting up, try running your command later." val embed = new EmbedBuilder().setDescription(responseText).setColor(3092790).build() @@ -88,1773 +64,9 @@ class BotListener extends ListenerAdapter with StrictLogging { BotApp.discordLeave(event) } - override def onModalInteraction(event: ModalInteractionEvent): Unit = { - event.deferEdit().queue() - val user = event.getUser - val modalValues = event.getValues.asScala.toList - modalValues.map { element => - val id = element.getId - var inputName = element.getAsString.trim.toLowerCase - val shortName = Map( - "oberon" -> "grand master oberon", - "scarlett" -> "scarlett etzel", - "scarlet" -> "scarlett etzel", - "timira" -> "timira the many-headed", - "timira the many headed" -> "timira the many-headed", - "timira many headed" -> "timira the many-headed", - "timira many-headed" -> "timira the many-headed", - "magma" -> "magma bubble", - "rotten final" -> "bakragore", - "yselda" -> "megasylvan yselda", - "zelos" -> "king zelos", - "despor" -> "dragon pack", - "dragon hoard" -> "dragon pack", - "vengar" -> "dragon pack", - "maliz" -> "dragon pack", - "bruton" -> "dragon pack", - "greedok" -> "dragon pack", - "vilear" -> "dragon pack", - "crultor" -> "dragon pack", - "dragon boss" -> "dragon pack", - "dragon bosses" -> "dragon pack", - "thorn knight" -> "the enraged thorn knight", - "the thorn knight" -> "the enraged thorn knight", - "shielded thorn knight" -> "the enraged thorn knight", - "the shielded thorn knight" -> "the enraged thorn knight", - "mounted thorn knight" -> "the enraged thorn knight", - "the mounted thorn knight" -> "the enraged thorn knight", - "paleworm" -> "the paleworm", - "unwelcome" -> "the unwelcome", - "yirkas" -> "yirkas blue scales", - "vok" -> "vok the feakish", - "irgix" -> "irgix the flimsy", - "unaz" -> "unaz the mean", - "utua" -> "utua stone sting", - "katex" -> "katex blood tongue", - "voidborn" -> "the unarmored voidborn", - "the voidborn" -> "the unarmored voidborn", - "unarmored voidborn" -> "the unarmored voidborn", - "urmahlullu" -> "urmahlullu the weakened", - "winter bloom" -> "the winter bloom", - "time guardian" -> "the time guardian", - "souldespoiler" -> "the souldespoiler", - "scourge of oblivion" -> "the scourge of oblivion", - "lib final" -> "the scourge of oblivion", - "lb final" -> "the scourge of oblivion", - "sandking" -> "the sandking", - "nightmare beast" -> "the nightmare beast", - "moonlight aster" -> "the moonlight aster", - "monster" -> "the monster", - "ingol boss" -> "the monster", - "ingol final" -> "the monster", - "mega magmaoid" -> "the mega magmaoid", - "lily of night" -> "the lily of night", - "flaming orchid" -> "the flaming orchid", - "fear feaster" -> "the fear feaster", - "false god" -> "the false god", - "enraged thorn knight" -> "the enraged thorn knight", - "dread maiden" -> "the dread maiden", - "diamond blossom" -> "the diamond blossom", - "brainstealer" -> "the brainstealer", - "blazing rose" -> "the blazing rose", - "srezz" -> "srezz yellow eyes", - "werelion serpent spawn" -> "srezz yellow eyes", - "werelions serpent spawn" -> "srezz yellow eyes", - "werelion goanna" -> "yirkas blue scales", - "werelions goanna" -> "yirkas blue scales", - "werelion scorpion" -> "utua stone sting", - "werelions scorpion" -> "utua stone sting", - "werelion hyena" -> "katex blood tongue", - "werelions hyena" -> "katex blood tongue", - "werelion hyaena" -> "katex blood tongue", - "werelions hyaena" -> "katex blood tongue", - "werelion werehyena" -> "katex blood tongue", - "werelions werehyena" -> "katex blood tongue", - "werelion werehyaena" -> "katex blood tongue", - "werelions werehyaena" -> "katex blood tongue", - "dragon king" -> "soul of dragonking zyrtarch", - "zyrtarch" -> "soul of dragonking zyrtarch", - "dragonking zyrtarch" -> "soul of dragonking zyrtarch", - "dragon king zyrtarch" -> "soul of dragonking zyrtarch", - "dragonking zyrtarch" -> "soul of dragonking zyrtarch", - "dragonking" -> "soul of dragonking zyrtarch", - "tenebris" -> "lady tenebris", - "ratmiral" -> "ratmiral blackwhiskers", - "plague seal" -> "plagirath", - "pumin seal" -> "tarbaz", - "jugg seal" -> "razzagorn", - "vexclaw seal" -> "shulgrax", - "undead seal" -> "ragiaz" - ) - if (shortName.contains(inputName)) { - inputName = shortName(inputName) - } - if (id == "boosted add") { - val newEmbed = BotApp.boosted(user.getId, "add", inputName) - event.getHook().editOriginalEmbeds(newEmbed).setActionRow( - Button.success("boosted add", "Add"), - Button.danger("boosted remove", "Remove"), - Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) - ).queue() - } else if (id == "boosted remove") { - val newEmbed = BotApp.boosted(user.getId, "remove", inputName) - event.getHook().editOriginalEmbeds(newEmbed).setActionRow( - Button.success("boosted add", "Add"), - Button.danger("boosted remove", "Remove"), - Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) - ).queue() - } else if (id == "galthen add") { - - val newEmbed = new EmbedBuilder() - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() - val tagDisplay = element.getAsString.trim.toLowerCase - newEmbed.setColor(3092790) - if (tagDisplay.toLowerCase == user.getName.toLowerCase) { - BotApp.addGalthen(user.getId, ZonedDateTime.now(), "") - } else { - BotApp.addGalthen(user.getId, ZonedDateTime.now(), tagDisplay) - } - var editedMessage = "" - var oneRecord = false - val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.getGalthenTable(event.getUser.getId) - satchelTimeOption match { - case Some(satchelTimeList) => - val fullList = satchelTimeList.collect { - case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() - val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" - s"${Config.satchelEmoji} can be collected by $displayTag " - } - if (fullList.nonEmpty) { - newEmbed.setTitle("Existing Cooldowns:") - if (fullList.size == 1) { - oneRecord = true - editedMessage = fullList.mkString - } else { - val descriptionTruncate = fullList.mkString("\n") - if (descriptionTruncate.length > 4050) { - val truncatedDescription = descriptionTruncate.substring(0, 4050) - val lastNewLineIndex = truncatedDescription.lastIndexOf("\n") - val finalDescription = if (lastNewLineIndex >= 0) truncatedDescription.substring(0, lastNewLineIndex) else truncatedDescription - //newEmbed.setDescription(finalDescription) - editedMessage = finalDescription - } else { - //newEmbed.setDescription(descriptionTruncate) - editedMessage = descriptionTruncate - } - } - } - case None => // - } - val replyMessage = s"\n\n${Config.yesEmoji} cooldown tracker for **`$tagDisplay`** has been **added**." - newEmbed.setDescription(editedMessage + replyMessage) - if (oneRecord) { - event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( - Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), - Button.danger("galthenRemoveAll", "Remove") - ).queue() - } else { - event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( - Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), - Button.danger("galthenButtonRem", "Remove"), - Button.secondary("galthenRemoveAll", "Clear All") - ).queue() - } - } else if (id == "galthen rem") { - val newEmbed = new EmbedBuilder() - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() - val tagDisplay = element.getAsString.trim.toLowerCase - newEmbed.setColor(3092790) - if (tagDisplay.toLowerCase == user.getName.toLowerCase) { - BotApp.delGalthen(user.getId, "") - } else { - BotApp.delGalthen(user.getId, tagDisplay) - } - var editedMessage = "" - var oneRecord = false - val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.getGalthenTable(event.getUser.getId) - satchelTimeOption match { - case Some(satchelTimeList) => - val fullList = satchelTimeList.collect { - case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() - val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" - s"${Config.satchelEmoji} can be collected by $displayTag " - } - if (fullList.nonEmpty) { - newEmbed.setTitle("Existing Cooldowns:") - if (fullList.size == 1) { - oneRecord = true - editedMessage = fullList.mkString - } else { - val descriptionTruncate = fullList.mkString("\n") - if (descriptionTruncate.length > 4050) { - val truncatedDescription = descriptionTruncate.substring(0, 4050) - val lastNewLineIndex = truncatedDescription.lastIndexOf("\n") - val finalDescription = if (lastNewLineIndex >= 0) truncatedDescription.substring(0, lastNewLineIndex) else truncatedDescription - //newEmbed.setDescription(finalDescription) - editedMessage = finalDescription - } else { - //newEmbed.setDescription(descriptionTruncate) - editedMessage = descriptionTruncate - } - } - } - case None => // WIP - } - val replyMessage = s"\n\n${Config.yesEmoji} cooldown tracker for **`$tagDisplay`** has been **Disabled**." - newEmbed.setDescription(editedMessage + replyMessage) - if (oneRecord) { - event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( - Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), - Button.danger("galthenRemoveAll", "Remove") - ).queue() - } else { - event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( - Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), - Button.danger("galthenButtonRem", "Remove"), - Button.secondary("galthenRemoveAll", "Clear All") - ).queue() - } - } - } - } - - override def onButtonInteraction(event: ButtonInteractionEvent): Unit = { - val embed = event.getInteraction.getMessage.getEmbeds - val title = if (!embed.isEmpty) embed.get(0).getTitle else "" - val button = event.getComponentId - val guild = event.getGuild - val user = event.getUser - var responseText = s"${Config.noEmoji} An unknown error occured, please try again." - - val footer = if (!embed.isEmpty) Option(embed.get(0).getFooter) else None - val tagId = footer.map(_.getText.replace("Tag: ", "")).getOrElse("") - - /** - if (button == "galthen board") { - event.deferReply(true).queue() - //WIP - val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.getGalthenTable(event.getUser.getId) - satchelTimeOption match { - case Some(satchelTimeList) => - val fullList = satchelTimeList.collect { - case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() - val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" - s"<:satchel:1030348072577945651> can be collected by $displayTag " - } else { - embed.setColor(178877) - embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you: ```when the 30 day cooldown expires```") - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected"), - Button.danger("galthenRemove", "Clear").asDisabled - ).queue() - } - // /HERE - case None => - embed.setColor(178877) - embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you: ```when the 30 day cooldown expires```") - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected"), - Button.danger("galthenRemove", "Clear").asDisabled - ).queue() - } - } else - **/ - if (button == "galthenSet") { - event.deferEdit().queue(); - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() - BotApp.addGalthen(user.getId, ZonedDateTime.now(), tagId) - val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" - responseText = s"${Config.satchelEmoji} can be collected by $tagDisplay " - val newEmbed = new EmbedBuilder() - newEmbed.setDescription(responseText) - newEmbed.setColor(178877) - event.getHook().editOriginalEmbeds(newEmbed.build()).setComponents().queue(); - } else if (button == "galthenRemove") { - event.deferEdit().queue() - BotApp.delGalthen(user.getId, tagId) - val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" - responseText = s"${Config.satchelEmoji} cooldown tracker for $tagDisplay has been **Disabled**." - event.getHook().editOriginalComponents().queue(); - val newEmbed = new EmbedBuilder().setDescription(responseText).setColor(178877).build() - event.getHook().editOriginalEmbeds(newEmbed).queue(); - } else if (button == "galthenRemoveAll") { - event.deferEdit().queue() - BotApp.delAllGalthen(user.getId) - responseText = s"${Config.satchelEmoji} cooldown tracker has been **Disabled**." - event.getHook().editOriginalComponents().queue(); - val newEmbed = new EmbedBuilder().setDescription(responseText).setColor(178877).build() - event.getHook().editOriginalEmbeds(newEmbed).queue(); - } else if (button == "galthenLock") { - event.deferEdit().queue() - event.getHook().editOriginalComponents(ActionRow.of( - Button.secondary("galthenUnLock", "🔓"), - Button.danger("galthenRemoveAll", "Clear All") - )).queue(); - } else if (button == "galthenUnLock") { - event.deferEdit().queue() - event.getHook().editOriginalComponents(ActionRow.of( - Button.secondary("galthenLock", "🔒"), - Button.danger("galthenRemoveAll", "Clear All").asDisabled - )).queue(); - } else if (button == "galthenRemind") { // WIP - event.deferEdit().queue() - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() - BotApp.addGalthen(user.getId, ZonedDateTime.now(), tagId) - val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" - responseText = s"${Config.satchelEmoji} can be collected by $tagDisplay " - event.getHook().editOriginalComponents().queue(); - val newEmbed = new EmbedBuilder().setDescription(responseText).setColor(178877).setFooter("You will be sent a message when the cooldown expires").build() - event.getHook().editOriginalEmbeds(newEmbed).queue() - } else if (button == "galthenClear") { // WIP - event.deferEdit().queue() - event.getHook().editOriginalComponents().queue() - } else if (button == "galthenAdd") { - val inputWindow = TextInput.create("galthen add", "Tag/Name for this cooldown", TextInputStyle.SHORT) - .setPlaceholder("Character Name or Tag to Add") - .build() - val modal = Modal.create("add galthen", "Add a Galthen Satchel cooldown").addComponents(ActionRow.of(inputWindow)).build() - event.replyModal(modal).queue() - } else if (button == "galthenButtonRem") { - val inputWindow = TextInput.create("galthen rem", "Tag/Name for the cooldown", TextInputStyle.SHORT) - .setPlaceholder("Character Name or Tag to Remove") - .build() - val modal = Modal.create("rem galthen", "Remove a Galthen Satchel cooldown").addComponents(ActionRow.of(inputWindow)).build() - event.replyModal(modal).queue() - } else if (button == "boosted") { - event.deferReply(true).queue() - val replyEmbed = new EmbedBuilder() - replyEmbed.setTitle(s"Receiving boosted boss & creature notifications:") - responseText = s"Use the `/boosted` command to filter specific `bosses` & `creatures`." - replyEmbed.setDescription(responseText) - event.getHook.sendMessageEmbeds(replyEmbed.build()).queue() - } else if (button == "boosted add") { - val inputWindow = TextInput.create("boosted add", "Boss or Creature name", TextInputStyle.SHORT) - .setPlaceholder("Grand Master Oberon") - .build() - val modal = Modal.create("add modal", "Add a Boss or Creature").addComponents(ActionRow.of(inputWindow)).build() - event.replyModal(modal).queue() - } else if (button == "boosted remove") { - - val inputWindow = TextInput.create("boosted remove", "Boss or Creature name", TextInputStyle.SHORT).build() - val modal = Modal.create("remove modal", "Add Server Save Notificiations:").addComponents(ActionRow.of(inputWindow)).build() - event.replyModal(modal).queue() - } else if (button == "boosted list") { - event.deferReply(true).queue() - val allCheck = BotApp.boostedList(event.getUser.getId) - if (allCheck) { - val embed = BotApp.boosted(event.getUser.getId, "list", "") - event.getHook.sendMessageEmbeds(embed).setActionRow( - Button.success("boosted add", "Add").asDisabled, - Button.danger("boosted remove", "Remove").asDisabled, - Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOnEmoji)) - ).queue() - } else { - val embed = BotApp.boosted(event.getUser.getId, "list", "") - event.getHook.sendMessageEmbeds(embed).setActionRow( - Button.success("boosted add", "Add"), - Button.danger("boosted remove", "Remove"), - Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) - ).queue() - } - } else if (button == "boosted toggle") { - event.deferEdit().queue() - - val allCheck = BotApp.boostedList(event.getUser.getId) - if (allCheck) { - val embed = BotApp.boosted(event.getUser.getId, "toggle", "all") - event.getHook.editOriginalEmbeds(embed).setActionRow( - Button.success("boosted add", "Add"), - Button.danger("boosted remove", "Remove"), - Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) - ).queue() - } else { - val embed = BotApp.boosted(event.getUser.getId, "toggle", "all") - event.getHook.editOriginalEmbeds(embed).setActionRow( - Button.success("boosted add", "Add").asDisabled, - Button.danger("boosted remove", "Remove").asDisabled, - Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOnEmoji)) - ).queue() - } - } else if (button == "galthen default") { - event.deferReply(true).queue() - val embed = new EmbedBuilder() - - val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.getGalthenTable(event.getUser.getId) - satchelTimeOption match { - // - case Some(satchelTimeList) if satchelTimeList.isEmpty => - embed.setColor(3092790) - embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) - ).queue() - // - case Some(satchelTimeList) => - val fullList = satchelTimeList.collect { - case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() - val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" - s"${Config.satchelEmoji} can be collected by $displayTag " - } - if (fullList.nonEmpty) { - embed.setTitle("Existing Cooldowns:") - val descriptionTruncate = fullList.mkString("\n") - if (descriptionTruncate.length > 4050) { - val truncatedDescription = descriptionTruncate.substring(0, 4050) - val lastNewLineIndex = truncatedDescription.lastIndexOf("\n") - val finalDescription = if (lastNewLineIndex >= 0) truncatedDescription.substring(0, lastNewLineIndex) else truncatedDescription - embed.setDescription(finalDescription) - } else { - embed.setDescription(descriptionTruncate) - } - embed.setColor(3092790) - if (fullList.size == 1){ - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), //WIP - Button.danger("galthenRemoveAll", "Remove") - ).queue() - } else { - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), - Button.danger("galthenButtonRem", "Remove"), - Button.secondary("galthenRemoveAll", "Clear All") - ).queue() - } - } else { - embed.setColor(3092790) - embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) - ).queue() - } - // /HERE - case None => - embed.setColor(3092790) - embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) - ).queue() - // - } - } else if (button == "fullbless") { - event.deferReply(true).queue() - val world = title.replace(":crossed_swords:", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("fullbless_role")) - if (role != null) { - guild.retrieveMemberById(user.getId).queue { member => - val hasRole = member.getRoles.contains(role) - val action = - if (hasRole) guild.removeRoleFromMember(member, role) - else guild.addRoleToMember(member, role) - - action.queue( - _ => { - val msg = - if (hasRole) - s":gear: You have been removed from the <@&${role.getId}> role." - else - s":gear: You have been added to the <@&${role.getId}> role." - - event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() - }, - _ => () - ) - } - } - } else if (button == "nemesis") { - event.deferReply(true).queue() - val world = title.replace(":crossed_swords:", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("nemesis_role")) - if (role != null) { - guild.retrieveMemberById(user.getId).queue { member => - val hasRole = member.getRoles.contains(role) - val action = - if (hasRole) guild.removeRoleFromMember(member, role) - else guild.addRoleToMember(member, role) - - action.queue( - _ => { - val msg = - if (hasRole) - s":gear: You have been removed from the <@&${role.getId}> role." - else - s":gear: You have been added to the <@&${role.getId}> role." - - event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() - }, - _ => () - ) - } - } - } else if (button == "allypk") { - event.deferReply(true).queue() - val world = title.replace(":crossed_swords:", "").trim - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("allypk_role")) - if (role != null) { - guild.retrieveMemberById(user.getId).queue { member => - val hasRole = member.getRoles.contains(role) - val action = - if (hasRole) guild.removeRoleFromMember(member, role) - else guild.addRoleToMember(member, role) - - action.queue( - _ => { - val msg = - if (hasRole) - s":gear: You have been removed from the <@&${role.getId}> role." - else - s":gear: You have been added to the <@&${role.getId}> role." - - event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() - }, - _ => () - ) - } - } - } else if (button == "masslog") { - event.deferReply(true).queue() - val world = title.replace(":crossed_swords:", "").trim - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("masslog_role")) - if (role != null) { - guild.retrieveMemberById(user.getId).queue { member => - val hasRole = member.getRoles.contains(role) - val action = - if (hasRole) guild.removeRoleFromMember(member, role) - else guild.addRoleToMember(member, role) - - action.queue( - _ => { - val msg = - if (hasRole) - s":gear: You have been removed from the <@&${role.getId}> role." - else - s":gear: You have been added to the <@&${role.getId}> role." - - event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() - }, - _ => () - ) - } - } - } else if (button.startsWith("death_screenshot_")) { - // Handle death screenshot button clicks - val buttonParts = button.split("_") - if (buttonParts.length >= 4) { - val charName = buttonParts(2) - val deathTime = buttonParts(3).toLong - val messageId = event.getInteraction.getMessage.getId - - // Get world from guild configuration - val worldOpt = worldsData.get(guild.getId).flatMap(_.headOption).map(_.name) - - worldOpt match { - case Some(world) => - // Store pending screenshot request - val pendingKey = s"${event.getUser.getId}_${guild.getId}" - pendingScreenshots.put(pendingKey, PendingScreenshot(charName, deathTime, messageId, guild.getId, world, event.getUser.getId, event.getChannel.getId)) - - // Send DM to user - event.getUser.openPrivateChannel().queue(privateChannel => { - val embed = new EmbedBuilder() - .setColor(3092790) - .setTitle(s"Upload Screenshot for ${charName}") - .setDescription(s"Please upload an image file (PNG, JPG, GIF, Webp) to this DM within the next 5 minutes.\n\n" + - s"The screenshot will be added to the death message for **[${charName}](${BotApp.charUrl(charName)})** in **${guild.getName}**.") - .setFooter("You can also paste an image directly from your clipboard") - .build() - - privateChannel.sendMessageEmbeds(embed).queue( - _ => { - // Confirm to user that DM was sent - event.reply(s"${Config.yesEmoji} Screenshot upload request sent to your DMs for **[${charName}](${BotApp.charUrl(charName)})**.").setEphemeral(true).queue() - }, - error => { - // Fallback if DM fails - val fallbackEmbed = new EmbedBuilder() - .setColor(16711680) // Red color - .setTitle(s"Upload Screenshot for ${charName}") - .setDescription(s"Could not send you a DM. Please upload an image file (PNG, JPG, GIF, Webp) in this channel within the next 5 minutes, If you wish to cancel, simply respond with the word **cancel**.\n\n" + - s"The screenshot will be added to the death message for **[${charName}](${BotApp.charUrl(charName)})**.") - .setFooter("You can also paste an image directly from your clipboard") - .build() - - event.reply("").addEmbeds(fallbackEmbed).setEphemeral(true).queue() - } - ) - }) - - // Set a timeout to remove the pending request after 5 minutes - scala.concurrent.ExecutionContext.global.execute(() => { - Thread.sleep(300000) // 5 minutes - pendingScreenshots.remove(pendingKey) - }) - - case None => - responseText = s"${Config.noEmoji} Could not determine world for this guild." - val replyEmbed = new EmbedBuilder().setDescription(responseText).build() - event.reply("").addEmbeds(replyEmbed).setEphemeral(true).queue() - } - } else { - responseText = s"${Config.noEmoji} Invalid button format." - val replyEmbed = new EmbedBuilder().setDescription(responseText).build() - event.reply("").addEmbeds(replyEmbed).setEphemeral(true).queue() - } - } else if (button.startsWith("prev_screenshot_") || button.startsWith("next_screenshot_")) { - event.deferEdit().queue() - - val buttonParts = button.split("_") - if (buttonParts.length >= 6) { - val charName = buttonParts(2) - val deathTime = buttonParts(3).toLong - val messageId = event.getInteraction.getMessage.getId - val currentIndex = buttonParts(5).toInt - - // Get world from guild configuration - val worldOpt = worldsData.get(guild.getId).flatMap(_.headOption).map(_.name) - - worldOpt.foreach { world => - val screenshots = BotApp.getDeathScreenshots(guild.getId, world, charName, deathTime) - - if (screenshots.nonEmpty) { - val newIndex = if (button.startsWith("prev_")) { - if (currentIndex > 0) currentIndex - 1 else screenshots.length - 1 - } else { - if (currentIndex < screenshots.length - 1) currentIndex + 1 else 0 - } - - val currentScreenshot = screenshots(newIndex) - - // Preserve the original death message embed and just update the image - val originalEmbed = event.getMessage.getEmbeds.get(0) - val embed = new EmbedBuilder(originalEmbed) - .setImage(currentScreenshot.screenshotUrl) - .setFooter(s"Screenshot added by ${currentScreenshot.addedName} • ${newIndex + 1}/${screenshots.length}") - .build() - - val components = if (screenshots.length > 1) { - val baseButtons = List( - Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot"), - Button.primary(s"prev_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "◀"), - Button.secondary(s"screenshot_info_${charName}_${deathTime}_${messageId}", s"${newIndex + 1}/${screenshots.length}").asDisabled(), - Button.primary(s"next_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "▶") - ) - val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") - List(ActionRow.of(buttonsWithDelete: _*)) - } else { - val baseButtons = List(Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot")) - val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") - List(ActionRow.of(buttonsWithDelete: _*)) - } - - event.getHook.editOriginalEmbeds(embed).setComponents(components: _*).queue() - } - } - } - } else if (button.startsWith("delete_screenshot_")) { - event.deferEdit().queue() - - val buttonParts = button.split("_") - if (buttonParts.length >= 6) { - val charName = buttonParts(2) - val deathTime = buttonParts(3).toLong - val messageId = event.getInteraction.getMessage.getId - val currentIndex = buttonParts(5).toInt - - val guild = event.getGuild - val user = event.getUser - val originalMessage = event.getMessage - - // Get current screenshots to find the URL of the screenshot to delete - val screenshots = BotApp.getDeathScreenshots(guild.getId, guild.getName, charName, deathTime) - if (screenshots.nonEmpty && currentIndex < screenshots.length) { - val screenshotToDelete = screenshots(currentIndex) - - // Attempt to delete the screenshot - if (BotApp.deleteDeathScreenshot(guild.getId, guild.getName, charName, deathTime, screenshotToDelete.screenshotUrl, user.getId)) { - // Successfully deleted, update the embed - val updatedScreenshots = BotApp.getDeathScreenshots(guild.getId, guild.getName, charName, deathTime) - val embeds = originalMessage.getEmbeds - - if (embeds.size() > 0 && updatedScreenshots.nonEmpty) { - // Still have screenshots, show another one - val newIndex = Math.min(currentIndex, updatedScreenshots.length - 1) - val newCurrentScreenshot = updatedScreenshots(newIndex) - - val originalEmbed = embeds.get(0) - val updatedEmbed = new EmbedBuilder(originalEmbed) - .setImage(newCurrentScreenshot.screenshotUrl) - .setFooter(s"Screenshot added by ${newCurrentScreenshot.addedName} • ${newIndex + 1}/${updatedScreenshots.length}") - .build() - - val components = if (updatedScreenshots.length > 1) { - val baseButtons = List( - Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot"), - Button.primary(s"prev_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "◀"), - Button.secondary(s"screenshot_info_${charName}_${deathTime}_${messageId}", s"${newIndex + 1}/${updatedScreenshots.length}").asDisabled(), - Button.primary(s"next_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "▶") - ) - val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") - List(ActionRow.of(buttonsWithDelete: _*)) - } else { - val baseButtons = List(Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot")) - val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") - List(ActionRow.of(buttonsWithDelete: _*)) - } - - event.getHook.editOriginalEmbeds(updatedEmbed).setComponents(components: _*).queue() - } else { - // No more screenshots, remove image and show only add button - val originalEmbed = embeds.get(0) - val updatedEmbed = new EmbedBuilder(originalEmbed) - .setImage(null) - .setFooter(null) - .build() - - val addButton = List(ActionRow.of(Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot"))) - event.getHook.editOriginalEmbeds(updatedEmbed).setComponents(addButton: _*).queue() - } - } else { - // Failed to delete - not the author or other error - event.getHook.sendMessage(s"${Config.noEmoji} You can only delete screenshots you uploaded.").setEphemeral(true).queue() - } - } else { - event.getHook.sendMessage(s"${Config.noEmoji} Screenshot not found.").setEphemeral(true).queue() - } - } else { - event.getHook.sendMessage(s"${Config.noEmoji} Invalid button format.").setEphemeral(true).queue() - } - } else { - event.deferReply(true).queue() - if (title != "") { - val roleType = if (title.contains(":crossed_swords:")) "fullbless" else if (title.contains(s"${Config.nemesisEmoji}")) "nemesis" else if (title.contains(s"${Config.hazardEmoji}")) "allypk" else "" - if (roleType == "fullbless") { - val world = title.replace(":crossed_swords:", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("fullbless_role")) - if (role != null) { - if (button == "add") { - // get role add user to it - try { - guild.addRoleToMember(user, role).queue() - responseText = s":gear: You have been added to the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } else if (button == "remove") { - // remove role - try { - guild.removeRoleFromMember(user, role).queue() - responseText = s":gear: You have been removed from the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to remove user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } - } else { - // role doesn't exist - responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." - } - } else if (roleType == "nemesis") { - val world = title.replace(s"${Config.nemesisEmoji}", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("nemesis_role")) - if (role != null) { - if (button == "add") { - // get role add user to it - try { - guild.addRoleToMember(user, role).queue() - responseText = s":gear: You have been added to the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } else if (button == "remove") { - // remove role - try { - guild.removeRoleFromMember(user, role).queue() - responseText = s":gear: You have been removed from the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to remove user $commandPlayer from the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } - } else { - // role doesn't exist - responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." - } - } else if (roleType == "allypk") { - val world = title.replace(s"${Config.hazardEmoji}", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("allypk_role")) - if (role != null) { - if (button == "add") { - // get role add user to it - try { - guild.addRoleToMember(user, role).queue() - responseText = s":gear: You have been added to the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } else if (button == "remove") { - // remove role - try { - guild.removeRoleFromMember(user, role).queue() - responseText = s":gear: You have been removed from the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to remove user $commandPlayer from the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } - } else { - // role doesn't exist - responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." - } - } - } - val replyEmbed = new EmbedBuilder().setDescription(responseText).build() - event.getHook.sendMessageEmbeds(replyEmbed).queue() - } - } - - private def handleSetup(event: SlashCommandInteractionEvent): Unit = { - val embed = BotApp.createChannels(event) - event.getHook.sendMessageEmbeds(embed).queue() - } - private def handleRemove(event: SlashCommandInteractionEvent): Unit = { - val embed = BotApp.removeChannels(event) - event.getHook.sendMessageEmbeds(embed).queue() - } - - private def handleGalthen(event: SlashCommandInteractionEvent): Unit = { - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val tagOption: String = options.getOrElse("character", "") - val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.getGalthenTable(event.getUser.getId) - val embed = new EmbedBuilder() - - satchelTimeOption match { - // - case Some(satchelTimeList) if satchelTimeList.isEmpty => - embed.setColor(178877) - if (tagOption.nonEmpty) embed.setFooter(s"Tag: ${tagOption.toLowerCase}") - embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you when the 30 day cooldown expires.") - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected"), - Button.danger("galthenRemove", "Clear").asDisabled - ).queue() - // - case Some(satchelTimeList) => - val tagList = satchelTimeList.collect { - case satchel if tagOption.equalsIgnoreCase(satchel.tag) => - val when = satchel.when.plusDays(30).toEpochSecond.toString() - s"<:satchel:1030348072577945651> can be collected by **`${satchel.tag}`** " - } - - val fullList = satchelTimeList.collect { - case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() - val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" - s"<:satchel:1030348072577945651> can be collected by $displayTag " - } - - if (tagOption.isEmpty && fullList.nonEmpty) { - embed.setTitle("Existing Cooldowns:") - val descriptionTruncate = fullList.mkString("\n") - if (descriptionTruncate.length > 4050) { - val truncatedDescription = descriptionTruncate.substring(0, 4050) - val lastNewLineIndex = truncatedDescription.lastIndexOf("\n") - val finalDescription = if (lastNewLineIndex >= 0) truncatedDescription.substring(0, lastNewLineIndex) else truncatedDescription - embed.setDescription(finalDescription) - } else { - embed.setDescription(descriptionTruncate) - } - embed.setColor(13773097) - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - if (fullList.size == 1){ - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected").asDisabled, - Button.danger("galthenRemoveAll", "Clear") - ).queue() - } else { - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.secondary("galthenLock", "🔒"), - Button.danger("galthenRemoveAll", "Clear All").asDisabled - ).queue() - } - } else if (tagOption.nonEmpty && tagList.nonEmpty) { // tag picked up - embed.setFooter(s"Tag: ${tagOption.toLowerCase}") - embed.setDescription(tagList.mkString("\n")) - embed.setColor(9855533) - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected").asDisabled, - Button.danger("galthenRemove", "Clear") - ).queue() - // Add any other modifications to the embed if needed - } else { - embed.setColor(178877) - if (tagOption.nonEmpty) embed.setFooter(s"Tag: ${tagOption.toLowerCase}") - embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you when the 30 day cooldown expires.") - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected"), - Button.danger("galthenRemove", "Clear").asDisabled - ).queue() - } - // /HERE - case None => - embed.setColor(178877) - if (tagOption.nonEmpty) embed.setFooter(s"Tag: ${tagOption.toLowerCase}") - embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you when the 30 day cooldown expires.") - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected"), - Button.danger("galthenRemove", "Clear").asDisabled - ).queue() - // - } - } - - private def handleHunted(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val toggleOption: String = options.getOrElse("option", "") - val worldOption: String = options.getOrElse("world", "") - val nameOption: String = options.getOrElse("name", "") - val reasonOption: String = options.getOrElse("reason", "none") - - var authed = false - val user = event.getUser // Get the user who ran the command - val guild = event.getGuild - val member = guild.retrieveMember(user).complete() - if (member != null && member.hasPermission(Permission.MANAGE_SERVER)) { - authed = true - } - - subCommand match { - case "player" => - if (authed) { - if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.addHunted(event, "player", nameOption, reasonOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.removeHunted(event, "player", nameOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "guild" => - if (authed) { - if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.addHunted(event, "guild", nameOption, reasonOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.removeHunted(event, "guild", nameOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "list" => - if (authed) { - BotApp.listAlliesAndHuntedGuilds(event, "hunted", hunteds => { - val embedsJava = hunteds.asJava - embedsJava.forEach { embed => - event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() - } - BotApp.listAlliesAndHuntedPlayers(event, "hunted", hunteds => { - val embedsJava = hunteds.asJava - embedsJava.forEach { embed => - event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() - } - }) - }) - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "clear" => - if (authed) { - val embed = BotApp.clearHunted(event) - event.getHook.sendMessageEmbeds(embed).queue() - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "deaths" => - if (authed) { - if (toggleOption == "show") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "enemies", "deaths") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "hide") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "enemies", "deaths") - event.getHook.sendMessageEmbeds(embed).queue() - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "levels" => - if (authed) { - if (toggleOption == "show") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "enemies", "levels") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "hide") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "enemies", "levels") - event.getHook.sendMessageEmbeds(embed).queue() - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "info" => - val embed = BotApp.infoHunted(event, "player", nameOption) - event.getHook.sendMessageEmbeds(embed).queue() - case "autodetect" => - if (authed) { - val embed = BotApp.detectHunted(event) - event.getHook.sendMessageEmbeds(embed).queue() - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$subCommand' for `/hunted`.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - - private def handleAllies(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val toggleOption: String = options.getOrElse("option", "") - val nameOption: String = options.getOrElse("name", "") - val reasonOption: String = options.getOrElse("reason", "none") - val worldOption: String = options.getOrElse("world", "") - - var authed = false - val user = event.getUser // Get the user who ran the command - val guild = event.getGuild - val member = guild.retrieveMember(user).complete() - if (member != null && member.hasPermission(Permission.MANAGE_SERVER)) { - authed = true - } - - subCommand match { - case "player" => - if (authed) { - if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.addAlly(event, "player", nameOption, reasonOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.removeAlly(event, "player", nameOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "guild" => - if (authed) { - if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.addAlly(event, "guild", nameOption, reasonOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) - BotApp.removeAlly(event, "guild", nameOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) - }) - }) - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "list" => - if (authed) { - BotApp.listAlliesAndHuntedGuilds(event, "allies", allies => { - val embedsJava = allies.asJava - embedsJava.forEach { embed => - event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() - } - BotApp.listAlliesAndHuntedPlayers(event, "allies", allies => { - val embedsJava = allies.asJava - embedsJava.forEach { embed => - event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() - } - }) - }) - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "clear" => - if (authed) { - val embed = BotApp.clearAllies(event) - event.getHook.sendMessageEmbeds(embed).queue() - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "deaths" => - if (authed) { - if (toggleOption == "show") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "allies", "deaths") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "hide") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "allies", "deaths") - event.getHook.sendMessageEmbeds(embed).queue() - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "levels" => - if (authed) { - if (toggleOption == "show") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "allies", "levels") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "hide") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "allies", "levels") - event.getHook.sendMessageEmbeds(embed).queue() - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "info" => - val embed = BotApp.infoAllies(event, "player", nameOption) - event.getHook.sendMessageEmbeds(embed).queue() - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$subCommand' for `/allies`.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - - } - - private def handleNeutrals(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - val subcommandGroupName = event.getInteraction.getSubcommandGroup - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val toggleOption: String = options.getOrElse("option", "") - val worldOption: String = options.getOrElse("world", "") - - if (subcommandGroupName != null) { - subcommandGroupName match { - case "tag" => - subCommand match { - case "add" => - val typeOption: String = options.getOrElse("type", "") - val nameOption: String = options.getOrElse("name", "").trim - val labelOption: String = options.getOrElse("label", "").replaceAll("[^a-zA-Z0-9\\s]", "").trim - val emojiOption: String = options.getOrElse("emoji", "").trim - if (labelOption == "" || emojiOption == ""){ - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You must supply a **label** and **emoji** when tagging a guild or player.").setColor(3092790).build() - event.getHook.sendMessageEmbeds(embed).queue() - } else { - - // default emoji regex - val emojiPattern = "^(?:[\\uD83C\\uDF00-\\uD83D\\uDDFF]|[\\uD83E\\uDD00-\\uD83E\\uDDFF]|[\\uD83D\\uDE00-\\uD83D\\uDE4F]|[\\uD83D\\uDE80-\\uD83D\\uDEFF]|[\\u2600-\\u26FF]\\uFE0F?|[\\u2700-\\u27BF]\\uFE0F?|\\u24C2\\uFE0F?|[\\uD83C\\uDDE6-\\uD83C\\uDDFF]{1,2}|[\\uD83C\\uDD70\\uD83C\\uDD71\\uD83C\\uDD7E\\uD83C\\uDD7F\\uD83C\\uDD8E\\uD83C\\uDD91-\\uD83C\\uDD9A]\\uFE0F?|[\\u0023\\u002A\\u0030-\\u0039]\\uFE0F?\\u20E3|[\\u2194-\\u2199\\u21A9-\\u21AA]\\uFE0F?|[\\u2B05-\\u2B07\\u2B1B\\u2B1C\\u2B50\\u2B55]\\uFE0F?|[\\u2934\\u2935]\\uFE0F?|[\\u3030\\u303D]\\uFE0F?|[\\u3297\\u3299]\\uFE0F?|[\\uD83C\\uDE01\\uD83C\\uDE02\\uD83C\\uDE1A\\uD83C\\uDE2F\\uD83C\\uDE32-\\uD83C\\uDE3A\\uD83C\\uDE50\\uD83C\\uDE51]\\uFE0F?|[\\u203C\\u2049]\\uFE0F?|[\\u25AA\\u25AB\\u25B6\\u25C0\\u25FB-\\u25FE]\\uFE0F?|[\\u00A9\\u00AE]\\uFE0F?|[\\u2122\\u2139]\\uFE0F?|\\uD83C\\uDC04\\uFE0F?|\\uD83C\\uDCCF\\uFE0F?|[\\u231A\\u231B\\u2328\\u23CF\\u23E9-\\u23F3\\u23F8-\\u23FA]\\uFE0F?)$".r - - val isValidEmoji = emojiPattern.findFirstIn(emojiOption).isDefined - if (isValidEmoji) { - BotApp.addOnlineListCategory(event, typeOption, nameOption, labelOption, emojiOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue() - }) - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} The provided emoji is invalid - use a standard discord emoji.\n:warning: Custom emojis are not supported.").setColor(3092790).build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - case "remove" => - val typeOption: String = options.getOrElse("type", "") - val nameOption: String = options.getOrElse("name", "").trim - val embed = BotApp.removeOnlineListCategory(event, typeOption, nameOption) - event.getHook.sendMessageEmbeds(embed).queue() - case "clear" => - val labelOption: String = options.getOrElse("label", "").replaceAll("[^a-zA-Z0-9\\s]", "").trim - val embed = BotApp.clearOnlineListCategory(event, labelOption) - event.getHook.sendMessageEmbeds(embed).queue() - case "list" => - val embeds = BotApp.listOnlineListCategory(event) - embeds.foreach { embed => - event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() - } - } - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommandGroup '$subcommandGroupName' for `/neutral`.").setColor(3092790).build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } else { - subCommand match { - case "deaths" => - if (toggleOption == "show") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "neutrals", "deaths") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "hide") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "neutrals", "deaths") - event.getHook.sendMessageEmbeds(embed).queue() - } - case "levels" => - if (toggleOption == "show") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "neutrals", "levels") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "hide") { - val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "neutrals", "levels") - event.getHook.sendMessageEmbeds(embed).queue() - } - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$subCommand' for `/neutral`.").setColor(3092790).build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - } - - private def handleFullbless(event: SlashCommandInteractionEvent): Unit = { - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val worldOption: String = options.getOrElse("world", "") - val levelOption: Int = options.get("level").map(_.toInt).getOrElse(250) - - val embed = BotApp.fullblessLevel(event, worldOption, levelOption) - event.getHook.sendMessageEmbeds(embed).queue() - } - - private def handleLeaderboards(event: SlashCommandInteractionEvent): Unit = { - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val worldOption: String = options.getOrElse("world", "") - - BotApp.leaderboards(event, worldOption, embed => { - event.getHook.sendMessageEmbeds(embed).queue() - }) - } - - private def handleFilter(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val worldOption: String = options.getOrElse("world", "") - val levelOption: Int = options.get("level").map(_.toInt).getOrElse(8) - - subCommand match { - case "levels" => - val embed = BotApp.minLevel(event, worldOption, levelOption, "levels") - event.getHook.sendMessageEmbeds(embed).queue() - case "deaths" => - val embed = BotApp.minLevel(event, worldOption, levelOption, "deaths") - event.getHook.sendMessageEmbeds(embed).queue() - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$subCommand' for `/filter`.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - - private def handleAdmin(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val guildOption: String = options.getOrElse("guildid", "") - val reasonOption: String = options.getOrElse("reason", "") - val messageOption: String = options.getOrElse("message", "") - - subCommand match { - case "leave" => - val embed = BotApp.adminLeave(event, guildOption, reasonOption) - event.getHook.sendMessageEmbeds(embed).queue() - case "dreamscar" => - val embed = BotApp.adminDreamScar(event) - event.getHook.sendMessageEmbeds(embed).queue() - case "message" => - val embed = BotApp.adminMessage(event, guildOption, messageOption) - event.getHook.sendMessageEmbeds(embed).queue() - case "worldlist" => - try { - WorldManager.getWorldList() - val embed = new EmbedBuilder() - .setDescription(s"${Config.yesEmoji} The worlds list has been refreshed.") - .build() - event.getHook.sendMessageEmbeds(embed).queue() - } catch { - case _: Exception => - val embed = new EmbedBuilder() - .setDescription(s"${Config.noEmoji} The worlds list has failed to refresh.") - .build() - event.getHook.sendMessageEmbeds(embed).queue() - } - case "info" => - BotApp.adminInfo(event, embeds => { - val embedsJava = embeds.asJava - embedsJava.forEach { embed => - event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() - } - }) - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$subCommand' for `/admin`.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - - private def handleExiva(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - - subCommand match { - case "deaths" => - val embed = BotApp.exivaList(event) - event.getHook.sendMessageEmbeds(embed).queue() - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$subCommand' for `/exiva`.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - - private def handleOnlineList(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val toggleOption: String = options.getOrElse("option", "") - - subCommand match { - case "list" => - val worldOption: String = options.getOrElse("world", "") - if (toggleOption == "separate") { - val embed = BotApp.onlineListConfig(event, worldOption, "separate") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "combine") { - val embed = BotApp.onlineListConfig(event, worldOption, "combine") - event.getHook.sendMessageEmbeds(embed).queue() - } - case _ => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$subCommand' for `/online`.").build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - - def toggleRole( - guild: Guild, - member: Member, - role: Role - ): Boolean = { - - if (member.getRoles.contains(role)) { - guild.removeRoleFromMember(member, role).queue() - false - } else { - guild.addRoleToMember(member, role).queue() - true - } - } - - private def handleBoosted(event: SlashCommandInteractionEvent): Unit = { - val subCommand = event.getInteraction.getSubcommandName - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val toggleOption: String = options.getOrElse("option", "") - - if (toggleOption == "disable") { // "disabled" - val embed = BotApp.boosted(event.getUser.getId, "disable", "") - event.getHook.sendMessageEmbeds(embed).queue() - } else if (toggleOption == "list") { - val embed = BotApp.boosted(event.getUser.getId, "list", "") - val allCheck = BotApp.boostedList(event.getUser.getId) - if (allCheck) { - event.getHook.sendMessageEmbeds(embed).setActionRow( - Button.success("boosted add", "Add").asDisabled, - Button.danger("boosted remove", "Remove").asDisabled, - Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOnEmoji)) - ).queue() - } else { - event.getHook.sendMessageEmbeds(embed).setActionRow( - Button.success("boosted add", "Add"), - Button.danger("boosted remove", "Remove") - ).queue() - } - } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid option for `/boosted`.").setColor(3092790).build() - event.getHook.sendMessageEmbeds(embed).queue() - } - } - - private def handleHelp(event: SlashCommandInteractionEvent): Unit = { - val embedBuilder = new EmbedBuilder() - val descripText = Config.helpText - embedBuilder.setAuthor("Violent Beams", "https://www.tibia.com/community/?subtopic=characters&name=Violent+Beams", "https://github.com/Leo32onGIT.png") - embedBuilder.setDescription(descripText) - embedBuilder.setThumbnail(Config.webHookAvatar) - embedBuilder.setColor(14397256) // orange for bot auto command - event.getHook.sendMessageEmbeds(embedBuilder.build()).queue() - } - - private def handleRepair(event: SlashCommandInteractionEvent): Unit = { - val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap - val worldOption: String = options.getOrElse("world", "") - - val embed = BotApp.repairChannel(event, worldOption) - event.getHook.sendMessageEmbeds(embed).queue() - } - - override def onMessageReceived(event: MessageReceivedEvent): Unit = { - // Ignore bot messages - if (!event.getAuthor.isBot) { - // Handle DM messages for screenshot uploads - if (!event.isFromGuild) { - handlePrivateMessage(event) - return - } - - // Handle guild messages for screenshot uploads - if (event.isFromGuild) { - val guild = event.getGuild - val user = event.getAuthor - val pendingKey = s"${user.getId}_${guild.getId}" - - // Check if this user has a pending screenshot request - pendingScreenshots.get(pendingKey) match { - case Some(pending) => - // Check if message has attachments - val attachments = event.getMessage.getAttachments.asScala - val imageAttachments = attachments.filter { attachment => - val fileName = attachment.getFileName.toLowerCase - fileName.endsWith(".png") || fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || - fileName.endsWith(".gif") || fileName.endsWith(".webp") - } - - if (imageAttachments.nonEmpty) { - val attachment = imageAttachments.head - val imageUrl = attachment.getUrl - - // Remove the pending request - pendingScreenshots.remove(pendingKey) + override def onModalInteraction(event: ModalInteractionEvent): Unit = interactions.ModalHandler.handle(event) - try { - // Store the screenshot in database - BotApp.storeDeathScreenshot(pending.guildId, pending.world, pending.charName, pending.deathTime, imageUrl, pending.userId, user.getName, pending.messageId) - - // Update the original death message with the screenshot - val channel = guild.getTextChannelById(pending.channelId) - if (channel != null) { - channel.retrieveMessageById(pending.messageId).queue(message => { - val embeds = message.getEmbeds - if (embeds.size() > 0) { - val originalEmbed = embeds.get(0) - val updatedEmbed = new EmbedBuilder(originalEmbed) - - // Get existing screenshots to check if we need navigation buttons - val screenshots = BotApp.getDeathScreenshots(pending.guildId, pending.world, pending.charName, pending.deathTime) - val screenshotCount = screenshots.length - val latestIndex = Math.max(0, screenshotCount - 1) // Show the newest screenshot (last in ASC order) - - // Update embed to show the newest screenshot - val latestScreenshot = if (screenshots.nonEmpty) screenshots.last else null - if (latestScreenshot != null) { - updatedEmbed.setImage(latestScreenshot.screenshotUrl) - .setFooter(s"Screenshot added by ${latestScreenshot.addedName} • ${screenshotCount}/${screenshotCount}") - } else { - updatedEmbed.setImage(imageUrl) - .setFooter(s"Screenshot added by ${user.getName}") - } - - val buttons = if (screenshotCount > 1) { - val baseButtons = List( - Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot"), - Button.primary(s"prev_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "◀"), - Button.secondary(s"screenshot_info_${pending.charName}_${pending.deathTime}_${pending.messageId}", s"${screenshotCount}/${screenshotCount}").asDisabled(), - Button.primary(s"next_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "▶") - ) - if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { - baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") - } else { - baseButtons - } - } else { - val baseButtons = List(Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot")) - if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { - baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") - } else { - baseButtons - } - } - - message.editMessageEmbeds(updatedEmbed.build()).setActionRow(buttons: _*).queue() - - // React to the user's message to confirm, then delete it - event.getMessage.addReaction(Emoji.fromUnicode("✅")).queue(_ => { - event.getMessage.delete().queue() - }) - - logger.info(s"Screenshot uploaded successfully for ${pending.charName} death at ${pending.deathTime}") - } - }) - } - } catch { - case e: Exception => - logger.error(s"Failed to store screenshot: ${e.getMessage}", e) - event.getMessage.addReaction(Emoji.fromUnicode("❌")).queue() - } - } - case None => - // No pending screenshot request for this user - } - } - } - } + override def onButtonInteraction(event: ButtonInteractionEvent): Unit = interactions.ButtonHandler.handle(event, pendingScreenshots) - private def handlePrivateMessage(event: MessageReceivedEvent): Unit = { - val user = event.getAuthor - - // Check if this user has a pending screenshot request for any guild - val userPendingScreenshots = pendingScreenshots.filter(_._1.startsWith(user.getId + "_")).toMap - - if (userPendingScreenshots.nonEmpty) { - // Check if message has attachments - val attachments = event.getMessage.getAttachments.asScala - val imageAttachments = attachments.filter { attachment => - val fileName = attachment.getFileName.toLowerCase - fileName.endsWith(".png") || fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || - fileName.endsWith(".gif") || fileName.endsWith(".webp") - } - - if (imageAttachments.nonEmpty) { - val attachment = imageAttachments.head - val imageUrl = attachment.getUrl - - // Process all pending screenshots for this user (in case they have multiple) - userPendingScreenshots.foreach { case (pendingKey, pending) => - // Remove the pending request - pendingScreenshots.remove(pendingKey) - - try { - // Store the screenshot in database - BotApp.storeDeathScreenshot(pending.guildId, pending.world, pending.charName, pending.deathTime, imageUrl, pending.userId, user.getName, pending.messageId) - - // Update the original death message with the screenshot - val guild = event.getJDA.getGuildById(pending.guildId) - if (guild != null) { - val channel = guild.getTextChannelById(pending.channelId) - if (channel != null) { - channel.retrieveMessageById(pending.messageId).queue(message => { - val embeds = message.getEmbeds - if (embeds.size() > 0) { - val originalEmbed = embeds.get(0) - val updatedEmbed = new EmbedBuilder(originalEmbed) - - // Get existing screenshots to check if we need navigation buttons - val screenshots = BotApp.getDeathScreenshots(pending.guildId, pending.world, pending.charName, pending.deathTime) - val screenshotCount = screenshots.length - val latestIndex = Math.max(0, screenshotCount - 1) // Show the newest screenshot (last in ASC order) - - // Update embed to show the newest screenshot - val latestScreenshot = if (screenshots.nonEmpty) screenshots.last else null - if (latestScreenshot != null) { - updatedEmbed.setImage(latestScreenshot.screenshotUrl) - .setFooter(s"Screenshot added by ${latestScreenshot.addedName} • ${screenshotCount}/${screenshotCount}") - } else { - updatedEmbed.setImage(imageUrl) - .setFooter(s"Screenshot added by ${user.getName}") - } - - val buttons = if (screenshotCount > 1) { - val baseButtons = List( - Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot"), - Button.primary(s"prev_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "◀"), - Button.secondary(s"screenshot_info_${pending.charName}_${pending.deathTime}_${pending.messageId}", s"${screenshotCount}/${screenshotCount}").asDisabled(), - Button.primary(s"next_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "▶") - ) - if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { - baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") - } else { - baseButtons - } - } else { - val baseButtons = List(Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot")) - if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { - baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") - } else { - baseButtons - } - } - - message.editMessageEmbeds(updatedEmbed.build()).setActionRow(buttons: _*).queue() - - logger.info(s"Screenshot uploaded successfully via DM for ${pending.charName} death at ${pending.deathTime} in guild ${guild.getName}") - } - }) - } - } - - // Send confirmation DM to user - event.getChannel.sendMessage(s"${Config.yesEmoji} Screenshot uploaded successfully for **[${pending.charName}](${BotApp.charUrl(pending.charName)})**.").queue() - - } catch { - case e: Exception => - logger.error(s"Failed to store screenshot from DM: ${e.getMessage}", e) - event.getChannel.sendMessage(s"${Config.noEmoji} Failed to upload screenshot. Please try again.").queue() - } - } - } else { - // Check if user is trying to cancel uploads - val messageContent = event.getMessage.getContentRaw.toLowerCase.trim - if (messageContent.contains("cancel")) { - // Cancel all pending uploads for this user - val cancelledCount = userPendingScreenshots.size - userPendingScreenshots.keys.foreach(pendingScreenshots.remove) - - if (cancelledCount == 1) { - event.getChannel.sendMessage(s"Your pending upload has been cancelled.").queue() - } else if (cancelledCount > 1) { - event.getChannel.sendMessage(s"${cancelledCount} pending uploads have been cancelled.").queue() - } - - logger.info(s"User ${user.getName} (${user.getId}) cancelled ${cancelledCount} pending uploads via DM") - } else { - // User sent a DM but no image attachment and not a cancel command - event.getChannel.sendMessage("Please upload an image file (PNG, JPG, GIF, WebP) or paste an image from your clipboard.\nType `cancel` to cancel any pending upload requests.").queue() - } - } - } else { - // No pending uploads, check if user is asking for help or trying to cancel - val messageContent = event.getMessage.getContentRaw.toLowerCase.trim - if (messageContent.contains("cancel")) { - event.getChannel.sendMessage("You don't have any pending uploads to cancel.").queue() - } - } - } + override def onMessageReceived(event: MessageReceivedEvent): Unit = interactions.ScreenshotMessageHandler.onMessage(event, pendingScreenshots) } diff --git a/tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala b/tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala index 4e4d16c..365e974 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala @@ -10,6 +10,7 @@ import java.time.ZonedDateTime object CreatureManager extends StrictLogging { + implicit private val system: akka.actor.ActorSystem = akka.actor.ActorSystem() implicit private val executionContext: ExecutionContextExecutor = scala.concurrent.ExecutionContext.global private val tibiaDataClient = new TibiaDataClient() diff --git a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala index ac2b2f1..b6944ba 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala @@ -1,15 +1,17 @@ package com.tibiabot -import akka.actor.Cancellable +import akka.actor.{ActorSystem, Cancellable} import akka.stream.ActorAttributes.supervisionStrategy import akka.stream.scaladsl.{Flow, Keep, RunnableGraph, Sink, Source} import akka.stream.{Attributes, Materializer, Supervision} import com.tibiabot.BotApp.{alliedGuildsData, alliedPlayersData, discordsData, huntedGuildsData, huntedPlayersData, worldsData, activityData, customSortData, Players} -import com.tibiabot.tibiadata.TibiaDataClient +import com.tibiabot.tibiadata.{TibiaApi, TibiaDataClient} import com.tibiabot.tibiadata.response.{CharacterResponse, Deaths, OnlinePlayers, WorldResponse} import com.typesafe.scalalogging.StrictLogging import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.channel.concrete.TextChannel +import net.dv8tion.jda.api.exceptions.ErrorHandler +import net.dv8tion.jda.api.requests.ErrorResponse import net.dv8tion.jda.api.interactions.components.ActionRow import net.dv8tion.jda.api.interactions.components.buttons.Button @@ -29,21 +31,20 @@ import java.util.concurrent.ConcurrentHashMap import java.time.Instant //noinspection FieldFromDelayedInit -class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materializer) extends StrictLogging { +class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContextExecutor, mat: Materializer) extends StrictLogging { // A date-based "key" for a character, used to track recent deaths and recent online entries private case class CharKey(char: String, time: ZonedDateTime) private case class CharKeyBypass(char: String, level: Int, time: ZonedDateTime) private case class CurrentOnline(name: String, level: Int, vocation: String, guildName: String, time: ZonedDateTime, duration: Long = 0L, flag: String) private case class CharDeath(char: CharacterResponse, death: Deaths) - private case class CharLevel(name: String, level: Int, vocation: String, lastLogin: ZonedDateTime, time: ZonedDateTime) private case class CharSort(guildName: String, allyGuild: Boolean, huntedGuild: Boolean, allyPlayer: Boolean, huntedPlayer: Boolean, vocation: String, level: Int, message: String) private case class OnlineListEntry(name: String, level: Int, lastUpdated: ZonedDateTime) //val guildId: String = guild.getId private val recentDeaths = mutable.Set.empty[CharKey] - private val recentLevels = mutable.Set.empty[CharLevel] + private val levelTracker = new tracking.LevelTracker private val recentOnline = mutable.Set.empty[CharKey] private val recentOnlineBypass = mutable.Set.empty[CharKeyBypass] private var currentOnline = mutable.Set.empty[CurrentOnline] @@ -54,7 +55,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi // initialize cached deaths/levels from database recentDeaths ++= BotApp.getDeathsCache(world).map(deathsCache => CharKey(deathsCache.name, ZonedDateTime.parse(deathsCache.time))) - recentLevels ++= BotApp.getLevelsCache(world).map(levelsCache => CharLevel(levelsCache.name, levelsCache.level.toInt, levelsCache.vocation, ZonedDateTime.parse(levelsCache.lastLogin), ZonedDateTime.parse(levelsCache.time))) + levelTracker.load(BotApp.getLevelsCache(world).map(levelsCache => tracking.LevelRecord(levelsCache.name, levelsCache.level.toInt, levelsCache.vocation, ZonedDateTime.parse(levelsCache.lastLogin), ZonedDateTime.parse(levelsCache.time)))) private var onlineListTimer: Map[String, ZonedDateTime] = Map.empty private var onlineListCategoryTimer: Map[String, ZonedDateTime] = Map.empty @@ -65,13 +66,20 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi private var onlineListTableUpdateTimer: ZonedDateTime = ZonedDateTime.now().minusMinutes(10) // Start immediately // ZonedDateTime.parse("2022-01-01T01:00:00Z") - private val tibiaDataClient = new TibiaDataClient() + private val tibiaDataClient: TibiaApi = new TibiaDataClient() private val deathRecentDuration = 30 * 60 // 30 minutes for a death to count as recent enough to be worth notifying private val onlineRecentDuration = 10 * 60 // 10 minutes for a character to still be checked for deaths after logging off private val recentLevelExpiry = 25 * 60 * 60 // 25 hours before deleting recentLevel entry private val cooldowns = new ConcurrentHashMap[String, ZonedDateTime]() private val cooldownMinutes = 30L + // Safety cap on the per-world outbound backlog (~drains 1 / message-delay-ms). Beyond + // this the sender drops and logs rather than growing unbounded under a pathological burst. + private val outboundQueueCapacity = 10000 + // Benign, operator-side send failures (channel deleted / perms removed) — ignore instead + // of letting JDA's default handler spam them on every cycle; other errors still log. + private val ignoreDeletedTarget = new ErrorHandler() + .ignore(ErrorResponse.UNKNOWN_CHANNEL, ErrorResponse.MISSING_PERMISSIONS, ErrorResponse.MISSING_ACCESS) private val logAndResumeDecider: Supervision.Decider = { e => logger.error("An exception has occurred in the TibiaBot:", e) @@ -210,7 +218,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi // Activity channel if (!blocker) { - val guild = BotApp.jda.getGuildById(discords.id) + val guild = BotApp.discordGateway.guildById(discords.id) val worldData = worldsData.getOrElse(guildId, List()).filter(w => w.name.toLowerCase() == world.toLowerCase()) val activityChannel = worldData.headOption.map(_.activityChannel).getOrElse("0") val activityTextChannel = guild.getTextChannelById(activityChannel) @@ -256,7 +264,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } if (oldName != ""){ // update name in cache and db - activityData = activityData + (guildId -> updatedActivityData) + BotApp.modifyActivityData(m => m + (guildId -> updatedActivityData)) BotApp.updateActivityToDatabase(guild, oldName, formerNamesList, guildName, ZonedDateTime.now(), charName) skipJoinLeave = true if (timeDelay.isDefined) { @@ -273,7 +281,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi player } } - huntedPlayersData = huntedPlayersData + (guildId -> updatedHuntedPlayersData) + BotApp.modifyHuntedPlayersData(m => m + (guildId -> updatedHuntedPlayersData)) } if (allyPlayerCheck) { // change name in allied players cache and db @@ -285,7 +293,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi player } } - alliedPlayersData = alliedPlayersData + (guildId -> updatedAlliedPlayersData) + BotApp.modifyAlliedPlayersData(m => m + (guildId -> updatedAlliedPlayersData)) } if (activityTextChannel != null) { if (activityTextChannel.canTalk() || (!Config.prod)) { @@ -366,7 +374,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } // remove from hunted list if in allied guild if (allyGuildCheck) { - huntedPlayersData = huntedPlayersData.updated(guildId, huntedPlayersData.getOrElse(guildId, List.empty).filterNot(_.name == charName)) + BotApp.modifyHuntedPlayersData(m => m.updated(guildId, m.getOrElse(guildId, List.empty).filterNot(_.name == charName))) BotApp.removeHuntedFromDatabase(guild, "player", charName.toLowerCase()) val adminTextChannel = guild.getTextChannelById(adminChannel) if (adminTextChannel != null) { @@ -388,7 +396,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi if (wasInHuntedGuild) { if (!allyGuildCheck && !huntedGuildCheck && !huntedPlayerCheck && !allyPlayerCheck) { // add them to cached huntedPlayersData list - huntedPlayersData = huntedPlayersData + (guildId -> (BotApp.Players(charName.toLowerCase(), "false", s"was originally in hunted guild ${guildNameFromActivityData}", BotApp.botUser) :: huntedPlayersData.getOrElse(guildId, List()))) + BotApp.modifyHuntedPlayersData(m => m + (guildId -> (BotApp.Players(charName.toLowerCase(), "false", s"was originally in hunted guild ${guildNameFromActivityData}", BotApp.botUser) :: m.getOrElse(guildId, List())))) BotApp.addHuntedToDatabase(guild, "player", charName.toLowerCase(), "false", s"was originally in hunted guild ${guildNameFromActivityData}", BotApp.botUser) val adminTextChannel = guild.getTextChannelById(adminChannel) if (adminTextChannel != null) { @@ -407,7 +415,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } else if (wasInAlliedGuild){ if (!allyGuildCheck && !huntedGuildCheck && !huntedPlayerCheck && !allyPlayerCheck) { // remove from activity - activityData = activityData + (guildId -> activityData.getOrElse(guildId, List()).filterNot(_.name.equalsIgnoreCase(charName.toLowerCase))) + BotApp.modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(_.name.equalsIgnoreCase(charName.toLowerCase)))) BotApp.removePlayerActivityfromDatabase(guild, charName.toLowerCase) } } @@ -419,7 +427,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi // joined a hunted guild if (huntedGuildCheck) { // remove from hunted 'Player' cache and db - huntedPlayersData = huntedPlayersData.updated(guildId, huntedPlayersData.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase)) + BotApp.modifyHuntedPlayersData(m => m.updated(guildId, m.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase))) BotApp.removeHuntedFromDatabase(guild, "player", charName.toLowerCase()) // send message to admin channel val adminTextChannel = guild.getTextChannelById(adminChannel) @@ -437,7 +445,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } } else if (allyGuildCheck) { // remove from hunted 'Player' cache and db - huntedPlayersData = huntedPlayersData.updated(guildId, huntedPlayersData.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase)) + BotApp.modifyHuntedPlayersData(m => m.updated(guildId, m.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase))) BotApp.removeHuntedFromDatabase(guild, "player", charName.toLowerCase()) // send message to admin channel val adminTextChannel = guild.getTextChannelById(adminChannel) @@ -477,7 +485,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi }.getOrElse(activityData.getOrElse(guildId, List())) // Update in cache and db - activityData = activityData + (guildId -> updatedActivityData) + BotApp.modifyActivityData(m => m + (guildId -> updatedActivityData)) BotApp.updateActivityToDatabase(guild, charName, formerNamesList, guildName, ZonedDateTime.now(), charName) } } @@ -485,13 +493,13 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi // add to cache and db val newActivity = BotApp.PlayerCache(charName, formerNamesList, guildName, ZonedDateTime.now()) val updatedActivityData = newActivity :: activityData.getOrElse(guildId, List()) - activityData = activityData + (guildId -> updatedActivityData) + BotApp.modifyActivityData(m => m + (guildId -> updatedActivityData)) BotApp.addActivityToDatabase(guild, charName, formerNamesList, guildName, ZonedDateTime.now()) // joined a hunted guild if (huntedGuildCheck) { if (huntedPlayerCheck) { // was he originally in hunted 'player' list? // remove from hunted 'Player' cache and db - huntedPlayersData = huntedPlayersData.updated(guildId, huntedPlayersData.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase)) + BotApp.modifyHuntedPlayersData(m => m.updated(guildId, m.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase))) BotApp.removeHuntedFromDatabase(guild, "player", charName.toLowerCase()) // send message to admin channel val adminTextChannel = guild.getTextChannelById(adminChannel) @@ -511,7 +519,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } else if (allyGuildCheck) { // joined an allied guild if (allyPlayerCheck) { // remove from allied 'Player' cache and db - alliedPlayersData = alliedPlayersData.updated(guildId, alliedPlayersData.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase)) + BotApp.modifyAlliedPlayersData(m => m.updated(guildId, m.getOrElse(guildId, List.empty).filterNot(_.name.toLowerCase == charName.toLowerCase))) BotApp.removeAllyFromDatabase(guild, "player", charName.toLowerCase()) // send message to admin channel val adminTextChannel = guild.getTextChannelById(adminChannel) @@ -572,12 +580,12 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi currentOnline.find(_.name == charName).foreach { onlinePlayer => // level (i need to add logic here to batch messages control throughput a bit) if (onlinePlayer.level > sheetLevel) { - val newCharLevel = CharLevel(charName, onlinePlayer.level, sheetVocation, sheetLastLogin, now) + val newLevelRecord = tracking.LevelRecord(charName, onlinePlayer.level, sheetVocation, sheetLastLogin, now) // post level to each discord if (discordsData.contains(world)) { val discordsList = discordsData(world) discordsList.foreach { discords => - val guild = BotApp.jda.getGuildById(discords.id) + val guild = BotApp.discordGateway.guildById(discords.id) val guildId = discords.id // get appropriate guildIcon @@ -622,19 +630,8 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } else { true } - if (recentLevels.exists(x => x.name == charName && x.level == onlinePlayer.level)) { - val lastLoginInRecentLevels = recentLevels.filter(x => x.name == charName && x.level == onlinePlayer.level) - if (lastLoginInRecentLevels.forall(x => x.lastLogin.isBefore(sheetLastLogin))) { - if (levelsCheck) { - //createAndSendWebhookMessage(levelsTextChannel, webhookMessage, s"${world.capitalize}") - //sender.sendWebhookMessage(guild, levelsTextChannel, webhookMessage, s"${world.capitalize}") - sendMessageWithRateLimit(levelsTextChannel, message = webhookMessage) - } - } - } else { + if (levelTracker.shouldRecord(charName, onlinePlayer.level, sheetLastLogin)) { if (levelsCheck) { - //createAndSendWebhookMessage(levelsTextChannel, webhookMessage, s"${world.capitalize}") - //sender.sendWebhookMessage(guild, levelsTextChannel, webhookMessage, s"${world.capitalize}") sendMessageWithRateLimit(levelsTextChannel, message = webhookMessage) } } @@ -647,14 +644,8 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi currentOnline -= onlinePlayer currentOnline += onlinePlayer.copy(flag = Config.levelUpEmoji) } - if (recentLevels.exists(x => x.name == charName && x.level == onlinePlayer.level)) { - val lastLoginInRecentLevels = recentLevels.filter(x => x.name == charName && x.level == onlinePlayer.level) - if (lastLoginInRecentLevels.forall(x => x.lastLogin.isBefore(sheetLastLogin))) { - recentLevels += newCharLevel - BotApp.addLevelsCache(world, charName, onlinePlayer.level.toString, sheetVocation, sheetLastLogin.toString, now.toString) - } - } else { - recentLevels += newCharLevel + if (levelTracker.shouldRecord(charName, onlinePlayer.level, sheetLastLogin)) { + levelTracker.record(newLevelRecord) BotApp.addLevelsCache(world, charName, onlinePlayer.level.toString, sheetVocation, sheetLastLogin.toString, now.toString) } } @@ -705,7 +696,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi if (discordsData.contains(world)) { val discordsList = discordsData(world) discordsList.foreach { discords => - val guild = BotApp.jda.getGuildById(discords.id) + val guild = BotApp.discordGateway.guildById(discords.id) val guildId = discords.id val adminChannel = discords.adminChannel val worldData = worldsData.getOrElse(guildId, List()).filter(w => w.name.toLowerCase() == world.toLowerCase()) @@ -954,7 +945,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi huntedBuffer.foreach { case (player, world, vocation, level) => val playerString = player.toLowerCase() // add them to cached huntedPlayersData list - huntedPlayersData = huntedPlayersData + (guildId -> (BotApp.Players(playerString, "false", "killed an allied player", BotApp.botUser) :: huntedPlayersData.getOrElse(guildId, List()))) + BotApp.modifyHuntedPlayersData(m => m + (guildId -> (BotApp.Players(playerString, "false", "killed an allied player", BotApp.botUser) :: m.getOrElse(guildId, List())))) // add them to the database BotApp.addHuntedToDatabase(guild, "player", playerString, "false", "killed an allied player", BotApp.botUser) // send embed to admin channel @@ -1018,14 +1009,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi embedCheck = false } } - val embed = new EmbedBuilder() - embed.setTitle( - s"${vocEmoji(charDeath.char.character.character.vocation)} $charName ${vocEmoji(charDeath.char.character.character.vocation)}", - charUrl(charName) - ) - embed.setDescription(embedText) - embed.setThumbnail(embedThumbnail) - embed.setColor(embedColor) + val embed = presentation.DeathEmbeds.build(charName, charDeath.char.character.character.vocation, embedText, embedThumbnail, embedColor) // return embed + poke (embed, notablePoke, charName, embedText, charDeath.death.level.toInt, embedCheck, epochSecond) @@ -1130,17 +1114,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi val voc = player.vocation.toLowerCase.split(' ').last val vocationEmoji = vocEmoji(voc) val durationInSec = player.duration - val durationInMin = durationInSec / 60 - val durationStr = - if (durationInMin >= 60) { - val hours = durationInMin / 60 - val mins = durationInMin % 60 - s"${hours}hr ${mins}min" - } else { - s"${durationInMin}min" - } - - val durationString = s"`$durationStr`" + val durationString = presentation.OnlineListEmbeds.durationString(durationInSec) val allyGuildCheck = alliedGuildsData.getOrElse(guildId, List()) .exists(_.name.equalsIgnoreCase(player.guildName)) val huntedGuildCheck = huntedGuildsData.getOrElse(guildId, List()) @@ -1169,7 +1143,7 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } // run channel checks before updating the channels - val guild = BotApp.jda.getGuildById(guildId) + val guild = BotApp.discordGateway.guildById(guildId) val pattern = "^(.*?)(?:-[0-9]+)?$".r // default online list @@ -1204,36 +1178,8 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi // Masslog formula val enemyCount = enemiesList.size - // val sensitivity = //make user configurable - val sensitivity = 0 - val masslogFloor = 3 - - // convert sensitivity into a multiplier adjustment - val sensitivityModifier = sensitivity match { - case 0 => 1.20 // stricter - case 1 => 1.10 - case 2 => 1.00 // default - case 3 => 0.90 - case 4 => 0.80 // very sensitive - } - - // base percentages - val basePercentage = - enemyCount match { - case n if n <= 5 => 0.60 - case n if n <= 10 => 0.55 - case n if n <= 20 => 0.40 - case _ => 0.32 - } - - // adjusted percentage - val adjustedPercentage = basePercentage * sensitivityModifier - - // final required zap count - // Masslog minimum of 3 - val requiredZapCount = math.max(masslogFloor, math.ceil(enemyCount * adjustedPercentage).toInt) - - val masslogCategory = zapCount >= requiredZapCount + // Masslog threshold (sensitivity fixed at 0 today; formula in tracking.MasslogDetector) + val masslogCategory = tracking.MasslogDetector.isMasslog(zapCount, enemyCount, sensitivity = 0) if (masslogCategory && !isOnCooldown) { // get Activity channel val activityTextChannel = guild.getTextChannelById(activityChannel) @@ -1733,34 +1679,14 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi val diff = java.time.Duration.between(i.time, now).getSeconds diff < deathRecentDuration } - recentLevels.filterInPlace { i => - val diff = java.time.Duration.between(i.time, now).getSeconds - diff < recentLevelExpiry - } + levelTracker.prune(now, recentLevelExpiry) } - private def vocEmoji(vocation: String): String = { - val voc = vocation.toLowerCase.split(' ').last - voc match { - case "knight" => ":shield:" - case "druid" => ":snowflake:" - case "sorcerer" => ":fire:" - case "paladin" => ":bow_and_arrow:" - case "monk" => ":fist::skin-tone-3:" - case "none" => ":hatching_chick:" - case _ => "" - } - } + private def vocEmoji(vocation: String): String = presentation.Emojis.vocEmoji(vocation) - private def guildUrl(guild: String): String = { - val encodedString = URLEncoder.encode(guild, StandardCharsets.UTF_8.toString) - s"https://www.tibia.com/community/?subtopic=guilds&page=view&GuildName=${encodedString}" - } + private def guildUrl(guild: String): String = presentation.Urls.guildUrl(guild) - private def charUrl(char: String): String = { - val encodedString = URLEncoder.encode(char, StandardCharsets.UTF_8.toString) - s"https://www.tibia.com/community/?name=${encodedString}" - } + private def charUrl(char: String): String = presentation.Urls.charUrl(char) private def getKillerLevel(killerName: String): Option[Int] = { logger.info(s"getKillerLevel called for: $killerName") @@ -1793,21 +1719,8 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi } } - private def creatureImageUrl(creature: String): String = { - val finalCreature = Config.creatureUrlMappings.getOrElse(creature.toLowerCase, { - // Capitalise the start of each word, including after punctuation e.g. "Mooh'Tah Warrior", "Two-Headed Turtle" - val rx1 = """([^\w]\w)""".r - val parsed1 = rx1.replaceAllIn(creature, m => m.group(1).toUpperCase) - - // Lowercase the articles, prepositions etc., e.g. "The Voice of Ruin" - val rx2 = """( A| Of| The| In| On| To| And| With| From)(?=( ))""".r - val parsed2 = rx2.replaceAllIn(parsed1, m => m.group(1).toLowerCase) - - // Replace spaces with underscores and make sure the first letter is capitalised - parsed2.replaceAll(" ", "_").capitalize - }) - s"https://www.tibiawiki.com.br/wiki/Special:Redirect/file/$finalCreature.gif" - } + private def creatureImageUrl(creature: String): String = + presentation.Urls.creatureImageUrl(creature, Config.creatureUrlMappings) lazy val stream: RunnableGraph[Cancellable] = sourceTick via @@ -1817,48 +1730,14 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi postToDiscordAndCleanUp to Sink.ignore // Message queue for rate limiting - private case class QueuedMessage( - channel: TextChannel, - message: String, - embed: Option[EmbedBuilder] = None, - suppressNotifications: Boolean = true - ) - - private val messageQueue = mutable.Queue[QueuedMessage]() - private var messageQueueProcessor: Option[Cancellable] = None - - // Initialize message queue processor - private def startMessageQueueProcessor(): Unit = { - if (messageQueueProcessor.isEmpty) { - messageQueueProcessor = Some( - mat.system.scheduler.scheduleWithFixedDelay( - 0.seconds, - Config.messageDelayMs.milliseconds - )(() => processMessageQueue()) - ) - } - } - - private def processMessageQueue(): Unit = { - if (messageQueue.nonEmpty) { - val msg = messageQueue.dequeue() - try { - msg.embed match { - case Some(embed) => - if (msg.message.nonEmpty) { - msg.channel.sendMessage(msg.message).setEmbeds(embed.build()).setSuppressedNotifications(msg.suppressNotifications).queue() - } else { - msg.channel.sendMessageEmbeds(embed.build()).setSuppressedNotifications(msg.suppressNotifications).queue() - } - case None => - msg.channel.sendMessage(msg.message).setSuppressedNotifications(msg.suppressNotifications).queue() - } - } catch { - case ex: Exception => logger.error(s"Failed to send queued message: ${ex.getMessage}") - case _: Throwable => logger.error("Failed to send queued message") - } - } - } + // Outbound message delivery: rate-limited and drained one message per tick. + private val outboundSender = new discord.RateLimitedSender(drain => { + val cancellable = mat.system.scheduler.scheduleWithFixedDelay( + 0.seconds, + Config.messageDelayMs.milliseconds + )(new Runnable { def run(): Unit = drain() })(mat.system.dispatcher) + () => cancellable.cancel() + }, outboundQueueCapacity) def canPing(channelId: String): Boolean = { pingCleanup() @@ -1891,8 +1770,17 @@ class TibiaBot(world: String)(implicit ex: ExecutionContextExecutor, mat: Materi embed: Option[EmbedBuilder] = None, suppressNotifications: Boolean = true ): Unit = { - messageQueue.enqueue(QueuedMessage(channel, message, embed, suppressNotifications)) - startMessageQueueProcessor() + outboundSender.enqueue { () => + embed match { + case Some(e) => + if (message.nonEmpty) + channel.sendMessage(message).setEmbeds(e.build()).setSuppressedNotifications(suppressNotifications).queue(null, ignoreDeletedTarget) + else + channel.sendMessageEmbeds(e.build()).setSuppressedNotifications(suppressNotifications).queue(null, ignoreDeletedTarget) + case None => + channel.sendMessage(message).setSuppressedNotifications(suppressNotifications).queue(null, ignoreDeletedTarget) + } + } } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala b/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala index dec80e8..ef07884 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala @@ -10,6 +10,7 @@ import java.time.ZonedDateTime object WorldManager extends StrictLogging { + implicit private val system: akka.actor.ActorSystem = akka.actor.ActorSystem() implicit private val executionContext: ExecutionContextExecutor = scala.concurrent.ExecutionContext.global private val tibiaDataClient = new TibiaDataClient() diff --git a/tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala b/tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala new file mode 100644 index 0000000..ca898e7 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala @@ -0,0 +1,123 @@ +package com.tibiabot.admin + +import com.tibiabot.Config +import com.tibiabot.discord.DiscordGateway +import com.typesafe.scalalogging.StrictLogging +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.{Guild, MessageEmbed} + +import scala.collection.mutable.ListBuffer + +/** + * Bot-creator-only `/admin` operations, moved from BotApp. The shared `dreamScar` + * write stays in BotApp via the injected `resyncDreamScar` thunk; guild config + * lookup is injected too, so this is JDA-gateway + function deps only. + */ +final class AdminService( + discordGateway: DiscordGateway, + botUserId: String, + retrieveConfig: Guild => Map[String, String], + resyncDreamScar: () => Unit +) extends StrictLogging { + + /** Leave a guild, posting the reason to its admin channel first. */ + def leave(guildId: String, reason: String): MessageEmbed = { + val guild = discordGateway.guildById(guildId) + val discordInfo = retrieveConfig(guild) + var embedMessage = "" + + if (discordInfo.isEmpty) { + embedMessage = s":gear: The bot has left the Guild: **${guild.getName()}** without leaving a message for the owner." + } else { + val adminChannel = guild.getTextChannelById(discordInfo("admin_channel")) + if (adminChannel != null) { + if (adminChannel.canTalk() || !(Config.prod)) { + try { + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} The creator of the bot has run a command:") + adminEmbed.setDescription(s"<@$botUserId> has left your discord because of the following reason:\n> ${reason}") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Abacus.gif") + adminEmbed.setColor(3092790) + adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Throwable => logger.info(s"Failed to send admin message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + } + } + } + embedMessage = s":gear: The bot has left the Guild: **${guild.getName()}** and left a message for the owner." + } + + guild.leave().queue() + new EmbedBuilder() + .setColor(3092790) + .setDescription(embedMessage) + .build() + } + + /** Re-fetch the Dream Courts boss-of-the-day per world. */ + def resyncDreamCourtBosses(): MessageEmbed = { + resyncDreamScar() + new EmbedBuilder() + .setColor(3092790) + .setDescription(s":gear: The dreamcourts bosses for each world have been resynced.") + .build() + } + + /** Forward a message from the bot creator to a guild's admin channel. */ + def message(guildId: String, message: String): MessageEmbed = { + val guild = discordGateway.guildById(guildId) + val discordInfo = retrieveConfig(guild) + var embedMessage = "" + + if (discordInfo.isEmpty) { + embedMessage = s"${Config.noEmoji} The Guild: **${guild.getName()}** doesn't have any worlds setup yet, so a message cannot be sent." + } else { + val adminChannel = guild.getTextChannelById(discordInfo("admin_channel")) + if (adminChannel != null) { + if (adminChannel.canTalk() || !(Config.prod)) { + try { + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} The creator of the bot has run a command:") + adminEmbed.setDescription(s"<@$botUserId> has forwarded a message from the bot's creator:\n> ${message}") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Letter.gif") + adminEmbed.setColor(3092790) + adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Throwable => logger.info(s"Failed to send admin message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") + } + } + } else { + embedMessage = s"${Config.noEmoji} The Guild: **${guild.getName()}** has deleted the `command-log` channel, so a message cannot be sent." + } + embedMessage = s":gear: The bot has left a message for the Guild: **${guild.getName()}**." + } + new EmbedBuilder() + .setColor(3092790) + .setDescription(embedMessage) + .build() + } + + /** Paginated list of every guild the bot is in, delivered via callback. */ + def info(callback: List[MessageEmbed] => Unit): Unit = { + val allGuilds = discordGateway.guilds + val allGuildsCleaned: List[String] = allGuilds.map(guild => s"**${guild.getName}** - `${guild.getId}`") + logger.info(allGuildsCleaned.toString) + val embedBuffer = ListBuffer[MessageEmbed]() + var field = "" + allGuildsCleaned.foreach { v => + val currentField = field + "\n" + v + if (currentField.length <= 3000) { + field = currentField + } else { + val interimEmbed = new EmbedBuilder() + interimEmbed.setDescription(field) + embedBuffer += interimEmbed.build() + field = v + } + } + val finalEmbed = new EmbedBuilder() + finalEmbed.setDescription(field) + embedBuffer += finalEmbed.build() + callback(embedBuffer.toList) + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/app/Bootstrap.scala b/tibia-bot/src/main/scala/com/tibiabot/app/Bootstrap.scala new file mode 100644 index 0000000..d1f0be7 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/app/Bootstrap.scala @@ -0,0 +1,16 @@ +package com.tibiabot.app + +import net.dv8tion.jda.api.{JDA, JDABuilder} + +/** Startup wiring for the Discord session, kept out of BotApp's body. */ +object Bootstrap { + + /** Build the JDA session with the given listeners and block until it is ready. */ + def buildReadyJda(token: String, listeners: AnyRef*): JDA = { + val jda = JDABuilder.createDefault(token) + .addEventListeners(listeners: _*) + .build() + jda.awaitReady() + jda + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/app/StreamSupervisor.scala b/tibia-bot/src/main/scala/com/tibiabot/app/StreamSupervisor.scala new file mode 100644 index 0000000..33211e3 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/app/StreamSupervisor.scala @@ -0,0 +1,57 @@ +package com.tibiabot.app + +import akka.actor.Cancellable +import com.tibiabot.domain.Discords +import com.typesafe.scalalogging.StrictLogging + +/** A running per-world Akka stream and the guilds it serves. */ +final case class WorldStream(stream: Cancellable, usedBy: List[Discords]) + +/** + * Owns the per-world stream handles (previously `BotApp.botStreams`) and the + * attach / remove / cancel lifecycle. Streams are started elsewhere and handed + * in via [[put]]; this type only tracks them and cancels a stream once no guild + * uses it. Not thread-safe by itself — callers serialise mutations as before. + */ +final class StreamSupervisor extends StrictLogging { + + private var streams = Map.empty[String, WorldStream] + + def contains(world: String): Boolean = streams.contains(world) + def get(world: String): Option[WorldStream] = streams.get(world) + def activeWorlds: Set[String] = streams.keySet + def snapshot: Map[String, WorldStream] = streams + + /** Register an already-started `stream` for `world`, serving `usedBy`. */ + def put(world: String, stream: Cancellable, usedBy: List[Discords]): Unit = + streams += (world -> WorldStream(stream, usedBy)) + + /** Drop `guildId` from every world; cancel and remove any stream left unused. */ + def removeGuild(guildId: String): Unit = + streams = streams.flatMap { case (world, s) => + val updatedUsedBy = s.usedBy.filterNot(_.id == guildId) + if (updatedUsedBy.isEmpty) { + s.stream.cancel() + None + } else if (s.usedBy != updatedUsedBy) { + Some(world -> s.copy(usedBy = updatedUsedBy)) + } else { + Some(world -> s) + } + } + + /** Drop `guildId` from a single `world`; cancel and remove if it becomes unused. */ + def removeGuildFromWorld(world: String, guildId: String): Unit = + streams.get(world) match { + case Some(s) => + val updatedUsedBy = s.usedBy.filterNot(_.id == guildId) + if (updatedUsedBy.isEmpty) { + s.stream.cancel() + streams -= world + } else { + streams += (world -> s.copy(usedBy = updatedUsedBy)) + } + case None => + logger.info(s"No stream found for guild '$guildId' and world '$world'.") + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala b/tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala new file mode 100644 index 0000000..68a9bc3 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala @@ -0,0 +1,363 @@ +package com.tibiabot.boosted + +import com.tibiabot.Config +import com.tibiabot.domain.BoostedStamp +import com.tibiabot.persistence.{BoostedRepository, ConnectionProvider} +import com.tibiabot.presentation.Urls +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.MessageEmbed + +import scala.collection.mutable.ListBuffer + +/** + * Per-user boosted boss/creature notification subscriptions + * (the boosted_notifications table) and the /boosted command logic. + * Moved verbatim from BotApp; the private helpers mirror the former + * BotApp.capitalizeAllWords / creatureWikiUrl. + */ +final class BoostedService( + connectionProvider: ConnectionProvider, + boostedRepository: BoostedRepository, + boostedBosses: () => List[String] +) { + + def boostedAll(): List[BoostedStamp] = boostedRepository.all() + + def boostedList(userId: String): Boolean = + boostedRepository.forUser(userId).exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") + + private def capitalizeAllWords(s: String): String = s.split(" ").map(_.capitalize).mkString(" ") + + private def creatureWikiUrl(creature: String): String = + Urls.creatureWikiUrl(creature, Config.creatureUrlMappings) + + def boosted(userId: String, boostedOption: String, boostedName: String): MessageEmbed = { + val conn = connectionProvider.cache() + var embedMessage = s"${Config.noEmoji} This command failed to run, try again?" + + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = + statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_notifications'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createListTable = + s"""CREATE TABLE boosted_notifications ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |userid VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |type VARCHAR(255), + |CONSTRAINT unique_user_name_constraint UNIQUE (userid, name) + |);""".stripMargin + + statement.executeUpdate(createListTable) + } + + val result = statement.executeQuery(s"SELECT name,type FROM boosted_notifications WHERE userid = '$userId';") + val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() + + while (result.next()) { + val boostedNameSql = Option(result.getString("name")).getOrElse("") + val boostedTypeSql = Option(result.getString("type")).getOrElse("") + + val boostedStamp = BoostedStamp(userId, boostedTypeSql, boostedNameSql) + boostedStampList += boostedStamp + } + statement.close() + + val sanitizedName = boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase + val existingNames = boostedStampList.toList + + val replyEmbed = new EmbedBuilder() + replyEmbed.setColor(3092790) + if (boostedOption == "list") { // UNFINISHED + if (existingNames.size > 0) { + val listSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") + val groupedAndSorted = existingNames + .groupBy(_.boostedType) + .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name + .toSeq + .sortBy(_._1) // Sort groups by type + .flatMap { case (group, names) => + names.map { boosted => + val emoji = + if (group == "boss") Config.bossEmoji + else if (group == "creature") Config.creatureEmoji + else Config.indentEmoji + + val nameWithLink = + if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" + else s"**${capitalizeAllWords(boosted.boostedName)}**" + + s"$emoji $nameWithLink" + } + }.mkString("\n") + embedMessage = if (listSetting) s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." else s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" + val combinedMessage = embedMessage + if (combinedMessage.size >= 4096) { + val substituteText = "\n\n*`...cannot display any more results`*" + val lastLineIndex = embedMessage.lastIndexOf('\n', (4090 - (substituteText.size))) + val truncatedMessage = embedMessage.substring(0, lastLineIndex) + embedMessage = truncatedMessage + substituteText + } else { + embedMessage = combinedMessage + } + } else { + embedMessage = s"${Config.letterEmoji} Your notification list is *empty*." + } + } else if (boostedOption == "add"){ + if (sanitizedName != "") { + if (existingNames.exists(_.boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase == sanitizedName)) { + embedMessage = s"${Config.noEmoji} **$sanitizedName** already exists." + } else { + if (sanitizedName == "all") { + val query = + "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" + val preparedStatement = conn.prepareStatement(query) + preparedStatement.setString(1, userId) + preparedStatement.setString(2, sanitizedName) + preparedStatement.setString(3, "all") + preparedStatement.executeUpdate() + preparedStatement.close() + embedMessage = s"${Config.yesEmoji} you have enabled notifications for **all** bosses and creatures." + } else { + // Check if sanitizedName exists in boostedBossesList + val isBoostedBoss = boostedBosses().exists(_.equalsIgnoreCase(sanitizedName)) + + // Check if sanitizedName is a valid creature + //val boostedCreature: Future[Either[String, RaceResponse]] = tibiaDataClient.getCreature(sanitizedName) + + val dreamcourtCheck: Boolean = if (List("plagueroot","malofur mangrinder","maxxenius","alptramun","izcandar the banished").contains(sanitizedName.toLowerCase)) true else false + val creatureCheck: Boolean = if (Config.creaturesList.contains(sanitizedName.toLowerCase)) true else false + val monsterType = if (isBoostedBoss) "boss" else if (creatureCheck) "creature" else "all" + if (dreamcourtCheck){ + embedMessage = s"${Config.noEmoji} dreamcourt bosses arn't supported yet." + } else { + if (monsterType == "all") { + val groupedAndSorted = existingNames + .groupBy(_.boostedType) + .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name + .toSeq + .sortBy(_._1) // Sort groups by type + .flatMap { case (group, names) => + names.map { boosted => + val emoji = + if (group == "boss") Config.bossEmoji + else if (group == "creature") Config.creatureEmoji + else Config.indentEmoji + + val nameWithLink = + if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" + else s"**${capitalizeAllWords(boosted.boostedName)}**" + + s"$emoji $nameWithLink" + } + }.mkString("\n") + val listMessage = if (groupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." + val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not a valid `boss` or `creature`." + val combinedMessage = listMessage + s"\n\n$commandMessage" + if (combinedMessage.size >= 4096) { + val substituteText = "\n\n*`...cannot display any more results`*" + val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) + val truncatedMessage = listMessage.substring(0, lastLineIndex) + embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" + } else { + embedMessage = combinedMessage + } + } else { + val query = "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" + val preparedStatement = conn.prepareStatement(query) + preparedStatement.setString(1, userId) + preparedStatement.setString(2, sanitizedName) + preparedStatement.setString(3, monsterType) + preparedStatement.executeUpdate() + preparedStatement.close() + + val newNames = existingNames :+ BoostedStamp(userId, monsterType, sanitizedName) + val groupedAndSorted = newNames + .groupBy(_.boostedType) + .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name + .toSeq + .sortBy(_._1) // Sort groups by type + .flatMap { case (group, names) => + names.map { boosted => + val emoji = + if (group == "boss") Config.bossEmoji + else if (group == "creature") Config.creatureEmoji + else Config.indentEmoji + + val nameWithLink = + if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" + else s"**${capitalizeAllWords(boosted.boostedName)}**" + + s"$emoji $nameWithLink" + } + }.mkString("\n") + val listMessage = if (groupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" else s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." + val commandMessage = s"${Config.yesEmoji} **$sanitizedName** was added." + //WIP + val combinedMessage = listMessage + s"\n\n$commandMessage" + if (combinedMessage.size >= 4096) { + val substituteText = "\n\n*`...cannot display any more results`*" + val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) + val truncatedMessage = listMessage.substring(0, lastLineIndex) + embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" + } else { + embedMessage = combinedMessage + } + } + } + } + } + } else { + // Check if sanitizedName exists in boostedBossesList + val isBoostedBoss = boostedBosses().exists(_.equalsIgnoreCase(sanitizedName)) + + // Check if sanitizedName is a valid creature + /** + val boostedCreature: Future[Either[String, RaceResponse]] = tibiaDataClient.getCreature(sanitizedName) + val creatureCheck: Future[Boolean] = boostedCreature.map { + case Right(raceResponse) => + raceResponse.creature.isDefined + case Left(errorMessage) => false + } + **/ + val creatureCheck: Boolean = if (Config.creaturesList.contains(sanitizedName.toLowerCase)) true else false + val monsterType = if (isBoostedBoss) "boss" else if (creatureCheck) "creature" else "all" + val listSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") + val newNames = existingNames :+ BoostedStamp(userId, monsterType, boostedName) + val groupedAndSorted = newNames + .groupBy(_.boostedType) + .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name + .toSeq + .sortBy(_._1) // Sort groups by type + .flatMap { case (group, names) => + names.map { boosted => + val emoji = + if (group == "boss") Config.bossEmoji + else if (group == "creature") Config.creatureEmoji + else Config.indentEmoji + + val nameWithLink = + if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" + else s"**${capitalizeAllWords(boosted.boostedName)}**" + + s"$emoji $nameWithLink" + } + }.mkString("\n") + val listMessage = if (listSetting) s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." else s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" + val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not a valid `boss` or `creature`." + val combinedMessage = listMessage + s"\n\n$commandMessage" + if (combinedMessage.size >= 4096) { + val substituteText = "\n\n*`...cannot display any more results`*" + val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) + val truncatedMessage = listMessage.substring(0, lastLineIndex) + embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" + } else { + embedMessage = combinedMessage + } + } + } else if (boostedOption == "remove"){ + val filteredGroupedAndSorted = existingNames + .groupBy(_.boostedType) + .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name + .toSeq + .sortBy(_._1) // Sort groups by type + .flatMap { case (group, names) => + val filteredNames = names.filterNot(bs => bs.boostedName.toLowerCase == sanitizedName) + + filteredNames.map { boosted => + val emoji = + if (group == "boss") Config.bossEmoji + else if (group == "creature") Config.creatureEmoji + else Config.indentEmoji + + val nameWithLink = + if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" + else s"**${capitalizeAllWords(boosted.boostedName)}**" + + s"$emoji $nameWithLink" + } + }.mkString("\n") + if (sanitizedName == "all") { + var query = "DELETE FROM boosted_notifications WHERE userid = ?" + val preparedStatement = conn.prepareStatement(query) + preparedStatement.setString(1, userId) + preparedStatement.executeUpdate() + preparedStatement.close() + + embedMessage = s"${Config.yesEmoji} you have disabled notifications for **all** bosses and creatures." + } else if (existingNames.exists(_.boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase == sanitizedName)) { + var query = "DELETE FROM boosted_notifications WHERE userid = ? AND LOWER(name) = LOWER(?)" + val preparedStatement = conn.prepareStatement(query) + preparedStatement.setString(1, userId) + preparedStatement.setString(2, sanitizedName) + preparedStatement.executeUpdate() + preparedStatement.close() + + val listMessage = if (filteredGroupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$filteredGroupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." + val commandMessage = s"${Config.yesEmoji} you removed **$sanitizedName** from the list." + val combinedMessage = listMessage + s"\n\n$commandMessage" + if (combinedMessage.size >= 4096) { + val substituteText = "\n\n*`...cannot display any more results`*" + val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) + val truncatedMessage = listMessage.substring(0, lastLineIndex) + embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" + } else { + embedMessage = combinedMessage + } + + } else { + + val listMessage = if (filteredGroupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$filteredGroupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." + val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not on your list." + val combinedMessage = listMessage + s"\n\n$commandMessage" + if (combinedMessage.size >= 4096) { + val substituteText = "\n\n*`...cannot display any more results`*" + val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) + val truncatedMessage = listMessage.substring(0, lastLineIndex) + embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" + } else { + embedMessage = combinedMessage + } + } + // + } else if (boostedOption == "toggle"){ + val existingSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") + if (existingSetting) { + var query = "DELETE FROM boosted_notifications WHERE userid = ?" + val preparedStatement = conn.prepareStatement(query) + preparedStatement.setString(1, userId) + preparedStatement.executeUpdate() + preparedStatement.close() + // WIP Message + embedMessage = s"${Config.letterEmoji} Your notification list is *empty*." + } else { + val query = "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" + val preparedStatement = conn.prepareStatement(query) + preparedStatement.setString(1, userId) + preparedStatement.setString(2, "all") + preparedStatement.setString(3, "all") + preparedStatement.executeUpdate() + preparedStatement.close() + embedMessage = s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." + } + // + } else if (boostedOption == "disable") { + var query = "DELETE FROM boosted_notifications WHERE userid = ?" + val preparedStatement = conn.prepareStatement(query) + preparedStatement.setString(1, userId) + preparedStatement.executeUpdate() + preparedStatement.close() + + embedMessage = s"${Config.yesEmoji} you have **disabled** notifications for **all** bosses and creatures." + } + + conn.close() + replyEmbed.setDescription(embedMessage).build() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/CommandRouter.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/CommandRouter.scala new file mode 100644 index 0000000..106bb69 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/CommandRouter.scala @@ -0,0 +1,18 @@ +package com.tibiabot.commands + +/** + * Maps a slash-command name to its handler. Keeps the dispatch table in one place + * and free of JDA types (the event type `E` is a parameter), so routing is unit-testable. + */ +final class CommandRouter[E](handlers: Map[String, E => Unit]) { + + /** Names this router knows how to handle. */ + def commandNames: Set[String] = handlers.keySet + + /** Dispatch to the handler for `name`. Returns false if no handler is registered. */ + def route(name: String, event: E): Boolean = + handlers.get(name) match { + case Some(handler) => handler(event); true + case None => false + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/CommandSchemas.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/CommandSchemas.scala new file mode 100644 index 0000000..2ed9041 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/CommandSchemas.scala @@ -0,0 +1,273 @@ +package com.tibiabot.commands + +import net.dv8tion.jda.api.Permission +import net.dv8tion.jda.api.interactions.commands.Command.Choice +import net.dv8tion.jda.api.interactions.commands.build.{Commands, OptionData, SlashCommandData, SubcommandData} +import net.dv8tion.jda.api.interactions.commands.{DefaultMemberPermissions, OptionType} + +/** Slash-command schema (shape) definitions, extracted verbatim from BotApp. + * Pure JDA command-builder data — no behaviour, no external coupling. + * + * Note: `leaderboardsCommand` is defined but intentionally NOT in `commands` + * or `adminCommands` (it is unregistered today); preserved as-is. */ +object CommandSchemas { + + // create the command to set up the bot + val setupCommand: SlashCommandData = Commands.slash("setup", "Setup a world to be tracked") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addOptions(new OptionData(OptionType.STRING, "world", "The world you want to track") + .setRequired(true)) + + // remove world command + val removeCommand: SlashCommandData = Commands.slash("remove", "Remove a world from being tracked") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addOptions(new OptionData(OptionType.STRING, "world", "The world you want to remove") + .setRequired(true)) + + // hunted command + val huntedCommand: SlashCommandData = Commands.slash("hunted", "Manage the hunted list") + .addSubcommands( + new SubcommandData("guild", "Manage guilds in the hunted list") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to add or remove a guild?").setRequired(true) + .addChoices( + new Choice("add", "add"), + new Choice("remove", "remove") + ), + new OptionData(OptionType.STRING, "name", "The guild name you want to add to the hunted list").setRequired(true) + ), + new SubcommandData("player", "Manage players in the hunted list") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to add or remove a player?").setRequired(true) + .addChoices( + new Choice("add", "add"), + new Choice("remove", "remove") + ), + new OptionData(OptionType.STRING, "name", "The player name you want to add to the hunted list").setRequired(true), + new OptionData(OptionType.STRING, "reason", "You can add a reason when players are added to the hunted list") + ), + new SubcommandData("list", "List players & guilds in the hunted list"), + new SubcommandData("clear", "Remove all players and guilds from the hunted list"), + new SubcommandData("info", "Show detailed info on a hunted player") + .addOptions(new OptionData(OptionType.STRING, "name", "The player name you want to check").setRequired(true) + ), + new SubcommandData("autodetect", "Configure the auto-detection on or off") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to toggle it on or off?").setRequired(true) + .addChoices( + new Choice("on", "on"), + new Choice("off", "off") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ), + new SubcommandData("levels", "Show or hide hunted levels") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to show or hide hunted levels?").setRequired(true) + .addChoices( + new Choice("show", "show"), + new Choice("hide", "hide") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ), + new SubcommandData("deaths", "Show or hide hunted deaths") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to show or hide hunted deaths?").setRequired(true) + .addChoices( + new Choice("show", "show"), + new Choice("hide", "hide") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ) + ) + + // allies command + val alliesCommand: SlashCommandData = Commands.slash("allies", "Manage the allies list") + .addSubcommands( + new SubcommandData("guild", "Manage guilds in the allies list") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to add or remove a guild?").setRequired(true) + .addChoices( + new Choice("add", "add"), + new Choice("remove", "remove") + ), + new OptionData(OptionType.STRING, "name", "The guild name you want to add to the allies list").setRequired(true) + ), + new SubcommandData("player", "Manage players in the allies list") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to add or remove a player?").setRequired(true) + .addChoices( + new Choice("add", "add"), + new Choice("remove", "remove") + ), + new OptionData(OptionType.STRING, "name", "The player name you want to add to the allies list").setRequired(true) + ), + new SubcommandData("list", "List players & guilds in the allies list"), + new SubcommandData("clear", "Remove all players and guilds from the allies list"), + new SubcommandData("info", "Show detailed info on a allied player") + .addOptions(new OptionData(OptionType.STRING, "name", "The player name you want to check").setRequired(true) + ), + new SubcommandData("levels", "Show or hide ally levels") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to show or hide ally levels?").setRequired(true) + .addChoices( + new Choice("show", "show"), + new Choice("hide", "hide") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ), + new SubcommandData("deaths", "Show or hide ally deaths") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to show or hide ally levels?").setRequired(true) + .addChoices( + new Choice("show", "show"), + new Choice("hide", "hide") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ) + ) + + // neutrals command + val neutralsCommand: SlashCommandData = Commands.slash("neutral", "Configuration options for neutrals") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addSubcommands( + new SubcommandData("levels", "Show or hide neutral levels") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to show or hide neutral levels?").setRequired(true) + .addChoices( + new Choice("show", "show"), + new Choice("hide", "hide") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ), + new SubcommandData("deaths", "Show or hide neutral deaths") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to show or hide neutral levels?").setRequired(true) + .addChoices( + new Choice("show", "show"), + new Choice("hide", "hide") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ) + ) + + // fullbless command + val fullblessCommand: SlashCommandData = Commands.slash("fullbless", "Modify the level at which enemy fullblesses poke") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addOptions( + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true), + new OptionData(OptionType.INTEGER, "level", "The minimum level you want to set for fullbless pokes").setRequired(true) + .setMinValue(1) + .setMaxValue(4000) + ) + + // leaderboards command + val leaderboardsCommand: SlashCommandData = Commands.slash("leaderboards", "Modify the level at which enemy fullblesses poke") + .addOptions( + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ) + + // minimum levels/deaths command + val filterCommand: SlashCommandData = Commands.slash("filter", "Set a minimum level for the levels or deaths channels") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addSubcommands( + new SubcommandData("levels", "Hide events in the levels channel if the character is below a certain level") + .addOptions( + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true), + new OptionData(OptionType.INTEGER, "level", "The minimum level you want to set for the levels channel").setRequired(true) + .setMinValue(1) + .setMaxValue(4000) + ), + new SubcommandData("deaths", "Hide events in the deaths channel if the character is below a certain level") + .addOptions( + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true), + new OptionData(OptionType.INTEGER, "level", "The minimum level you want to set for the deaths channel").setRequired(true) + .setMinValue(1) + .setMaxValue(4000) + ) + ) + + // admin command + val adminCommand: SlashCommandData = Commands.slash("admin", "Commands only available to the bot creator") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addSubcommands( + new SubcommandData("leave", "Force the bot to leave a specific discord") + .addOptions( + new OptionData(OptionType.STRING, "guildid", "The guild ID you want the bot to leave").setRequired(true), + new OptionData(OptionType.STRING, "reason", "What reason do you want to leave for the discord owner?").setRequired(true) + ), + new SubcommandData("info", "get discord info"), + new SubcommandData("dreamscar", "resync dreamscar wiki info"), + new SubcommandData("worldlist", "get discord info"), + new SubcommandData("message", "Send a message to a specific discord") + .addOptions( + new OptionData(OptionType.STRING, "guildid", "The guild ID you want the bot to leave").setRequired(true), + new OptionData(OptionType.STRING, "message", "What message do you want to leave for the discord owner?").setRequired(true) + ) + ) + + // exiva command + val exivaCommand: SlashCommandData = Commands.slash("exiva", "Show or hide exiva lists on death posts") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addSubcommands( + new SubcommandData("deaths", "Show or hide the exiva list in the deaths channel") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to show or hide the exiva list?").setRequired(true) + .addChoices( + new Choice("show", "show"), + new Choice("hide", "hide") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ) + ) + + // help command + val helpCommand: SlashCommandData = Commands.slash("help", "Resend the welcome message & basic getting started information") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + + // recreate channel command + val repairCommand: SlashCommandData = Commands.slash("repair", "Repair & recreate channels that have been deleted for a specific world") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addOptions( + new OptionData(OptionType.STRING, "world", "What world are you trying to recreate channels for?").setRequired(true), + ) + + // set galthen satchel reminder + val galthenCommand: SlashCommandData = Commands.slash("galthen", "Use this to set a galthen satchel cooldown timer") + .addSubcommands( + new SubcommandData("satchel", "Use this to set a galthen satchel cooldown timer") + .addOptions( + new OptionData(OptionType.STRING, "character", "What character/tag is this for?") + ) + ) + + // online list config command + val onlineCombineCommand: SlashCommandData = Commands.slash("online", "Configure how the online list is displayed") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .addSubcommands( + new SubcommandData("list", "Configure the online list") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to combine the list into one channel or keep them separate?").setRequired(true) + .addChoices( + new Choice("separate", "separate"), + new Choice("combine", "combine") + ), + new OptionData(OptionType.STRING, "world", "The world you want to configure this setting for").setRequired(true) + ) + ) + + // boosted notifications command + val boostedCommand: SlashCommandData = Commands.slash("boosted", "Turn off these notifications or filter them") + .addOptions( + new OptionData(OptionType.STRING, "option", "Would you like to add/remove a boss or creature?").setRequired(true) + .addChoices( + new Choice("list", "list"), + new Choice("disable", "disable") + ) + ) + + /** Commands registered in normal guilds. */ + val commands: List[SlashCommandData] = List(setupCommand, removeCommand, huntedCommand, alliesCommand, neutralsCommand, fullblessCommand, filterCommand, exivaCommand, helpCommand, repairCommand, onlineCombineCommand, boostedCommand, galthenCommand) + + /** Commands registered in the bot-owner guilds (adds /admin). */ + val adminCommands: List[SlashCommandData] = commands :+ adminCommand +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala new file mode 100644 index 0000000..696a177 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala @@ -0,0 +1,16 @@ +package com.tibiabot.commands + +import net.dv8tion.jda.api.Permission +import net.dv8tion.jda.api.entities.Member + +/** Centralized command authorization checks. */ +object Permissions { + + /** True if the caller is the bot's creator (the Discord application owner). */ + def isBotCreator(callerId: String, ownerId: String): Boolean = + ownerId.nonEmpty && callerId == ownerId + + /** True if the member may run server-management commands. */ + def hasManageServer(member: Member): Boolean = + member != null && member.hasPermission(Permission.MANAGE_SERVER) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AdminCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AdminCommands.scala new file mode 100644 index 0000000..6218139 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AdminCommands.scala @@ -0,0 +1,63 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config, WorldManager} +import com.tibiabot.commands.Permissions +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +import scala.jdk.CollectionConverters._ + +/** Handles `/admin`: bot-creator-only maintenance subcommands. */ +object AdminCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + val options = Options.of(event) + val guildOption = options.getOrElse("guildid", "") + val reasonOption = options.getOrElse("reason", "") + val messageOption = options.getOrElse("message", "") + + // Only the bot creator (the Discord application owner) may use /admin + if (!Permissions.isBotCreator(event.getUser.getId, BotApp.botOwner)) { + val embed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} This command is only available to the bot creator.") + .build() + event.getHook.sendMessageEmbeds(embed).queue() + return + } + + event.getInteraction.getSubcommandName match { + case "leave" => + val embed = BotApp.adminService.leave(guildOption, reasonOption) + event.getHook.sendMessageEmbeds(embed).queue() + case "dreamscar" => + val embed = BotApp.adminService.resyncDreamCourtBosses() + event.getHook.sendMessageEmbeds(embed).queue() + case "message" => + val embed = BotApp.adminService.message(guildOption, messageOption) + event.getHook.sendMessageEmbeds(embed).queue() + case "worldlist" => + try { + WorldManager.getWorldList() + val embed = new EmbedBuilder() + .setDescription(s"${Config.yesEmoji} The worlds list has been refreshed.") + .build() + event.getHook.sendMessageEmbeds(embed).queue() + } catch { + case _: Exception => + val embed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} The worlds list has failed to refresh.") + .build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "info" => + BotApp.adminService.info(embeds => { + embeds.asJava.forEach { embed => + event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() + } + }) + case other => + val embed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/admin`.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala new file mode 100644 index 0000000..74b30aa --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala @@ -0,0 +1,132 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config} +import com.tibiabot.commands.Permissions +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +import scala.jdk.CollectionConverters._ + +/** Handles `/allies`: manage allied players and guilds. */ +object AlliesCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + val subCommand = event.getInteraction.getSubcommandName + val options = Options.of(event) + val toggleOption: String = options.getOrElse("option", "") + val nameOption: String = options.getOrElse("name", "") + val reasonOption: String = options.getOrElse("reason", "none") + val worldOption: String = options.getOrElse("world", "") + + var authed = false + val user = event.getUser // Get the user who ran the command + val guild = event.getGuild + val member = guild.retrieveMember(user).complete() + if (Permissions.hasManageServer(member)) { + authed = true + } + + subCommand match { + case "player" => + if (authed) { + if (toggleOption == "add") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.addAlly(event, "player", nameOption, reasonOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } else if (toggleOption == "remove") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.removeAlly(event, "player", nameOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "guild" => + if (authed) { + if (toggleOption == "add") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.addAlly(event, "guild", nameOption, reasonOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } else if (toggleOption == "remove") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.removeAlly(event, "guild", nameOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "list" => + if (authed) { + BotApp.listAlliesAndHuntedGuilds(event, "allies", allies => { + val embedsJava = allies.asJava + embedsJava.forEach { embed => + event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() + } + BotApp.listAlliesAndHuntedPlayers(event, "allies", allies => { + val embedsJava = allies.asJava + embedsJava.forEach { embed => + event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() + } + }) + }) + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "clear" => + if (authed) { + val embed = BotApp.clearAllies(event) + event.getHook.sendMessageEmbeds(embed).queue() + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "deaths" => + if (authed) { + if (toggleOption == "show") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "allies", "deaths") + event.getHook.sendMessageEmbeds(embed).queue() + } else if (toggleOption == "hide") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "allies", "deaths") + event.getHook.sendMessageEmbeds(embed).queue() + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "levels" => + if (authed) { + if (toggleOption == "show") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "allies", "levels") + event.getHook.sendMessageEmbeds(embed).queue() + } else if (toggleOption == "hide") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "allies", "levels") + event.getHook.sendMessageEmbeds(embed).queue() + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "info" => + val embed = BotApp.infoAllies(event, "player", nameOption) + event.getHook.sendMessageEmbeds(embed).queue() + case other => + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/allies`.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala new file mode 100644 index 0000000..07b0e8b --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala @@ -0,0 +1,38 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config} +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.emoji.Emoji +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent +import net.dv8tion.jda.api.interactions.components.buttons.Button + +/** Handles `/boosted`: manage per-user boosted boss/creature notifications. */ +object BoostedCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + val toggleOption = Options.of(event).getOrElse("option", "") + val userId = event.getUser.getId + + if (toggleOption == "disable") { + val embed = BotApp.boostedService.boosted(userId, "disable", "") + event.getHook.sendMessageEmbeds(embed).queue() + } else if (toggleOption == "list") { + val embed = BotApp.boostedService.boosted(userId, "list", "") + if (BotApp.boostedService.boostedList(userId)) { + event.getHook.sendMessageEmbeds(embed).setActionRow( + Button.success("boosted add", "Add").asDisabled, + Button.danger("boosted remove", "Remove").asDisabled, + Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOnEmoji)) + ).queue() + } else { + event.getHook.sendMessageEmbeds(embed).setActionRow( + Button.success("boosted add", "Add"), + Button.danger("boosted remove", "Remove") + ).queue() + } + } else { + val embed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} Invalid option for `/boosted`.").setColor(3092790).build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ChannelCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ChannelCommands.scala new file mode 100644 index 0000000..01f7b43 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ChannelCommands.scala @@ -0,0 +1,24 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.BotApp +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles the channel-management commands: `/setup`, `/remove`, `/repair`. */ +object ChannelCommands { + + def setup(event: SlashCommandInteractionEvent): Unit = { + val embed = BotApp.createChannels(event) + event.getHook.sendMessageEmbeds(embed).queue() + } + + def remove(event: SlashCommandInteractionEvent): Unit = { + val embed = BotApp.removeChannels(event) + event.getHook.sendMessageEmbeds(embed).queue() + } + + def repair(event: SlashCommandInteractionEvent): Unit = { + val worldOption = Options.of(event).getOrElse("world", "") + val embed = BotApp.repairChannel(event, worldOption) + event.getHook.sendMessageEmbeds(embed).queue() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ExivaCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ExivaCommands.scala new file mode 100644 index 0000000..e60c573 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/ExivaCommands.scala @@ -0,0 +1,20 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config} +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles `/exiva`: lists recent deaths for exiva tracking. */ +object ExivaCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + event.getInteraction.getSubcommandName match { + case "deaths" => + val embed = BotApp.exivaList(event) + event.getHook.sendMessageEmbeds(embed).queue() + case other => + val embed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/exiva`.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FilterCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FilterCommands.scala new file mode 100644 index 0000000..dda1d00 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FilterCommands.scala @@ -0,0 +1,31 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config} +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles `/filter`: sets the minimum level for level/death notifications. */ +object FilterCommands { + + val DefaultLevel = 8 + + /** The level option, defaulting to [[DefaultLevel]] when absent. */ + def parseLevel(options: Map[String, String]): Int = + options.get("level").map(_.toInt).getOrElse(DefaultLevel) + + def handle(event: SlashCommandInteractionEvent): Unit = { + val options = Options.of(event) + val worldOption = options.getOrElse("world", "") + val levelOption = parseLevel(options) + + event.getInteraction.getSubcommandName match { + case channel @ ("levels" | "deaths") => + val embed = BotApp.minLevel(event, worldOption, levelOption, channel) + event.getHook.sendMessageEmbeds(embed).queue() + case other => + val embed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/filter`.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FullblessCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FullblessCommands.scala new file mode 100644 index 0000000..18086c4 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/FullblessCommands.scala @@ -0,0 +1,21 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.BotApp +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles `/fullbless`: shows the fullbless minimum-level config for a world. */ +object FullblessCommands { + + val DefaultLevel = 250 + + /** The level option, defaulting to [[DefaultLevel]] when absent. */ + def parseLevel(options: Map[String, String]): Int = + options.get("level").map(_.toInt).getOrElse(DefaultLevel) + + def handle(event: SlashCommandInteractionEvent): Unit = { + val options = Options.of(event) + val worldOption = options.getOrElse("world", "") + val embed = BotApp.fullblessLevel(event, worldOption, parseLevel(options)) + event.getHook.sendMessageEmbeds(embed).queue() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala new file mode 100644 index 0000000..b805da7 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala @@ -0,0 +1,89 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.BotApp +import com.tibiabot.BotApp.SatchelStamp +import com.tibiabot.presentation.GalthenEmbeds +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent +import net.dv8tion.jda.api.interactions.components.buttons.Button + +/** Handles `/galthen`: per-user Galthen's Satchel 30-day cooldown tracker. */ +object GalthenCommands { + + def handle(event: SlashCommandInteractionEvent): Unit = { + val tagOption: String = Options.of(event).getOrElse("character", "") + val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.galthenService.getStamps(event.getUser.getId) + val embed = new EmbedBuilder() + + satchelTimeOption match { + case Some(satchelTimeList) if satchelTimeList.isEmpty => + embed.setColor(178877) + if (tagOption.nonEmpty) embed.setFooter(s"Tag: ${tagOption.toLowerCase}") + embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you when the 30 day cooldown expires.") + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected"), + Button.danger("galthenRemove", "Clear").asDisabled + ).queue() + + case Some(satchelTimeList) => + val tagList = satchelTimeList.collect { + case satchel if tagOption.equalsIgnoreCase(satchel.tag) => + val when = satchel.when.plusDays(30).toEpochSecond.toString() + s"<:satchel:1030348072577945651> can be collected by **`${satchel.tag}`** " + } + + val fullList = satchelTimeList.collect { + case satchel => + val when = satchel.when.plusDays(30).toEpochSecond.toString() + val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" + s"<:satchel:1030348072577945651> can be collected by $displayTag " + } + + if (tagOption.isEmpty && fullList.nonEmpty) { + embed.setTitle("Existing Cooldowns:") + embed.setDescription(GalthenEmbeds.truncate(fullList)) + embed.setColor(13773097) + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + if (fullList.size == 1){ + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected").asDisabled, + Button.danger("galthenRemoveAll", "Clear") + ).queue() + } else { + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.secondary("galthenLock", "🔒"), + Button.danger("galthenRemoveAll", "Clear All").asDisabled + ).queue() + } + } else if (tagOption.nonEmpty && tagList.nonEmpty) { // tag picked up + embed.setFooter(s"Tag: ${tagOption.toLowerCase}") + embed.setDescription(tagList.mkString("\n")) + embed.setColor(9855533) + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected").asDisabled, + Button.danger("galthenRemove", "Clear") + ).queue() + } else { + embed.setColor(178877) + if (tagOption.nonEmpty) embed.setFooter(s"Tag: ${tagOption.toLowerCase}") + embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you when the 30 day cooldown expires.") + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected"), + Button.danger("galthenRemove", "Clear").asDisabled + ).queue() + } + + case None => + embed.setColor(178877) + if (tagOption.nonEmpty) embed.setFooter(s"Tag: ${tagOption.toLowerCase}") + embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you when the 30 day cooldown expires.") + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected"), + Button.danger("galthenRemove", "Clear").asDisabled + ).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HelpCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HelpCommands.scala new file mode 100644 index 0000000..c39f009 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HelpCommands.scala @@ -0,0 +1,17 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.Config +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles `/help`: posts the bot's help text. */ +object HelpCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + val embed = new EmbedBuilder() + embed.setAuthor("Violent Beams", "https://www.tibia.com/community/?subtopic=characters&name=Violent+Beams", "https://github.com/Leo32onGIT.png") + embed.setDescription(Config.helpText) + embed.setThumbnail(Config.webHookAvatar) + embed.setColor(14397256) // orange for bot auto command + event.getHook.sendMessageEmbeds(embed.build()).queue() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala new file mode 100644 index 0000000..1811f44 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala @@ -0,0 +1,139 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config} +import com.tibiabot.commands.Permissions +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +import scala.jdk.CollectionConverters._ + +/** Handles `/hunted`: manage hunted (enemy) players and guilds. */ +object HuntedCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + val subCommand = event.getInteraction.getSubcommandName + val options = Options.of(event) + val toggleOption: String = options.getOrElse("option", "") + val worldOption: String = options.getOrElse("world", "") + val nameOption: String = options.getOrElse("name", "") + val reasonOption: String = options.getOrElse("reason", "none") + + var authed = false + val user = event.getUser // Get the user who ran the command + val guild = event.getGuild + val member = guild.retrieveMember(user).complete() + if (Permissions.hasManageServer(member)) { + authed = true + } + + subCommand match { + case "player" => + if (authed) { + if (toggleOption == "add") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.addHunted(event, "player", nameOption, reasonOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } else if (toggleOption == "remove") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.removeHunted(event, "player", nameOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "guild" => + if (authed) { + if (toggleOption == "add") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.addHunted(event, "guild", nameOption, reasonOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } else if (toggleOption == "remove") { + BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.removeHunted(event, "guild", nameOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue(_ => { + BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + }) + }) + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "list" => + if (authed) { + BotApp.listAlliesAndHuntedGuilds(event, "hunted", hunteds => { + val embedsJava = hunteds.asJava + embedsJava.forEach { embed => + event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() + } + BotApp.listAlliesAndHuntedPlayers(event, "hunted", hunteds => { + val embedsJava = hunteds.asJava + embedsJava.forEach { embed => + event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() + } + }) + }) + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "clear" => + if (authed) { + val embed = BotApp.clearHunted(event) + event.getHook.sendMessageEmbeds(embed).queue() + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "deaths" => + if (authed) { + if (toggleOption == "show") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "enemies", "deaths") + event.getHook.sendMessageEmbeds(embed).queue() + } else if (toggleOption == "hide") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "enemies", "deaths") + event.getHook.sendMessageEmbeds(embed).queue() + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "levels" => + if (authed) { + if (toggleOption == "show") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "enemies", "levels") + event.getHook.sendMessageEmbeds(embed).queue() + } else if (toggleOption == "hide") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "enemies", "levels") + event.getHook.sendMessageEmbeds(embed).queue() + } + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case "info" => + val embed = BotApp.infoHunted(event, "player", nameOption) + event.getHook.sendMessageEmbeds(embed).queue() + case "autodetect" => + if (authed) { + val embed = BotApp.detectHunted(event) + event.getHook.sendMessageEmbeds(embed).queue() + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You do not have permission to use this command.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + case other => + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/hunted`.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/LeaderboardCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/LeaderboardCommands.scala new file mode 100644 index 0000000..985d0b8 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/LeaderboardCommands.scala @@ -0,0 +1,14 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.BotApp +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles `/leaderboards`: posts the world leaderboard (defined but not currently registered). */ +object LeaderboardCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + val worldOption = Options.of(event).getOrElse("world", "") + BotApp.leaderboards(event, worldOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue() + }) + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala new file mode 100644 index 0000000..9620391 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala @@ -0,0 +1,92 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config} +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles `/neutral`: per-world neutral death/level toggles and online-list tag categories. */ +object NeutralCommands { + + // Matches a single standard (non-custom) Discord emoji. + private val emojiPattern = + "^(?:[\\uD83C\\uDF00-\\uD83D\\uDDFF]|[\\uD83E\\uDD00-\\uD83E\\uDDFF]|[\\uD83D\\uDE00-\\uD83D\\uDE4F]|[\\uD83D\\uDE80-\\uD83D\\uDEFF]|[\\u2600-\\u26FF]\\uFE0F?|[\\u2700-\\u27BF]\\uFE0F?|\\u24C2\\uFE0F?|[\\uD83C\\uDDE6-\\uD83C\\uDDFF]{1,2}|[\\uD83C\\uDD70\\uD83C\\uDD71\\uD83C\\uDD7E\\uD83C\\uDD7F\\uD83C\\uDD8E\\uD83C\\uDD91-\\uD83C\\uDD9A]\\uFE0F?|[\\u0023\\u002A\\u0030-\\u0039]\\uFE0F?\\u20E3|[\\u2194-\\u2199\\u21A9-\\u21AA]\\uFE0F?|[\\u2B05-\\u2B07\\u2B1B\\u2B1C\\u2B50\\u2B55]\\uFE0F?|[\\u2934\\u2935]\\uFE0F?|[\\u3030\\u303D]\\uFE0F?|[\\u3297\\u3299]\\uFE0F?|[\\uD83C\\uDE01\\uD83C\\uDE02\\uD83C\\uDE1A\\uD83C\\uDE2F\\uD83C\\uDE32-\\uD83C\\uDE3A\\uD83C\\uDE50\\uD83C\\uDE51]\\uFE0F?|[\\u203C\\u2049]\\uFE0F?|[\\u25AA\\u25AB\\u25B6\\u25C0\\u25FB-\\u25FE]\\uFE0F?|[\\u00A9\\u00AE]\\uFE0F?|[\\u2122\\u2139]\\uFE0F?|\\uD83C\\uDC04\\uFE0F?|\\uD83C\\uDCCF\\uFE0F?|[\\u231A\\u231B\\u2328\\u23CF\\u23E9-\\u23F3\\u23F8-\\u23FA]\\uFE0F?)$".r + + /** True when the input is exactly one standard (non-custom) Discord emoji. */ + def isValidEmoji(emoji: String): Boolean = emojiPattern.findFirstIn(emoji).isDefined + + /** Strip anything but letters, digits and whitespace from a category label, then trim. */ + def sanitizeLabel(label: String): String = label.replaceAll("[^a-zA-Z0-9\\s]", "").trim + + def handle(event: SlashCommandInteractionEvent): Unit = { + val subCommand = event.getInteraction.getSubcommandName + val subcommandGroupName = event.getInteraction.getSubcommandGroup + val options = Options.of(event) + val toggleOption: String = options.getOrElse("option", "") + val worldOption: String = options.getOrElse("world", "") + + if (subcommandGroupName != null) { + subcommandGroupName match { + case "tag" => + subCommand match { + case "add" => + val typeOption: String = options.getOrElse("type", "") + val nameOption: String = options.getOrElse("name", "").trim + val labelOption: String = sanitizeLabel(options.getOrElse("label", "")) + val emojiOption: String = options.getOrElse("emoji", "").trim + if (labelOption == "" || emojiOption == ""){ + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You must supply a **label** and **emoji** when tagging a guild or player.").setColor(3092790).build() + event.getHook.sendMessageEmbeds(embed).queue() + } else { + if (isValidEmoji(emojiOption)) { + BotApp.addOnlineListCategory(event, typeOption, nameOption, labelOption, emojiOption, embed => { + event.getHook.sendMessageEmbeds(embed).queue() + }) + } else { + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} The provided emoji is invalid - use a standard discord emoji.\n:warning: Custom emojis are not supported.").setColor(3092790).build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } + case "remove" => + val typeOption: String = options.getOrElse("type", "") + val nameOption: String = options.getOrElse("name", "").trim + val embed = BotApp.removeOnlineListCategory(event, typeOption, nameOption) + event.getHook.sendMessageEmbeds(embed).queue() + case "clear" => + val labelOption: String = sanitizeLabel(options.getOrElse("label", "")) + val embed = BotApp.clearOnlineListCategory(event, labelOption) + event.getHook.sendMessageEmbeds(embed).queue() + case "list" => + val embeds = BotApp.listOnlineListCategory(event) + embeds.foreach { embed => + event.getHook.sendMessageEmbeds(embed).setEphemeral(true).queue() + } + } + case other => + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommandGroup '$other' for `/neutral`.").setColor(3092790).build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } else { + subCommand match { + case "deaths" => + if (toggleOption == "show") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "neutrals", "deaths") + event.getHook.sendMessageEmbeds(embed).queue() + } else if (toggleOption == "hide") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "neutrals", "deaths") + event.getHook.sendMessageEmbeds(embed).queue() + } + case "levels" => + if (toggleOption == "show") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "show", "neutrals", "levels") + event.getHook.sendMessageEmbeds(embed).queue() + } else if (toggleOption == "hide") { + val embed = BotApp.deathsLevelsHideShow(event, worldOption, "hide", "neutrals", "levels") + event.getHook.sendMessageEmbeds(embed).queue() + } + case other => + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/neutral`.").setColor(3092790).build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/OnlineListCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/OnlineListCommands.scala new file mode 100644 index 0000000..ed4bb04 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/OnlineListCommands.scala @@ -0,0 +1,26 @@ +package com.tibiabot.commands.handlers + +import com.tibiabot.{BotApp, Config} +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** Handles `/online`: configures the per-world online list as separate or combined. */ +object OnlineListCommands { + def handle(event: SlashCommandInteractionEvent): Unit = { + val options = Options.of(event) + val toggleOption = options.getOrElse("option", "") + + event.getInteraction.getSubcommandName match { + case "list" if toggleOption == "separate" || toggleOption == "combine" => + val worldOption = options.getOrElse("world", "") + val embed = BotApp.onlineListConfig(event, worldOption, toggleOption) + event.getHook.sendMessageEmbeds(embed).queue() + case "list" => + () // unknown toggle: preserve prior no-op behaviour + case other => + val embed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/online`.").build() + event.getHook.sendMessageEmbeds(embed).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/Options.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/Options.scala new file mode 100644 index 0000000..e0ce4dd --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/Options.scala @@ -0,0 +1,13 @@ +package com.tibiabot.commands.handlers + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +import scala.jdk.CollectionConverters._ + +/** Shared parsing of slash-command options into a lower-cased, trimmed name -> value map. */ +object Options { + def of(event: SlashCommandInteractionEvent): Map[String, String] = + event.getInteraction.getOptions.asScala + .map(option => option.getName.toLowerCase() -> option.getAsString.trim()) + .toMap +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/discord/DiscordGateway.scala b/tibia-bot/src/main/scala/com/tibiabot/discord/DiscordGateway.scala new file mode 100644 index 0000000..d5e0e0d --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/discord/DiscordGateway.scala @@ -0,0 +1,26 @@ +package com.tibiabot.discord + +import net.dv8tion.jda.api.entities.{Guild, User} + +/** + * Read-side seam over the JDA instance: the single place the rest of the bot + * goes through for guild/user lookups, identity and presence. Mirrors JDA's + * semantics (`guildById` may return null; `retrieveUser` blocks) so call sites + * are unchanged, while making the JDA dependency injectable and fakeable. + */ +trait DiscordGateway { + /** The guild with this id, or null if the bot can't see it (mirrors JDA). */ + def guildById(id: String): Guild + /** All guilds the bot is currently in. */ + def guilds: List[Guild] + /** Blocking user retrieval by id (mirrors `retrieveUserById(id).complete()`). */ + def retrieveUser(id: String): User + /** The bot account's own user id. */ + def selfUserId: String + /** The bot account's own username. */ + def selfUserName: String + /** The Discord application owner's user id (the bot creator), or "" if unknown. */ + def applicationOwnerId: String + /** Set the bot's "Watching " presence. */ + def setWatchingActivity(text: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/discord/JdaDiscordGateway.scala b/tibia-bot/src/main/scala/com/tibiabot/discord/JdaDiscordGateway.scala new file mode 100644 index 0000000..c24d5b3 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/discord/JdaDiscordGateway.scala @@ -0,0 +1,19 @@ +package com.tibiabot.discord + +import net.dv8tion.jda.api.JDA +import net.dv8tion.jda.api.entities.{Activity, Guild, User} + +import scala.jdk.CollectionConverters._ + +/** JDA-backed [[DiscordGateway]]. */ +final class JdaDiscordGateway(jda: JDA) extends DiscordGateway { + def guildById(id: String): Guild = jda.getGuildById(id) + def guilds: List[Guild] = jda.getGuilds.asScala.toList + def retrieveUser(id: String): User = jda.retrieveUserById(id).complete() + def selfUserId: String = jda.getSelfUser.getId + def selfUserName: String = jda.getSelfUser.getName + def applicationOwnerId: String = + Option(jda.retrieveApplicationInfo().complete().getOwner).map(_.getId).getOrElse("") + def setWatchingActivity(text: String): Unit = + jda.getPresence().setActivity(Activity.of(Activity.ActivityType.WATCHING, text)) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/discord/RateLimitedSender.scala b/tibia-bot/src/main/scala/com/tibiabot/discord/RateLimitedSender.scala new file mode 100644 index 0000000..52b6f1e --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/discord/RateLimitedSender.scala @@ -0,0 +1,45 @@ +package com.tibiabot.discord + +import com.tibiabot.tracking.BoundedMessageQueue +import com.typesafe.scalalogging.StrictLogging + +/** + * Owns the outbound Discord message queue and drains it one item per tick so we + * never exceed Discord's rate limits. + * + * Each queued item is a `dispatch` thunk that performs the actual JDA send, which + * keeps this class free of JDA types and unit-testable. Scheduling is injected via + * `startTicker`: it must run the supplied drain action immediately and then on a + * fixed delay, returning a handle that stops the ticker. The drain loop is started + * lazily on the first enqueue. + * + * The default capacity (`Int.MaxValue`) reproduces the previous unbounded behaviour + * exactly; a finite capacity drops messages instead of leaking memory under a burst. + */ +final class RateLimitedSender( + startTicker: (() => Unit) => (() => Unit), + capacity: Int = Int.MaxValue +) extends StrictLogging { + + private val queue = new BoundedMessageQueue[() => Unit](capacity) + private var stopTicker: Option[() => Unit] = None + + /** Queue a send and ensure the drain loop is running. Thread-safe. */ + def enqueue(dispatch: () => Unit): Unit = synchronized { + if (!queue.enqueue(dispatch)) + logger.warn(s"Outbound message queue full (capacity $capacity); dropped ${queue.dropped} messages so far") + if (stopTicker.isEmpty) stopTicker = Some(startTicker(() => drainOne())) + } + + /** Send the next queued message, if any. Failures are logged, never propagated. */ + private[discord] def drainOne(): Unit = { + val next = synchronized { queue.dequeueOption() } + next.foreach { dispatch => + try dispatch() + catch { + case ex: Exception => logger.error(s"Failed to send queued message: ${ex.getMessage}") + case _: Throwable => logger.error("Failed to send queued message") + } + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Cache.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Cache.scala new file mode 100644 index 0000000..356573c --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Cache.scala @@ -0,0 +1,8 @@ +package com.tibiabot.domain + +import java.time.ZonedDateTime + +case class BoostedCache(boss: String, creature: String, bossChanged: String, creatureChanged: String) +case class DeathsCache(world: String, name: String, time: String) +case class LevelsCache(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String) +case class ListCache(name: String, formerNames: List[String], world: String, formerWorlds: List[String], guild: String, level: String, vocation: String, last_login: String, updatedTime: ZonedDateTime) diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Discord.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Discord.scala new file mode 100644 index 0000000..c3af2c5 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Discord.scala @@ -0,0 +1,4 @@ +package com.tibiabot.domain + +case class Discords(id: String, adminChannel: String, boostedChannel: String, boostedMessage: String) +case class Guilds(name: String, reason: String, reasonText: String, addedBy: String) diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Player.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Player.scala new file mode 100644 index 0000000..62cbc63 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Player.scala @@ -0,0 +1,6 @@ +package com.tibiabot.domain + +import java.time.ZonedDateTime + +case class Players(name: String, reason: String, reasonText: String, addedBy: String) +case class PlayerCache(name: String, formerNames: List[String], guild: String, updatedTime: ZonedDateTime) diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Screenshot.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Screenshot.scala new file mode 100644 index 0000000..74bda9b --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Screenshot.scala @@ -0,0 +1,6 @@ +package com.tibiabot.domain + +import java.time.ZonedDateTime + +case class DeathScreenshot(guildId: String, world: String, characterName: String, deathTime: Long, screenshotUrl: String, addedBy: String, addedName: String, addedAt: ZonedDateTime, messageId: String) +case class PendingScreenshot(charName: String, deathTime: Long, messageId: String, guildId: String, world: String, userId: String, channelId: String) diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Stamps.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Stamps.scala new file mode 100644 index 0000000..5d7d3e9 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Stamps.scala @@ -0,0 +1,6 @@ +package com.tibiabot.domain + +import java.time.ZonedDateTime + +case class SatchelStamp(user: String, when: ZonedDateTime, tag: String) +case class BoostedStamp(user: String, boostedType: String, boostedName: String) diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/World.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/World.scala new file mode 100644 index 0000000..f6bc230 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/World.scala @@ -0,0 +1,33 @@ +package com.tibiabot.domain + +/** Per-world tracking configuration (one row of the per-guild `worlds` table). */ +case class Worlds(name: String, + alliesChannel: String, + enemiesChannel: String, + neutralsChannel: String, + levelsChannel: String, + deathsChannel: String, + category: String, + fullblessRole: String, + nemesisRole: String, + allyPkRole: String, + masslogRole: String, + fullblessChannel: String, + nemesisChannel: String, + fullblessLevel: Int, + showNeutralLevels: String, + showNeutralDeaths: String, + showAlliesLevels: String, + showAlliesDeaths: String, + showEnemiesLevels: String, + showEnemiesDeaths: String, + detectHunteds: String, + levelsMin: Int, + deathsMin: Int, + exivaList: String, + activityChannel: String, + onlineCombined: String +) + +case class CustomSort(entityType: String, name: String, label: String, emoji: String) +case class BossEntry(world: String, boss: String) diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/time/Clock.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/time/Clock.scala new file mode 100644 index 0000000..cb658a0 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/time/Clock.scala @@ -0,0 +1,28 @@ +package com.tibiabot.domain.time + +import java.time.{Instant, ZoneId, ZonedDateTime} + +/** Time-source port (Dependency Inversion). + * + * Time-dependent logic depends on this trait rather than calling + * `Instant.now()` / `ZonedDateTime.now()` directly, so it can be tested + * deterministically with a fixed clock. Wiring the existing wall-clock call + * sites onto this port happens incrementally (e.g. with the scheduler step); + * the pure cycle math in this package is already parameterized by explicit + * instants and needs no clock. + */ +trait Clock { + def instant: Instant + def now: ZonedDateTime +} + +object Clock { + /** The game's reference time zone, used throughout the bot. */ + val Berlin: ZoneId = ZoneId.of("Europe/Berlin") +} + +/** Real clock backed by system time, in the game's Europe/Berlin zone. */ +final class SystemClock extends Clock { + def instant: Instant = Instant.now() + def now: ZonedDateTime = ZonedDateTime.now(Clock.Berlin) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala new file mode 100644 index 0000000..8be06c4 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala @@ -0,0 +1,27 @@ +package com.tibiabot.domain.time + +/** The Dream Courts (Dream Scar) boss-of-the-day rotation. + * https://tibia.fandom.com/wiki/Template:Dream_Scar_Boss/Offsets */ +object DreamScarCycle { + + val bossCycle: Vector[String] = Vector( + "Plagueroot", + "Malofur Mangrinder", + "Maxxenius", + "Alptramun", + "Izcandar the Banished" + ) + + val indexOfBoss: Map[String, Int] = bossCycle.zipWithIndex.toMap + + /** Shift each world's boss to the next in the cycle; unknown bosses are kept + * unchanged. Extracted verbatim from `BotApp.shiftAllBossesUp`. */ + def shiftAllBossesUp(current: Map[String, String]): Map[String, String] = + current.map { case (world, boss) => + val nextBoss = indexOfBoss.get(boss) match { + case Some(idx) => bossCycle((idx + 1) % bossCycle.length) + case None => boss // fallback: keep unchanged + } + world -> nextBoss + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/time/DromeCycle.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/time/DromeCycle.scala new file mode 100644 index 0000000..3b272a2 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/time/DromeCycle.scala @@ -0,0 +1,19 @@ +package com.tibiabot.domain.time + +import java.time.Instant + +/** The Tibiadrome cycle: a server-save anchor that advances in 2-week steps. + * Pure math extracted verbatim from `BotApp.advanceDromeTime`. */ +object DromeCycle { + + /** The initial anchor: 27 May 2026 server save. */ + val initial: Instant = Instant.ofEpochSecond(1779868800L) + + /** Advance `current` in 2-week (Europe/Berlin) steps until it is no longer + * before `target`, returning that instant. */ + def advanceFrom(current: Instant, target: Instant): Instant = + Iterator + .iterate(current)(t => t.atZone(Clock.Berlin).plusWeeks(2).toInstant) + .dropWhile(_.isBefore(target)) + .next() +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala b/tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala new file mode 100644 index 0000000..0cf7d24 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala @@ -0,0 +1,78 @@ +package com.tibiabot.galthen + +import com.tibiabot.discord.DiscordGateway +import com.tibiabot.domain.SatchelStamp +import com.tibiabot.persistence.{ConnectionProvider, GalthenRepository} +import com.typesafe.scalalogging.StrictLogging +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.User +import net.dv8tion.jda.api.interactions.components.buttons.Button + +import java.sql.Timestamp +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +/** + * Galthen's Satchel cooldown tracking: CRUD over the satchel table plus the + * daily expiry DM. Extracted from BotApp verbatim; CRUD delegates to the + * repository, [[cleanExpired]] runs the notify-then-delete job. + */ +final class GalthenService( + repository: GalthenRepository, + connectionProvider: ConnectionProvider, + discordGateway: DiscordGateway +) extends StrictLogging { + + def getStamps(userId: String): Option[List[SatchelStamp]] = repository.getStamps(userId) + def add(user: String, when: ZonedDateTime, tag: String): Unit = repository.add(user, when, tag) + def del(user: String, tag: String): Unit = repository.del(user, tag) + def delAll(user: String): Unit = repository.delAll(user) + + /** DM each user whose 30-day satchel cooldown has expired, then delete those rows. */ + def cleanExpired(): Unit = { + val conn = connectionProvider.cache() + + // Retrieve the data before deletion + val selectStatement = conn.prepareStatement("SELECT userid,time,tag FROM satchel WHERE time < ?;") + selectStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(30, ChronoUnit.DAYS).toInstant)) + val resultSet = selectStatement.executeQuery() + + // Retrieve the data from the result set + while (resultSet.next()) { + val userId = resultSet.getString("userid") + val tagId = Option(resultSet.getString("tag")).getOrElse("") + val user: User = discordGateway.retrieveUser(userId) + val userTimeStamp = resultSet.getTimestamp("time").toInstant() + val cooldown = userTimeStamp.plus(30, ChronoUnit.DAYS).getEpochSecond.toString() + + if (user != null) { + try { + user.openPrivateChannel().queue { privateChannel => + val embed = new EmbedBuilder() + if (tagId.nonEmpty) embed.setFooter(s"Tag: ${tagId.toLowerCase}") + val displayTag = if (tagId.nonEmpty) s"**`$tagId`**" else s"<@$userId>" + embed.setColor(178877) + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + embed.setDescription(s"<:satchel:1030348072577945651> cooldown for $displayTag expired \n\nMark it as **Collected** and I will message you when the 30 day cooldown expires.") + privateChannel.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenRemind", "Collected"), + Button.secondary("galthenClear", "Dismiss") + ).queue() + } + } catch { + case ex: Exception => // + } + } + } + + selectStatement.close() + + // Now you have the list of userids and time before deletion, you can proceed with deletion + val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE time < ?;") + deleteStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(30, ChronoUnit.DAYS).toInstant)) + deleteStatement.executeUpdate() + deleteStatement.close() + + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala b/tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala new file mode 100644 index 0000000..7dd1277 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala @@ -0,0 +1,725 @@ +package com.tibiabot.interactions + +import com.tibiabot.{BotApp, Config, presentation} +import com.tibiabot.BotApp.worldsData +import com.tibiabot.domain.{PendingScreenshot, SatchelStamp} +import com.typesafe.scalalogging.StrictLogging + +import java.time.ZonedDateTime +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.emoji.Emoji +import net.dv8tion.jda.api.entities.{Guild, Member, Role} +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent +import net.dv8tion.jda.api.interactions.components.ActionRow +import net.dv8tion.jda.api.interactions.components.buttons.Button +import net.dv8tion.jda.api.interactions.components.text.{TextInput, TextInputStyle} +import net.dv8tion.jda.api.interactions.modals.Modal + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ + +/** Handles all button-click interactions (galthen, boosted, screenshot nav, + * role toggles). Moved verbatim from BotListener.onButtonInteraction; the + * shared pendingScreenshots map is passed in. */ +object ButtonHandler extends StrictLogging { + def handle(event: ButtonInteractionEvent, pendingScreenshots: mutable.Map[String, PendingScreenshot]): Unit = { + val embed = event.getInteraction.getMessage.getEmbeds + val title = if (!embed.isEmpty) embed.get(0).getTitle else "" + val button = event.getComponentId + val guild = event.getGuild + val user = event.getUser + var responseText = s"${Config.noEmoji} An unknown error occured, please try again." + + val footer = if (!embed.isEmpty) Option(embed.get(0).getFooter) else None + val tagId = footer.map(_.getText.replace("Tag: ", "")).getOrElse("") + + /** + if (button == "galthen board") { + event.deferReply(true).queue() + //WIP + val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.galthenService.getStamps(event.getUser.getId) + satchelTimeOption match { + case Some(satchelTimeList) => + val fullList = satchelTimeList.collect { + case satchel => + val when = satchel.when.plusDays(30).toEpochSecond.toString() + val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" + s"<:satchel:1030348072577945651> can be collected by $displayTag " + } else { + embed.setColor(178877) + embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you: ```when the 30 day cooldown expires```") + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected"), + Button.danger("galthenRemove", "Clear").asDisabled + ).queue() + } + // /HERE + case None => + embed.setColor(178877) + embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you: ```when the 30 day cooldown expires```") + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected"), + Button.danger("galthenRemove", "Clear").asDisabled + ).queue() + } + } else + **/ + if (button == "galthenSet") { + event.deferEdit().queue(); + val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() + BotApp.galthenService.add(user.getId, ZonedDateTime.now(), tagId) + val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" + responseText = s"${Config.satchelEmoji} can be collected by $tagDisplay " + val newEmbed = new EmbedBuilder() + newEmbed.setDescription(responseText) + newEmbed.setColor(178877) + event.getHook().editOriginalEmbeds(newEmbed.build()).setComponents().queue(); + } else if (button == "galthenRemove") { + event.deferEdit().queue() + BotApp.galthenService.del(user.getId, tagId) + val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" + responseText = s"${Config.satchelEmoji} cooldown tracker for $tagDisplay has been **Disabled**." + event.getHook().editOriginalComponents().queue(); + val newEmbed = new EmbedBuilder().setDescription(responseText).setColor(178877).build() + event.getHook().editOriginalEmbeds(newEmbed).queue(); + } else if (button == "galthenRemoveAll") { + event.deferEdit().queue() + BotApp.galthenService.delAll(user.getId) + responseText = s"${Config.satchelEmoji} cooldown tracker has been **Disabled**." + event.getHook().editOriginalComponents().queue(); + val newEmbed = new EmbedBuilder().setDescription(responseText).setColor(178877).build() + event.getHook().editOriginalEmbeds(newEmbed).queue(); + } else if (button == "galthenLock") { + event.deferEdit().queue() + event.getHook().editOriginalComponents(ActionRow.of( + Button.secondary("galthenUnLock", "🔓"), + Button.danger("galthenRemoveAll", "Clear All") + )).queue(); + } else if (button == "galthenUnLock") { + event.deferEdit().queue() + event.getHook().editOriginalComponents(ActionRow.of( + Button.secondary("galthenLock", "🔒"), + Button.danger("galthenRemoveAll", "Clear All").asDisabled + )).queue(); + } else if (button == "galthenRemind") { // WIP + event.deferEdit().queue() + val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() + BotApp.galthenService.add(user.getId, ZonedDateTime.now(), tagId) + val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" + responseText = s"${Config.satchelEmoji} can be collected by $tagDisplay " + event.getHook().editOriginalComponents().queue(); + val newEmbed = new EmbedBuilder().setDescription(responseText).setColor(178877).setFooter("You will be sent a message when the cooldown expires").build() + event.getHook().editOriginalEmbeds(newEmbed).queue() + } else if (button == "galthenClear") { // WIP + event.deferEdit().queue() + event.getHook().editOriginalComponents().queue() + } else if (button == "galthenAdd") { + val inputWindow = TextInput.create("galthen add", "Tag/Name for this cooldown", TextInputStyle.SHORT) + .setPlaceholder("Character Name or Tag to Add") + .build() + val modal = Modal.create("add galthen", "Add a Galthen Satchel cooldown").addComponents(ActionRow.of(inputWindow)).build() + event.replyModal(modal).queue() + } else if (button == "galthenButtonRem") { + val inputWindow = TextInput.create("galthen rem", "Tag/Name for the cooldown", TextInputStyle.SHORT) + .setPlaceholder("Character Name or Tag to Remove") + .build() + val modal = Modal.create("rem galthen", "Remove a Galthen Satchel cooldown").addComponents(ActionRow.of(inputWindow)).build() + event.replyModal(modal).queue() + } else if (button == "boosted") { + event.deferReply(true).queue() + val replyEmbed = new EmbedBuilder() + replyEmbed.setTitle(s"Receiving boosted boss & creature notifications:") + responseText = s"Use the `/boosted` command to filter specific `bosses` & `creatures`." + replyEmbed.setDescription(responseText) + event.getHook.sendMessageEmbeds(replyEmbed.build()).queue() + } else if (button == "boosted add") { + val inputWindow = TextInput.create("boosted add", "Boss or Creature name", TextInputStyle.SHORT) + .setPlaceholder("Grand Master Oberon") + .build() + val modal = Modal.create("add modal", "Add a Boss or Creature").addComponents(ActionRow.of(inputWindow)).build() + event.replyModal(modal).queue() + } else if (button == "boosted remove") { + + val inputWindow = TextInput.create("boosted remove", "Boss or Creature name", TextInputStyle.SHORT).build() + val modal = Modal.create("remove modal", "Add Server Save Notificiations:").addComponents(ActionRow.of(inputWindow)).build() + event.replyModal(modal).queue() + } else if (button == "boosted list") { + event.deferReply(true).queue() + val allCheck = BotApp.boostedService.boostedList(event.getUser.getId) + if (allCheck) { + val embed = BotApp.boostedService.boosted(event.getUser.getId, "list", "") + event.getHook.sendMessageEmbeds(embed).setActionRow( + Button.success("boosted add", "Add").asDisabled, + Button.danger("boosted remove", "Remove").asDisabled, + Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOnEmoji)) + ).queue() + } else { + val embed = BotApp.boostedService.boosted(event.getUser.getId, "list", "") + event.getHook.sendMessageEmbeds(embed).setActionRow( + Button.success("boosted add", "Add"), + Button.danger("boosted remove", "Remove"), + Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) + ).queue() + } + } else if (button == "boosted toggle") { + event.deferEdit().queue() + + val allCheck = BotApp.boostedService.boostedList(event.getUser.getId) + if (allCheck) { + val embed = BotApp.boostedService.boosted(event.getUser.getId, "toggle", "all") + event.getHook.editOriginalEmbeds(embed).setActionRow( + Button.success("boosted add", "Add"), + Button.danger("boosted remove", "Remove"), + Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) + ).queue() + } else { + val embed = BotApp.boostedService.boosted(event.getUser.getId, "toggle", "all") + event.getHook.editOriginalEmbeds(embed).setActionRow( + Button.success("boosted add", "Add").asDisabled, + Button.danger("boosted remove", "Remove").asDisabled, + Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOnEmoji)) + ).queue() + } + } else if (button == "galthen default") { + event.deferReply(true).queue() + val embed = new EmbedBuilder() + + val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.galthenService.getStamps(event.getUser.getId) + satchelTimeOption match { + // + case Some(satchelTimeList) if satchelTimeList.isEmpty => + embed.setColor(3092790) + embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) + ).queue() + // + case Some(satchelTimeList) => + val fullList = satchelTimeList.collect { + case satchel => + val when = satchel.when.plusDays(30).toEpochSecond.toString() + val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" + s"${Config.satchelEmoji} can be collected by $displayTag " + } + if (fullList.nonEmpty) { + embed.setTitle("Existing Cooldowns:") + embed.setDescription(presentation.GalthenEmbeds.truncate(fullList)) + embed.setColor(3092790) + if (fullList.size == 1){ + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), //WIP + Button.danger("galthenRemoveAll", "Remove") + ).queue() + } else { + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), + Button.danger("galthenButtonRem", "Remove"), + Button.secondary("galthenRemoveAll", "Clear All") + ).queue() + } + } else { + embed.setColor(3092790) + embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) + ).queue() + } + // /HERE + case None => + embed.setColor(3092790) + embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") + event.getHook.sendMessageEmbeds(embed.build()).addActionRow( + Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) + ).queue() + // + } + } else if (button == "fullbless") { + event.deferReply(true).queue() + val world = title.replace(":crossed_swords:", "").trim() + val worldConfigData = BotApp.worldRetrieveConfig(guild, world) + val role = guild.getRoleById(worldConfigData("fullbless_role")) + if (role != null) { + guild.retrieveMemberById(user.getId).queue { member => + val hasRole = member.getRoles.contains(role) + val action = + if (hasRole) guild.removeRoleFromMember(member, role) + else guild.addRoleToMember(member, role) + + action.queue( + _ => { + val msg = + if (hasRole) + s":gear: You have been removed from the <@&${role.getId}> role." + else + s":gear: You have been added to the <@&${role.getId}> role." + + event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() + }, + _ => () + ) + } + } + } else if (button == "nemesis") { + event.deferReply(true).queue() + val world = title.replace(":crossed_swords:", "").trim() + val worldConfigData = BotApp.worldRetrieveConfig(guild, world) + val role = guild.getRoleById(worldConfigData("nemesis_role")) + if (role != null) { + guild.retrieveMemberById(user.getId).queue { member => + val hasRole = member.getRoles.contains(role) + val action = + if (hasRole) guild.removeRoleFromMember(member, role) + else guild.addRoleToMember(member, role) + + action.queue( + _ => { + val msg = + if (hasRole) + s":gear: You have been removed from the <@&${role.getId}> role." + else + s":gear: You have been added to the <@&${role.getId}> role." + + event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() + }, + _ => () + ) + } + } + } else if (button == "allypk") { + event.deferReply(true).queue() + val world = title.replace(":crossed_swords:", "").trim + val worldConfigData = BotApp.worldRetrieveConfig(guild, world) + val role = guild.getRoleById(worldConfigData("allypk_role")) + if (role != null) { + guild.retrieveMemberById(user.getId).queue { member => + val hasRole = member.getRoles.contains(role) + val action = + if (hasRole) guild.removeRoleFromMember(member, role) + else guild.addRoleToMember(member, role) + + action.queue( + _ => { + val msg = + if (hasRole) + s":gear: You have been removed from the <@&${role.getId}> role." + else + s":gear: You have been added to the <@&${role.getId}> role." + + event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() + }, + _ => () + ) + } + } + } else if (button == "masslog") { + event.deferReply(true).queue() + val world = title.replace(":crossed_swords:", "").trim + val worldConfigData = BotApp.worldRetrieveConfig(guild, world) + val role = guild.getRoleById(worldConfigData("masslog_role")) + if (role != null) { + guild.retrieveMemberById(user.getId).queue { member => + val hasRole = member.getRoles.contains(role) + val action = + if (hasRole) guild.removeRoleFromMember(member, role) + else guild.addRoleToMember(member, role) + + action.queue( + _ => { + val msg = + if (hasRole) + s":gear: You have been removed from the <@&${role.getId}> role." + else + s":gear: You have been added to the <@&${role.getId}> role." + + event.getHook.sendMessageEmbeds(new EmbedBuilder().setDescription(msg).build()).queue() + }, + _ => () + ) + } + } + } else if (button.startsWith("death_screenshot_")) { + // Handle death screenshot button clicks + val buttonParts = button.split("_") + if (buttonParts.length >= 4) { + val charName = buttonParts(2) + val deathTime = buttonParts(3).toLong + val messageId = event.getInteraction.getMessage.getId + + // Get world from guild configuration + val worldOpt = worldsData.get(guild.getId).flatMap(_.headOption).map(_.name) + + worldOpt match { + case Some(world) => + // Store pending screenshot request + val pendingKey = s"${event.getUser.getId}_${guild.getId}" + pendingScreenshots.put(pendingKey, PendingScreenshot(charName, deathTime, messageId, guild.getId, world, event.getUser.getId, event.getChannel.getId)) + + // Send DM to user + event.getUser.openPrivateChannel().queue(privateChannel => { + val embed = new EmbedBuilder() + .setColor(3092790) + .setTitle(s"Upload Screenshot for ${charName}") + .setDescription(s"Please upload an image file (PNG, JPG, GIF, Webp) to this DM within the next 5 minutes.\n\n" + + s"The screenshot will be added to the death message for **[${charName}](${BotApp.charUrl(charName)})** in **${guild.getName}**.") + .setFooter("You can also paste an image directly from your clipboard") + .build() + + privateChannel.sendMessageEmbeds(embed).queue( + _ => { + // Confirm to user that DM was sent + event.reply(s"${Config.yesEmoji} Screenshot upload request sent to your DMs for **[${charName}](${BotApp.charUrl(charName)})**.").setEphemeral(true).queue() + }, + error => { + // Fallback if DM fails + val fallbackEmbed = new EmbedBuilder() + .setColor(16711680) // Red color + .setTitle(s"Upload Screenshot for ${charName}") + .setDescription(s"Could not send you a DM. Please upload an image file (PNG, JPG, GIF, Webp) in this channel within the next 5 minutes, If you wish to cancel, simply respond with the word **cancel**.\n\n" + + s"The screenshot will be added to the death message for **[${charName}](${BotApp.charUrl(charName)})**.") + .setFooter("You can also paste an image directly from your clipboard") + .build() + + event.reply("").addEmbeds(fallbackEmbed).setEphemeral(true).queue() + } + ) + }) + + // Set a timeout to remove the pending request after 5 minutes + scala.concurrent.ExecutionContext.global.execute(() => { + Thread.sleep(300000) // 5 minutes + pendingScreenshots.remove(pendingKey) + }) + + case None => + responseText = s"${Config.noEmoji} Could not determine world for this guild." + val replyEmbed = new EmbedBuilder().setDescription(responseText).build() + event.reply("").addEmbeds(replyEmbed).setEphemeral(true).queue() + } + } else { + responseText = s"${Config.noEmoji} Invalid button format." + val replyEmbed = new EmbedBuilder().setDescription(responseText).build() + event.reply("").addEmbeds(replyEmbed).setEphemeral(true).queue() + } + } else if (button.startsWith("prev_screenshot_") || button.startsWith("next_screenshot_")) { + event.deferEdit().queue() + + val buttonParts = button.split("_") + if (buttonParts.length >= 6) { + val charName = buttonParts(2) + val deathTime = buttonParts(3).toLong + val messageId = event.getInteraction.getMessage.getId + val currentIndex = buttonParts(5).toInt + + // Get world from guild configuration + val worldOpt = worldsData.get(guild.getId).flatMap(_.headOption).map(_.name) + + worldOpt.foreach { world => + val screenshots = BotApp.getDeathScreenshots(guild.getId, world, charName, deathTime) + + if (screenshots.nonEmpty) { + val newIndex = if (button.startsWith("prev_")) { + if (currentIndex > 0) currentIndex - 1 else screenshots.length - 1 + } else { + if (currentIndex < screenshots.length - 1) currentIndex + 1 else 0 + } + + val currentScreenshot = screenshots(newIndex) + + // Preserve the original death message embed and just update the image + val originalEmbed = event.getMessage.getEmbeds.get(0) + val embed = new EmbedBuilder(originalEmbed) + .setImage(currentScreenshot.screenshotUrl) + .setFooter(s"Screenshot added by ${currentScreenshot.addedName} • ${newIndex + 1}/${screenshots.length}") + .build() + + val components = if (screenshots.length > 1) { + val baseButtons = List( + Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot"), + Button.primary(s"prev_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "◀"), + Button.secondary(s"screenshot_info_${charName}_${deathTime}_${messageId}", s"${newIndex + 1}/${screenshots.length}").asDisabled(), + Button.primary(s"next_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "▶") + ) + val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") + List(ActionRow.of(buttonsWithDelete: _*)) + } else { + val baseButtons = List(Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot")) + val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") + List(ActionRow.of(buttonsWithDelete: _*)) + } + + event.getHook.editOriginalEmbeds(embed).setComponents(components: _*).queue() + } + } + } + } else if (button.startsWith("delete_screenshot_")) { + event.deferEdit().queue() + + val buttonParts = button.split("_") + if (buttonParts.length >= 6) { + val charName = buttonParts(2) + val deathTime = buttonParts(3).toLong + val messageId = event.getInteraction.getMessage.getId + val currentIndex = buttonParts(5).toInt + + val guild = event.getGuild + val user = event.getUser + val originalMessage = event.getMessage + + // Get current screenshots to find the URL of the screenshot to delete + val screenshots = BotApp.getDeathScreenshots(guild.getId, guild.getName, charName, deathTime) + if (screenshots.nonEmpty && currentIndex < screenshots.length) { + val screenshotToDelete = screenshots(currentIndex) + + // Attempt to delete the screenshot + if (BotApp.deleteDeathScreenshot(guild.getId, guild.getName, charName, deathTime, screenshotToDelete.screenshotUrl, user.getId)) { + // Successfully deleted, update the embed + val updatedScreenshots = BotApp.getDeathScreenshots(guild.getId, guild.getName, charName, deathTime) + val embeds = originalMessage.getEmbeds + + if (embeds.size() > 0 && updatedScreenshots.nonEmpty) { + // Still have screenshots, show another one + val newIndex = Math.min(currentIndex, updatedScreenshots.length - 1) + val newCurrentScreenshot = updatedScreenshots(newIndex) + + val originalEmbed = embeds.get(0) + val updatedEmbed = new EmbedBuilder(originalEmbed) + .setImage(newCurrentScreenshot.screenshotUrl) + .setFooter(s"Screenshot added by ${newCurrentScreenshot.addedName} • ${newIndex + 1}/${updatedScreenshots.length}") + .build() + + val components = if (updatedScreenshots.length > 1) { + val baseButtons = List( + Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot"), + Button.primary(s"prev_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "◀"), + Button.secondary(s"screenshot_info_${charName}_${deathTime}_${messageId}", s"${newIndex + 1}/${updatedScreenshots.length}").asDisabled(), + Button.primary(s"next_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "▶") + ) + val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") + List(ActionRow.of(buttonsWithDelete: _*)) + } else { + val baseButtons = List(Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot")) + val buttonsWithDelete = baseButtons :+ Button.danger(s"delete_screenshot_${charName}_${deathTime}_${messageId}_${newIndex}", "🗑️") + List(ActionRow.of(buttonsWithDelete: _*)) + } + + event.getHook.editOriginalEmbeds(updatedEmbed).setComponents(components: _*).queue() + } else { + // No more screenshots, remove image and show only add button + val originalEmbed = embeds.get(0) + val updatedEmbed = new EmbedBuilder(originalEmbed) + .setImage(null) + .setFooter(null) + .build() + + val addButton = List(ActionRow.of(Button.secondary(s"death_screenshot_${charName}_${deathTime}_${messageId}", "Add Screenshot"))) + event.getHook.editOriginalEmbeds(updatedEmbed).setComponents(addButton: _*).queue() + } + } else { + // Failed to delete - not the author or other error + event.getHook.sendMessage(s"${Config.noEmoji} You can only delete screenshots you uploaded.").setEphemeral(true).queue() + } + } else { + event.getHook.sendMessage(s"${Config.noEmoji} Screenshot not found.").setEphemeral(true).queue() + } + } else { + event.getHook.sendMessage(s"${Config.noEmoji} Invalid button format.").setEphemeral(true).queue() + } + } else { + event.deferReply(true).queue() + if (title != "") { + val roleType = if (title.contains(":crossed_swords:")) "fullbless" else if (title.contains(s"${Config.nemesisEmoji}")) "nemesis" else if (title.contains(s"${Config.hazardEmoji}")) "allypk" else "" + if (roleType == "fullbless") { + val world = title.replace(":crossed_swords:", "").trim() + val worldConfigData = BotApp.worldRetrieveConfig(guild, world) + val role = guild.getRoleById(worldConfigData("fullbless_role")) + if (role != null) { + if (button == "add") { + // get role add user to it + try { + guild.addRoleToMember(user, role).queue() + responseText = s":gear: You have been added to the <@&${role.getId}> role." + } catch { + case _: Throwable => + responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." + val discordInfo = BotApp.discordRetrieveConfig(guild) + val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" + val adminTextChannel = guild.getTextChannelById(adminChannelId) + if (adminTextChannel != null) { + val commandPlayer = s"<@${user.getId}>" + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") + adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") + adminEmbed.setColor(3092790) // orange for bot auto command + try { + adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") + } + } + } + } else if (button == "remove") { + // remove role + try { + guild.removeRoleFromMember(user, role).queue() + responseText = s":gear: You have been removed from the <@&${role.getId}> role." + } catch { + case _: Throwable => + responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." + val discordInfo = BotApp.discordRetrieveConfig(guild) + val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" + val adminTextChannel = guild.getTextChannelById(adminChannelId) + if (adminTextChannel != null) { + val commandPlayer = s"<@${user.getId}>" + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") + adminEmbed.setDescription(s"Failed to remove user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") + adminEmbed.setColor(3092790) // orange for bot auto command + try { + adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") + } + } + } + } + } else { + // role doesn't exist + responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." + } + } else if (roleType == "nemesis") { + val world = title.replace(s"${Config.nemesisEmoji}", "").trim() + val worldConfigData = BotApp.worldRetrieveConfig(guild, world) + val role = guild.getRoleById(worldConfigData("nemesis_role")) + if (role != null) { + if (button == "add") { + // get role add user to it + try { + guild.addRoleToMember(user, role).queue() + responseText = s":gear: You have been added to the <@&${role.getId}> role." + } catch { + case _: Throwable => + responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." + val discordInfo = BotApp.discordRetrieveConfig(guild) + val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" + val adminTextChannel = guild.getTextChannelById(adminChannelId) + if (adminTextChannel != null) { + val commandPlayer = s"<@${user.getId}>" + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") + adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") + adminEmbed.setColor(3092790) // orange for bot auto command + try { + adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") + } + } + } + } else if (button == "remove") { + // remove role + try { + guild.removeRoleFromMember(user, role).queue() + responseText = s":gear: You have been removed from the <@&${role.getId}> role." + } catch { + case _: Throwable => + responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." + val discordInfo = BotApp.discordRetrieveConfig(guild) + val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" + val adminTextChannel = guild.getTextChannelById(adminChannelId) + if (adminTextChannel != null) { + val commandPlayer = s"<@${user.getId}>" + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") + adminEmbed.setDescription(s"Failed to remove user $commandPlayer from the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") + adminEmbed.setColor(3092790) // orange for bot auto command + try { + adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") + } + } + } + } + } else { + // role doesn't exist + responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." + } + } else if (roleType == "allypk") { + val world = title.replace(s"${Config.hazardEmoji}", "").trim() + val worldConfigData = BotApp.worldRetrieveConfig(guild, world) + val role = guild.getRoleById(worldConfigData("allypk_role")) + if (role != null) { + if (button == "add") { + // get role add user to it + try { + guild.addRoleToMember(user, role).queue() + responseText = s":gear: You have been added to the <@&${role.getId}> role." + } catch { + case _: Throwable => + responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." + val discordInfo = BotApp.discordRetrieveConfig(guild) + val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" + val adminTextChannel = guild.getTextChannelById(adminChannelId) + if (adminTextChannel != null) { + val commandPlayer = s"<@${user.getId}>" + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") + adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") + adminEmbed.setColor(3092790) // orange for bot auto command + try { + adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") + } + } + } + } else if (button == "remove") { + // remove role + try { + guild.removeRoleFromMember(user, role).queue() + responseText = s":gear: You have been removed from the <@&${role.getId}> role." + } catch { + case _: Throwable => + responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." + val discordInfo = BotApp.discordRetrieveConfig(guild) + val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" + val adminTextChannel = guild.getTextChannelById(adminChannelId) + if (adminTextChannel != null) { + val commandPlayer = s"<@${user.getId}>" + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") + adminEmbed.setDescription(s"Failed to remove user $commandPlayer from the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") + adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") + adminEmbed.setColor(3092790) // orange for bot auto command + try { + adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") + } + } + } + } + } else { + // role doesn't exist + responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." + } + } + } + val replyEmbed = new EmbedBuilder().setDescription(responseText).build() + event.getHook.sendMessageEmbeds(replyEmbed).queue() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala b/tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala new file mode 100644 index 0000000..2ad1200 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala @@ -0,0 +1,230 @@ +package com.tibiabot.interactions + +import com.tibiabot.{BotApp, Config, presentation} +import com.tibiabot.domain.SatchelStamp +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.emoji.Emoji +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent +import net.dv8tion.jda.api.interactions.components.buttons.Button +import net.dv8tion.jda.api.interactions.components.text.{TextInput, TextInputStyle} +import net.dv8tion.jda.api.interactions.components.ActionRow +import net.dv8tion.jda.api.interactions.modals.Modal + +import scala.jdk.CollectionConverters._ +import java.time.ZonedDateTime + +/** Handles modal submissions (boosted boss-name and galthen tag inputs). + * Moved verbatim from BotListener.onModalInteraction. */ +object ModalHandler { + def handle(event: ModalInteractionEvent): Unit = { + event.deferEdit().queue() + val user = event.getUser + val modalValues = event.getValues.asScala.toList + modalValues.map { element => + val id = element.getId + var inputName = element.getAsString.trim.toLowerCase + val shortName = Map( + "oberon" -> "grand master oberon", + "scarlett" -> "scarlett etzel", + "scarlet" -> "scarlett etzel", + "timira" -> "timira the many-headed", + "timira the many headed" -> "timira the many-headed", + "timira many headed" -> "timira the many-headed", + "timira many-headed" -> "timira the many-headed", + "magma" -> "magma bubble", + "rotten final" -> "bakragore", + "yselda" -> "megasylvan yselda", + "zelos" -> "king zelos", + "despor" -> "dragon pack", + "dragon hoard" -> "dragon pack", + "vengar" -> "dragon pack", + "maliz" -> "dragon pack", + "bruton" -> "dragon pack", + "greedok" -> "dragon pack", + "vilear" -> "dragon pack", + "crultor" -> "dragon pack", + "dragon boss" -> "dragon pack", + "dragon bosses" -> "dragon pack", + "thorn knight" -> "the enraged thorn knight", + "the thorn knight" -> "the enraged thorn knight", + "shielded thorn knight" -> "the enraged thorn knight", + "the shielded thorn knight" -> "the enraged thorn knight", + "mounted thorn knight" -> "the enraged thorn knight", + "the mounted thorn knight" -> "the enraged thorn knight", + "paleworm" -> "the paleworm", + "unwelcome" -> "the unwelcome", + "yirkas" -> "yirkas blue scales", + "vok" -> "vok the feakish", + "irgix" -> "irgix the flimsy", + "unaz" -> "unaz the mean", + "utua" -> "utua stone sting", + "katex" -> "katex blood tongue", + "voidborn" -> "the unarmored voidborn", + "the voidborn" -> "the unarmored voidborn", + "unarmored voidborn" -> "the unarmored voidborn", + "urmahlullu" -> "urmahlullu the weakened", + "winter bloom" -> "the winter bloom", + "time guardian" -> "the time guardian", + "souldespoiler" -> "the souldespoiler", + "scourge of oblivion" -> "the scourge of oblivion", + "lib final" -> "the scourge of oblivion", + "lb final" -> "the scourge of oblivion", + "sandking" -> "the sandking", + "nightmare beast" -> "the nightmare beast", + "moonlight aster" -> "the moonlight aster", + "monster" -> "the monster", + "ingol boss" -> "the monster", + "ingol final" -> "the monster", + "mega magmaoid" -> "the mega magmaoid", + "lily of night" -> "the lily of night", + "flaming orchid" -> "the flaming orchid", + "fear feaster" -> "the fear feaster", + "false god" -> "the false god", + "enraged thorn knight" -> "the enraged thorn knight", + "dread maiden" -> "the dread maiden", + "diamond blossom" -> "the diamond blossom", + "brainstealer" -> "the brainstealer", + "blazing rose" -> "the blazing rose", + "srezz" -> "srezz yellow eyes", + "werelion serpent spawn" -> "srezz yellow eyes", + "werelions serpent spawn" -> "srezz yellow eyes", + "werelion goanna" -> "yirkas blue scales", + "werelions goanna" -> "yirkas blue scales", + "werelion scorpion" -> "utua stone sting", + "werelions scorpion" -> "utua stone sting", + "werelion hyena" -> "katex blood tongue", + "werelions hyena" -> "katex blood tongue", + "werelion hyaena" -> "katex blood tongue", + "werelions hyaena" -> "katex blood tongue", + "werelion werehyena" -> "katex blood tongue", + "werelions werehyena" -> "katex blood tongue", + "werelion werehyaena" -> "katex blood tongue", + "werelions werehyaena" -> "katex blood tongue", + "dragon king" -> "soul of dragonking zyrtarch", + "zyrtarch" -> "soul of dragonking zyrtarch", + "dragonking zyrtarch" -> "soul of dragonking zyrtarch", + "dragon king zyrtarch" -> "soul of dragonking zyrtarch", + "dragonking zyrtarch" -> "soul of dragonking zyrtarch", + "dragonking" -> "soul of dragonking zyrtarch", + "tenebris" -> "lady tenebris", + "ratmiral" -> "ratmiral blackwhiskers", + "plague seal" -> "plagirath", + "pumin seal" -> "tarbaz", + "jugg seal" -> "razzagorn", + "vexclaw seal" -> "shulgrax", + "undead seal" -> "ragiaz" + ) + if (shortName.contains(inputName)) { + inputName = shortName(inputName) + } + if (id == "boosted add") { + val newEmbed = BotApp.boostedService.boosted(user.getId, "add", inputName) + event.getHook().editOriginalEmbeds(newEmbed).setActionRow( + Button.success("boosted add", "Add"), + Button.danger("boosted remove", "Remove"), + Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) + ).queue() + } else if (id == "boosted remove") { + val newEmbed = BotApp.boostedService.boosted(user.getId, "remove", inputName) + event.getHook().editOriginalEmbeds(newEmbed).setActionRow( + Button.success("boosted add", "Add"), + Button.danger("boosted remove", "Remove"), + Button.secondary("boosted toggle", " ").withEmoji(Emoji.fromFormatted(Config.torchOffEmoji)) + ).queue() + } else if (id == "galthen add") { + + val newEmbed = new EmbedBuilder() + val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() + val tagDisplay = element.getAsString.trim.toLowerCase + newEmbed.setColor(3092790) + if (tagDisplay.toLowerCase == user.getName.toLowerCase) { + BotApp.galthenService.add(user.getId, ZonedDateTime.now(), "") + } else { + BotApp.galthenService.add(user.getId, ZonedDateTime.now(), tagDisplay) + } + var editedMessage = "" + var oneRecord = false + val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.galthenService.getStamps(event.getUser.getId) + satchelTimeOption match { + case Some(satchelTimeList) => + val fullList = satchelTimeList.collect { + case satchel => + val when = satchel.when.plusDays(30).toEpochSecond.toString() + val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" + s"${Config.satchelEmoji} can be collected by $displayTag " + } + if (fullList.nonEmpty) { + newEmbed.setTitle("Existing Cooldowns:") + if (fullList.size == 1) { + oneRecord = true + editedMessage = fullList.mkString + } else { + editedMessage = presentation.GalthenEmbeds.truncate(fullList) + } + } + case None => // + } + val replyMessage = s"\n\n${Config.yesEmoji} cooldown tracker for **`$tagDisplay`** has been **added**." + newEmbed.setDescription(editedMessage + replyMessage) + if (oneRecord) { + event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( + Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), + Button.danger("galthenRemoveAll", "Remove") + ).queue() + } else { + event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( + Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), + Button.danger("galthenButtonRem", "Remove"), + Button.secondary("galthenRemoveAll", "Clear All") + ).queue() + } + } else if (id == "galthen rem") { + val newEmbed = new EmbedBuilder() + val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() + val tagDisplay = element.getAsString.trim.toLowerCase + newEmbed.setColor(3092790) + if (tagDisplay.toLowerCase == user.getName.toLowerCase) { + BotApp.galthenService.del(user.getId, "") + } else { + BotApp.galthenService.del(user.getId, tagDisplay) + } + var editedMessage = "" + var oneRecord = false + val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.galthenService.getStamps(event.getUser.getId) + satchelTimeOption match { + case Some(satchelTimeList) => + val fullList = satchelTimeList.collect { + case satchel => + val when = satchel.when.plusDays(30).toEpochSecond.toString() + val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" + s"${Config.satchelEmoji} can be collected by $displayTag " + } + if (fullList.nonEmpty) { + newEmbed.setTitle("Existing Cooldowns:") + if (fullList.size == 1) { + oneRecord = true + editedMessage = fullList.mkString + } else { + editedMessage = presentation.GalthenEmbeds.truncate(fullList) + } + } + case None => // WIP + } + val replyMessage = s"\n\n${Config.yesEmoji} cooldown tracker for **`$tagDisplay`** has been **Disabled**." + newEmbed.setDescription(editedMessage + replyMessage) + if (oneRecord) { + event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( + Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), + Button.danger("galthenRemoveAll", "Remove") + ).queue() + } else { + event.getHook().editOriginalEmbeds(newEmbed.build).setActionRow( + Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), + Button.danger("galthenButtonRem", "Remove"), + Button.secondary("galthenRemoveAll", "Clear All") + ).queue() + } + } + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/interactions/ScreenshotMessageHandler.scala b/tibia-bot/src/main/scala/com/tibiabot/interactions/ScreenshotMessageHandler.scala new file mode 100644 index 0000000..8d2e80d --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/interactions/ScreenshotMessageHandler.scala @@ -0,0 +1,245 @@ +package com.tibiabot.interactions + +import com.tibiabot.{BotApp, Config} +import com.tibiabot.domain.PendingScreenshot +import com.typesafe.scalalogging.StrictLogging +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.emoji.Emoji +import net.dv8tion.jda.api.events.message.MessageReceivedEvent +import net.dv8tion.jda.api.interactions.components.buttons.Button + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ + +/** Handles DM/guild messages for the death-screenshot upload flow. + * Moved verbatim from BotListener.onMessageReceived/handlePrivateMessage; + * the shared pendingScreenshots map is passed in by BotListener. */ +object ScreenshotMessageHandler extends StrictLogging { + + def onMessage(event: MessageReceivedEvent, pendingScreenshots: mutable.Map[String, PendingScreenshot]): Unit = { + // Ignore bot messages + if (!event.getAuthor.isBot) { + // Handle DM messages for screenshot uploads + if (!event.isFromGuild) { + handlePrivate(event, pendingScreenshots) + return + } + + // Handle guild messages for screenshot uploads + if (event.isFromGuild) { + val guild = event.getGuild + val user = event.getAuthor + val pendingKey = s"${user.getId}_${guild.getId}" + + // Check if this user has a pending screenshot request + pendingScreenshots.get(pendingKey) match { + case Some(pending) => + // Check if message has attachments + val attachments = event.getMessage.getAttachments.asScala + val imageAttachments = attachments.filter { attachment => + val fileName = attachment.getFileName.toLowerCase + fileName.endsWith(".png") || fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || + fileName.endsWith(".gif") || fileName.endsWith(".webp") + } + + if (imageAttachments.nonEmpty) { + val attachment = imageAttachments.head + val imageUrl = attachment.getUrl + + // Remove the pending request + pendingScreenshots.remove(pendingKey) + + try { + // Store the screenshot in database + BotApp.storeDeathScreenshot(pending.guildId, pending.world, pending.charName, pending.deathTime, imageUrl, pending.userId, user.getName, pending.messageId) + + // Update the original death message with the screenshot + val channel = guild.getTextChannelById(pending.channelId) + if (channel != null) { + channel.retrieveMessageById(pending.messageId).queue(message => { + val embeds = message.getEmbeds + if (embeds.size() > 0) { + val originalEmbed = embeds.get(0) + val updatedEmbed = new EmbedBuilder(originalEmbed) + + // Get existing screenshots to check if we need navigation buttons + val screenshots = BotApp.getDeathScreenshots(pending.guildId, pending.world, pending.charName, pending.deathTime) + val screenshotCount = screenshots.length + val latestIndex = Math.max(0, screenshotCount - 1) // Show the newest screenshot (last in ASC order) + + // Update embed to show the newest screenshot + val latestScreenshot = if (screenshots.nonEmpty) screenshots.last else null + if (latestScreenshot != null) { + updatedEmbed.setImage(latestScreenshot.screenshotUrl) + .setFooter(s"Screenshot added by ${latestScreenshot.addedName} • ${screenshotCount}/${screenshotCount}") + } else { + updatedEmbed.setImage(imageUrl) + .setFooter(s"Screenshot added by ${user.getName}") + } + + val buttons = if (screenshotCount > 1) { + val baseButtons = List( + Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot"), + Button.primary(s"prev_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "◀"), + Button.secondary(s"screenshot_info_${pending.charName}_${pending.deathTime}_${pending.messageId}", s"${screenshotCount}/${screenshotCount}").asDisabled(), + Button.primary(s"next_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "▶") + ) + if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { + baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") + } else { + baseButtons + } + } else { + val baseButtons = List(Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot")) + if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { + baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") + } else { + baseButtons + } + } + + message.editMessageEmbeds(updatedEmbed.build()).setActionRow(buttons: _*).queue() + + // React to the user's message to confirm, then delete it + event.getMessage.addReaction(Emoji.fromUnicode("✅")).queue(_ => { + event.getMessage.delete().queue() + }) + + logger.info(s"Screenshot uploaded successfully for ${pending.charName} death at ${pending.deathTime}") + } + }) + } + } catch { + case e: Exception => + logger.error(s"Failed to store screenshot: ${e.getMessage}", e) + event.getMessage.addReaction(Emoji.fromUnicode("❌")).queue() + } + } + case None => + // No pending screenshot request for this user + } + } + } + } + + private def handlePrivate(event: MessageReceivedEvent, pendingScreenshots: mutable.Map[String, PendingScreenshot]): Unit = { + val user = event.getAuthor + + // Check if this user has a pending screenshot request for any guild + val userPendingScreenshots = pendingScreenshots.filter(_._1.startsWith(user.getId + "_")).toMap + + if (userPendingScreenshots.nonEmpty) { + // Check if message has attachments + val attachments = event.getMessage.getAttachments.asScala + val imageAttachments = attachments.filter { attachment => + val fileName = attachment.getFileName.toLowerCase + fileName.endsWith(".png") || fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || + fileName.endsWith(".gif") || fileName.endsWith(".webp") + } + + if (imageAttachments.nonEmpty) { + val attachment = imageAttachments.head + val imageUrl = attachment.getUrl + + // Process all pending screenshots for this user (in case they have multiple) + userPendingScreenshots.foreach { case (pendingKey, pending) => + // Remove the pending request + pendingScreenshots.remove(pendingKey) + + try { + // Store the screenshot in database + BotApp.storeDeathScreenshot(pending.guildId, pending.world, pending.charName, pending.deathTime, imageUrl, pending.userId, user.getName, pending.messageId) + + // Update the original death message with the screenshot + val guild = event.getJDA.getGuildById(pending.guildId) + if (guild != null) { + val channel = guild.getTextChannelById(pending.channelId) + if (channel != null) { + channel.retrieveMessageById(pending.messageId).queue(message => { + val embeds = message.getEmbeds + if (embeds.size() > 0) { + val originalEmbed = embeds.get(0) + val updatedEmbed = new EmbedBuilder(originalEmbed) + + // Get existing screenshots to check if we need navigation buttons + val screenshots = BotApp.getDeathScreenshots(pending.guildId, pending.world, pending.charName, pending.deathTime) + val screenshotCount = screenshots.length + val latestIndex = Math.max(0, screenshotCount - 1) // Show the newest screenshot (last in ASC order) + + // Update embed to show the newest screenshot + val latestScreenshot = if (screenshots.nonEmpty) screenshots.last else null + if (latestScreenshot != null) { + updatedEmbed.setImage(latestScreenshot.screenshotUrl) + .setFooter(s"Screenshot added by ${latestScreenshot.addedName} • ${screenshotCount}/${screenshotCount}") + } else { + updatedEmbed.setImage(imageUrl) + .setFooter(s"Screenshot added by ${user.getName}") + } + + val buttons = if (screenshotCount > 1) { + val baseButtons = List( + Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot"), + Button.primary(s"prev_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "◀"), + Button.secondary(s"screenshot_info_${pending.charName}_${pending.deathTime}_${pending.messageId}", s"${screenshotCount}/${screenshotCount}").asDisabled(), + Button.primary(s"next_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "▶") + ) + if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { + baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") + } else { + baseButtons + } + } else { + val baseButtons = List(Button.secondary(s"death_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}", "Add Screenshot")) + if (latestScreenshot != null && latestScreenshot.addedBy == user.getId) { + baseButtons :+ Button.danger(s"delete_screenshot_${pending.charName}_${pending.deathTime}_${pending.messageId}_${latestIndex}", "🗑️") + } else { + baseButtons + } + } + + message.editMessageEmbeds(updatedEmbed.build()).setActionRow(buttons: _*).queue() + + logger.info(s"Screenshot uploaded successfully via DM for ${pending.charName} death at ${pending.deathTime} in guild ${guild.getName}") + } + }) + } + } + + // Send confirmation DM to user + event.getChannel.sendMessage(s"${Config.yesEmoji} Screenshot uploaded successfully for **[${pending.charName}](${BotApp.charUrl(pending.charName)})**.").queue() + + } catch { + case e: Exception => + logger.error(s"Failed to store screenshot from DM: ${e.getMessage}", e) + event.getChannel.sendMessage(s"${Config.noEmoji} Failed to upload screenshot. Please try again.").queue() + } + } + } else { + // Check if user is trying to cancel uploads + val messageContent = event.getMessage.getContentRaw.toLowerCase.trim + if (messageContent.contains("cancel")) { + // Cancel all pending uploads for this user + val cancelledCount = userPendingScreenshots.size + userPendingScreenshots.keys.foreach(pendingScreenshots.remove) + + if (cancelledCount == 1) { + event.getChannel.sendMessage(s"Your pending upload has been cancelled.").queue() + } else if (cancelledCount > 1) { + event.getChannel.sendMessage(s"${cancelledCount} pending uploads have been cancelled.").queue() + } + + logger.info(s"User ${user.getName} (${user.getId}) cancelled ${cancelledCount} pending uploads via DM") + } else { + // User sent a DM but no image attachment and not a cancel command + event.getChannel.sendMessage("Please upload an image file (PNG, JPG, GIF, WebP) or paste an image from your clipboard.\nType `cancel` to cancel any pending upload requests.").queue() + } + } + } else { + // No pending uploads, check if user is asking for help or trying to cancel + val messageContent = event.getMessage.getContentRaw.toLowerCase.trim + if (messageContent.contains("cancel")) { + event.getChannel.sendMessage("You don't have any pending uploads to cancel.").queue() + } + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/ActivityRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/ActivityRepository.scala new file mode 100644 index 0000000..0bd3749 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/ActivityRepository.scala @@ -0,0 +1,18 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.PlayerCache + +import java.time.ZonedDateTime + +/** Persistence port for the per-guild `tracked_activity` table. Keyed by + * guildId; callers pass `guild.getId` so JDA stays in BotApp. */ +trait ActivityRepository { + /** All tracked-activity rows for a guild (creating the table on first use). */ + def getActivity(guildId: String): List[PlayerCache] + /** Upsert a tracked player (ON CONFLICT(name) DO UPDATE). */ + def add(guildId: String, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime): Unit + /** Rename / update a tracked player, retrying past a duplicate-key collision. */ + def update(guildId: String, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime, newName: String): Unit + def removeByName(guildId: String, name: String): Unit + def removeByGuild(guildId: String, guildName: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/BoostedRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/BoostedRepository.scala new file mode 100644 index 0000000..b9a66a4 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/BoostedRepository.scala @@ -0,0 +1,15 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.BoostedStamp + +/** Persistence port for boosted-boss/creature notification subscriptions + * (the `boosted_notifications` table in bot_cache), keyed by Discord userId. */ +trait BoostedRepository { + /** All subscriptions across all users. */ + def all(): List[BoostedStamp] + /** A single user's subscriptions. */ + def forUser(userId: String): List[BoostedStamp] + def subscribe(userId: String, name: String, boostedType: String): Unit + def unsubscribe(userId: String, name: String): Unit + def unsubscribeAll(userId: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/CacheRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/CacheRepository.scala new file mode 100644 index 0000000..a2dc0fb --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/CacheRepository.scala @@ -0,0 +1,30 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.{BoostedCache, DeathsCache, LevelsCache, ListCache} + +import java.time.ZonedDateTime + +/** Persistence port for the shared `bot_cache` database, covering the `deaths`, + * `levels`, `list` and `boosted_info` caches. */ +trait CacheRepository { + def getDeaths(world: String): List[DeathsCache] + def addDeath(world: String, name: String, time: String): Unit + /** Delete death rows older than 30 minutes relative to `now`. */ + def removeExpiredDeaths(now: ZonedDateTime): Unit + + def getLevels(world: String): List[LevelsCache] + def addLevel(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String): Unit + /** Delete level rows older than 25 hours relative to `now`. */ + def removeExpiredLevels(now: ZonedDateTime): Unit + + def getList(world: String): List[ListCache] + def addToList(name: String, formerNames: List[String], world: String, formerWorlds: List[String], + guild: String, level: String, vocation: String, lastLogin: String, updatedTime: ZonedDateTime): Unit + /** Delete list rows older than 7 days relative to `now`. */ + def removeExpiredList(now: ZonedDateTime): Unit + + /** Read the boosted boss/creature row (creating the table + default row if needed). */ + def getBoosted(): List[BoostedCache] + /** Update boosted fields; empty-string arguments are left unchanged. */ + def updateBoosted(boss: String, creature: String, bossChanged: String, creatureChanged: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala new file mode 100644 index 0000000..009a17f --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala @@ -0,0 +1,18 @@ +package com.tibiabot.persistence + +import java.sql.Connection + +/** Port for obtaining JDBC connections, isolating the database-per-guild URL + * juggling from the many call sites that open connections. Lets repositories + * be pointed at a Dockerized Postgres in tests, and is the single seam where + * the injection-prone SQL gets fixed later without touching callers. */ +trait ConnectionProvider { + /** Connection to a guild's own database (`_`). */ + def guild(guildId: String): Connection + /** Connection to the shared `bot_cache` database. */ + def cache(): Connection + /** Maintenance connection to the default `postgres` database. */ + def admin(): Connection + /** Connection to the `premium` database. */ + def premium(): Connection +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/CustomSortRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/CustomSortRepository.scala new file mode 100644 index 0000000..ccc70ed --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/CustomSortRepository.scala @@ -0,0 +1,13 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.CustomSort + +/** Persistence port for the per-guild `online_list_categories` table (custom + * online-list sort categories). Keyed by guildId. */ +trait CustomSortRepository { + /** All categories (creating the table on first use). */ + def getAll(guildId: String): List[CustomSort] + def add(guildId: String, entity: String, name: String, label: String, emoji: String): Unit + def removeByNameEntity(guildId: String, entity: String, name: String): Unit + def removeByLabel(guildId: String, label: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/DeathScreenshotRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/DeathScreenshotRepository.scala new file mode 100644 index 0000000..833d2e4 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/DeathScreenshotRepository.scala @@ -0,0 +1,18 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.DeathScreenshot + +/** Persistence port for death-screenshot records (the `death_screenshots` table + * in each guild's own database). */ +trait DeathScreenshotRepository { + def store(guildId: String, world: String, characterName: String, deathTime: Long, + screenshotUrl: String, addedBy: String, addedName: String, messageId: String): Unit + + def get(guildId: String, world: String, characterName: String, deathTime: Long): List[DeathScreenshot] + + /** Delete the matching screenshot only if `permitted(addedBy)` holds. The + * permission decision (e.g. owner-or-admin) is supplied by the caller so JDA + * stays out of persistence. Returns true if a row was deleted. */ + def deleteIfPermitted(guildId: String, characterName: String, deathTime: Long, screenshotUrl: String) + (permitted: String => Boolean): Boolean +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/DiscordConfigRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/DiscordConfigRepository.scala new file mode 100644 index 0000000..43a2ca2 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/DiscordConfigRepository.scala @@ -0,0 +1,16 @@ +package com.tibiabot.persistence + +import java.time.ZonedDateTime + +/** Persistence port for the per-guild `discord_info` table (admin channel/category, + * boosted channel/message, last world). Keyed by guildId. */ +trait DiscordConfigRepository { + /** Read the guild's discord config as a column->value map (migrating columns). */ + def getConfig(guildId: String): Map[String, String] + /** Upsert the guild's discord config. */ + def create(guildId: String, guildName: String, guildOwner: String, adminCategory: String, + adminChannel: String, boostedChannel: String, boostedMessageId: String, created: ZonedDateTime): Unit + /** Conditionally update individual fields (empty-string args are left unchanged). */ + def update(guildId: String, adminCategory: String, adminChannel: String, boostedChannel: String, + boostedMessage: String, lastWorld: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/GalthenRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/GalthenRepository.scala new file mode 100644 index 0000000..89f6afd --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/GalthenRepository.scala @@ -0,0 +1,18 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.SatchelStamp + +import java.time.ZonedDateTime + +/** Persistence port for Galthen's Satchel cooldown stamps (the `satchel` table + * in the `bot_cache` database). */ +trait GalthenRepository { + /** All stamps for a user (creating the table on first use). */ + def getStamps(userId: String): Option[List[SatchelStamp]] + /** Insert or update the stamp for (user, tag). */ + def add(user: String, when: ZonedDateTime, tag: String): Unit + /** Delete the stamp for (user, tag). */ + def del(user: String, tag: String): Unit + /** Delete all stamps for a user. */ + def delAll(user: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/HuntedAlliedRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/HuntedAlliedRepository.scala new file mode 100644 index 0000000..49004ba --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/HuntedAlliedRepository.scala @@ -0,0 +1,17 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.{Guilds, Players} + +/** Persistence port for the per-guild hunted/allied lists + * (hunted_players, allied_players, hunted_guilds, allied_guilds). Keyed by + * guildId; the option/table strings are chosen by the caller as today. */ +trait HuntedAlliedRepository { + def getPlayers(guildId: String, table: String): List[Players] + def getGuilds(guildId: String, table: String): List[Guilds] + def addHunted(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit + def addAllied(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit + def removeHunted(guildId: String, option: String, name: String): Unit + def removeAllied(guildId: String, option: String, name: String): Unit + /** Rename a hunted/allied player, retrying past a duplicate-key collision. */ + def rename(guildId: String, option: String, oldName: String, newName: String): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcConnectionProvider.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcConnectionProvider.scala new file mode 100644 index 0000000..72f422d --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcConnectionProvider.scala @@ -0,0 +1,16 @@ +package com.tibiabot.persistence + +import java.sql.{Connection, DriverManager} + +/** DriverManager-backed ConnectionProvider. Reproduces today's exact URL + * shapes, username and password — no behaviour change. */ +final class JdbcConnectionProvider(host: String, password: String, user: String = "postgres") + extends ConnectionProvider { + + def guild(guildId: String): Connection = open(JdbcUrls.guild(host, guildId)) + def cache(): Connection = open(JdbcUrls.cache(host)) + def admin(): Connection = open(JdbcUrls.admin(host)) + def premium(): Connection = open(JdbcUrls.premium(host)) + + private def open(url: String): Connection = DriverManager.getConnection(url, user, password) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala new file mode 100644 index 0000000..45e3366 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala @@ -0,0 +1,20 @@ +package com.tibiabot.persistence + +/** Pure builders for the four JDBC URL shapes the bot uses, extracted so URL + * construction is unit-testable without a database. Strings reproduce the + * originals in BotApp verbatim. */ +object JdbcUrls { + private def base(host: String): String = s"jdbc:postgresql://$host:5432" + + /** Per-guild database, named `_`. */ + def guild(host: String, guildId: String): String = s"${base(host)}/_$guildId" + + /** Shared cache database (`bot_cache`). */ + def cache(host: String): String = s"${base(host)}/bot_cache" + + /** Maintenance connection (the default `postgres` database). */ + def admin(host: String): String = s"${base(host)}/postgres" + + /** Premium database. */ + def premium(host: String): String = s"${base(host)}/premium" +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala new file mode 100644 index 0000000..2d07391 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala @@ -0,0 +1,253 @@ +package com.tibiabot.persistence + +import com.typesafe.scalalogging.StrictLogging + +/** Creates the bot's databases and tables at startup / on guild join. Bodies + * moved verbatim from BotApp's checkConfigDatabase/createPremiumDatabase/ + * createCacheDatabase/createConfigDatabase, with the Guild parameter reduced to + * guildId/guildName. Behaviour preserved exactly (including the pre-existing + * quirk that initPremium creates 'bot_cache'). */ +final class SchemaInitializer(connectionProvider: ConnectionProvider) extends StrictLogging { + + def guildDatabaseExists(guildId: String): Boolean = { + val conn = connectionProvider.admin() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + val exist = result.next() + + statement.close() + conn.close() + + // check if database for discord exists + if (exist) { + true + } else { + false + } + } + + def initPremium(): Unit = { + val conn = connectionProvider.admin() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'premium'") + val exist = result.next() + + // if bot_configuration doesn't exist + if (!exist) { + statement.executeUpdate(s"CREATE DATABASE bot_cache;") + logger.info(s"Database 'bot_cache' created successfully") + statement.close() + conn.close() + + val newConn = connectionProvider.premium() + val newStatement = newConn.createStatement() + // create the tables in bot_configuration + val createPaymentsTable = + s"""CREATE TABLE payments ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |discord_id VARCHAR(255) NOT NULL, + |discord_name VARCHAR(255) NOT NULL, + |user_id VARCHAR(255) NOT NULL, + |user_name VARCHAR(255) NOT NULL, + |expiry VARCHAR(255) NOT NULL + |);""".stripMargin + + newStatement.executeUpdate(createPaymentsTable) + logger.info("Table 'payments' created successfully") + newStatement.close() + newConn.close() + } else { + statement.close() + conn.close() + } + } + + def initCache(): Unit = { + val conn = connectionProvider.admin() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'bot_cache'") + val exist = result.next() + + // if bot_configuration doesn't exist + if (!exist) { + statement.executeUpdate(s"CREATE DATABASE bot_cache;") + logger.info(s"Database 'bot_cache' created successfully") + statement.close() + conn.close() + + val newConn = connectionProvider.cache() + val newStatement = newConn.createStatement() + // create the tables in bot_configuration + val createDeathsTable = + s"""CREATE TABLE deaths ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createLevelsTable = + s"""CREATE TABLE levels ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |level VARCHAR(255) NOT NULL, + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createListTable = + s"""CREATE TABLE list ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |former_worlds VARCHAR(255), + |name VARCHAR(255) NOT NULL, + |former_names VARCHAR(1000), + |level VARCHAR(255) NOT NULL, + |guild_name VARCHAR(255), + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createGalthenTable = + s"""CREATE TABLE satchel ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |userid VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL, + |tag VARCHAR(255) + |);""".stripMargin + + newStatement.executeUpdate(createDeathsTable) + logger.info("Table 'deaths' created successfully") + newStatement.executeUpdate(createLevelsTable) + logger.info("Table 'levels' created successfully") + newStatement.executeUpdate(createListTable) + logger.info("Table 'list' created successfully") + newStatement.executeUpdate(createGalthenTable) + logger.info("Table 'galthen' created successfully") + newStatement.close() + newConn.close() + } else { + statement.close() + conn.close() + } + } + + def initGuild(guildId: String, guildName: String): Unit = { + val conn = connectionProvider.admin() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + val exist = result.next() + + // if bot_configuration doesn't exist + if (!exist) { + statement.executeUpdate(s"CREATE DATABASE _$guildId;") + logger.info(s"Database '$guildId' for discord '$guildName' created successfully") + statement.close() + conn.close() + + val newConn = connectionProvider.guild(guildId) + val newStatement = newConn.createStatement() + // create the tables in bot_configuration + val createDiscordInfoTable = + s"""CREATE TABLE discord_info ( + |guild_name VARCHAR(255) NOT NULL, + |guild_owner VARCHAR(255) NOT NULL, + |admin_category VARCHAR(255) NOT NULL, + |admin_channel VARCHAR(255) NOT NULL, + |boosted_channel VARCHAR(255) NOT NULL, + |boosted_messageid VARCHAR(255) NOT NULL, + |flags VARCHAR(255) NOT NULL, + |created TIMESTAMP NOT NULL, + |PRIMARY KEY (guild_name) + |);""".stripMargin + + val createHuntedPlayersTable = + s"""CREATE TABLE hunted_players ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin + + val createHuntedGuildsTable = + s"""CREATE TABLE hunted_guilds ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin + + val createAlliedPlayersTable = + s"""CREATE TABLE allied_players ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin + + val createAlliedGuildsTable = + s"""CREATE TABLE allied_guilds ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin + + val createWorldsTable = + s"""CREATE TABLE worlds ( + |name VARCHAR(255) NOT NULL, + |allies_channel VARCHAR(255) NOT NULL, + |enemies_channel VARCHAR(255) NOT NULL, + |neutrals_channel VARCHAR(255) NOT NULL, + |levels_channel VARCHAR(255) NOT NULL, + |deaths_channel VARCHAR(255) NOT NULL, + |category VARCHAR(255) NOT NULL, + |fullbless_role VARCHAR(255) NOT NULL, + |nemesis_role VARCHAR(255) NOT NULL, + |allypk_role VARCHAR(255) NOT NULL, + |masslog_role VARCHAR(255) NOT NULL, + |fullbless_channel VARCHAR(255) NOT NULL, + |nemesis_channel VARCHAR(255) NOT NULL, + |fullbless_level INT NOT NULL, + |show_neutral_levels VARCHAR(255) NOT NULL, + |show_neutral_deaths VARCHAR(255) NOT NULL, + |show_allies_levels VARCHAR(255) NOT NULL, + |show_allies_deaths VARCHAR(255) NOT NULL, + |show_enemies_levels VARCHAR(255) NOT NULL, + |show_enemies_deaths VARCHAR(255) NOT NULL, + |detect_hunteds VARCHAR(255) NOT NULL, + |levels_min INT NOT NULL, + |deaths_min INT NOT NULL, + |exiva_list VARCHAR(255) NOT NULL, + |online_combined VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin + + newStatement.executeUpdate(createDiscordInfoTable) + logger.info("Table 'discord_info' created successfully") + newStatement.executeUpdate(createHuntedPlayersTable) + logger.info("Table 'hunted_players' created successfully") + newStatement.executeUpdate(createHuntedGuildsTable) + logger.info("Table 'hunted_guilds' created successfully") + newStatement.executeUpdate(createAlliedPlayersTable) + logger.info("Table 'allied_players' created successfully") + newStatement.executeUpdate(createAlliedGuildsTable) + logger.info("Table 'allied_guilds' created successfully") + newStatement.executeUpdate(createWorldsTable) + logger.info("Table 'worlds' created successfully") + newStatement.close() + newConn.close() + } else { + logger.info(s"Database '$guildId' already exists") + statement.close() + conn.close() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/WorldConfigRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/WorldConfigRepository.scala new file mode 100644 index 0000000..7c8b412 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/WorldConfigRepository.scala @@ -0,0 +1,23 @@ +package com.tibiabot.persistence + +import com.tibiabot.domain.Worlds + +/** Persistence port for the per-guild `worlds` table (per-world tracking config). */ +trait WorldConfigRepository { + /** All configured worlds (migrating missing columns, filtering merged worlds). */ + def listWorlds(guildId: String): List[Worlds] + /** Upsert a world's initial config (defaults applied as today). */ + def createWorld(guildId: String, world: String, alliesChannel: String, enemiesChannel: String, + neutralsChannels: String, levelsChannel: String, deathsChannel: String, category: String, + fullblessRole: String, nemesisRole: String, allyPkRole: String, masslogRole: String, + fullblessChannel: String, nemesisChannel: String, activityChannel: String): Unit + /** Retrieve a single world's config as a column->value map. */ + def retrieveWorld(guildId: String, world: String): Map[String, String] + def removeWorld(guildId: String, world: String): Unit + + /** Update a string column for a world. `column` is chosen by calling code + * (not user input); the caller supplies the already-formatted world name. */ + def updateWorldString(guildId: String, world: String, column: String, value: String): Unit + /** Update an integer column for a world. */ + def updateWorldInt(guildId: String, world: String, column: String, value: Int): Unit +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala new file mode 100644 index 0000000..d9f42f9 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala @@ -0,0 +1,135 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.PlayerCache +import com.tibiabot.persistence.{ActivityRepository, ConnectionProvider} +import org.postgresql.util.PSQLException + +import java.sql.Timestamp +import java.time.{Instant, ZoneOffset, ZonedDateTime} +import scala.collection.mutable.ListBuffer + +/** JDBC implementation of ActivityRepository. Bodies moved verbatim from BotApp's + * activityConfig/addActivityToDatabase/updateActivityToDatabase/ + * removePlayerActivityfromDatabase/removeGuildActivityfromDatabase, with the + * Guild parameter reduced to guildId. */ +final class JdbcActivityRepository(connectionProvider: ConnectionProvider) extends ActivityRepository { + + def getActivity(guildId: String): List[PlayerCache] = { + val conn = connectionProvider.guild(guildId) + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'tracked_activity'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createActivityTable = + s"""CREATE TABLE tracked_activity ( + |name VARCHAR(255) NOT NULL, + |former_names VARCHAR(255) NOT NULL, + |guild_name VARCHAR(255) NOT NULL, + |updated TIMESTAMP NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin + + statement.executeUpdate(createActivityTable) + } + + val result = statement.executeQuery(s"SELECT name,former_names,guild_name,updated FROM tracked_activity") + + val results = new ListBuffer[PlayerCache]() + while (result.next()) { + val name = Option(result.getString("name")).getOrElse("") + val formerNames = Option(result.getString("former_names")).getOrElse("") + val guildName = Option(result.getString("guild_name")).getOrElse("") + val formerNamesList = formerNames.split(",").toList + val updatedTimeTemporal = Option(result.getTimestamp("updated").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z")) + val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) + + results += PlayerCache(name, formerNamesList, guildName, updatedTime) + } + + statement.close() + conn.close() + results.toList + } + + def add(guildId: String, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement( + s""" + |INSERT INTO tracked_activity(name, former_names, guild_name, updated) + |VALUES (?,?,?,?) + |ON CONFLICT (name) + |DO UPDATE SET + | former_names = excluded.former_names, + | guild_name = excluded.guild_name, + | updated = excluded.updated; + |""".stripMargin + ) + statement.setString(1, name) + statement.setString(2, formerNames.mkString(",")) + statement.setString(3, guildName) + statement.setTimestamp(4, Timestamp.from(updatedTime.toInstant)) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def update(guildId: String, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime, newName: String): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement("UPDATE tracked_activity SET name = ?, former_names = ?, guild_name = ?, updated = ? WHERE LOWER(name) = LOWER(?);") + statement.setString(1, newName) + statement.setString(2, formerNames.mkString(",")) + statement.setString(3, guildName) + statement.setTimestamp(4, Timestamp.from(updatedTime.toInstant)) + statement.setString(5, name) + + try { + statement.executeUpdate() + } catch { + case e: PSQLException if e.getMessage.contains("duplicate key value") => + val deleteStatement = conn.prepareStatement("DELETE FROM tracked_activity WHERE LOWER(name) = LOWER(?);") + deleteStatement.setString(1, newName) + deleteStatement.executeUpdate() + deleteStatement.close() + + // Retry the update + val retryStatement = conn.prepareStatement("UPDATE tracked_activity SET name = ?, former_names = ?, guild_name = ?, updated = ? WHERE LOWER(name) = LOWER(?);") + retryStatement.setString(1, newName) + retryStatement.setString(2, formerNames.mkString(",")) + retryStatement.setString(3, guildName) + retryStatement.setTimestamp(4, Timestamp.from(updatedTime.toInstant)) + retryStatement.setString(5, name) + retryStatement.executeUpdate() + retryStatement.close() + } finally { + statement.close() + conn.close() + } + } + + def removeByGuild(guildId: String, guildName: String): Unit = { + val conn = connectionProvider.guild(guildId) + + val statement = conn.prepareStatement(s"DELETE FROM tracked_activity WHERE LOWER(guild_name) = LOWER(?);") + statement.setString(1, guildName) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def removeByName(guildId: String, name: String): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement(s"DELETE FROM tracked_activity WHERE LOWER(name) = LOWER(?);") + statement.setString(1, name) + statement.executeUpdate() + + statement.close() + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala new file mode 100644 index 0000000..6a97ebc --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala @@ -0,0 +1,108 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.BoostedStamp +import com.tibiabot.persistence.{BoostedRepository, ConnectionProvider} + +import scala.collection.mutable.ListBuffer + +/** JDBC implementation of BoostedRepository. Read bodies moved verbatim from + * BotApp's boostedAll/boostedList; the subscribe/unsubscribe SQL matches the + * inline statements in BotApp.boosted. The table is created on first use, as + * the originals did. */ +final class JdbcBoostedRepository(connectionProvider: ConnectionProvider) extends BoostedRepository { + + private def ensureTable(statement: java.sql.Statement): Unit = { + val tableExistsQuery = + statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_notifications'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + if (!tableExists) { + val createListTable = + s"""CREATE TABLE boosted_notifications ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |userid VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |type VARCHAR(255), + |CONSTRAINT unique_user_name_constraint UNIQUE (userid, name) + |);""".stripMargin + statement.executeUpdate(createListTable) + } + } + + def all(): List[BoostedStamp] = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + ensureTable(statement) + + val result = statement.executeQuery(s"SELECT userid,name,type FROM boosted_notifications;") + val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() + + while (result.next()) { + val boostedUserSql = Option(result.getString("userid")).getOrElse("") + val boostedNameSql = Option(result.getString("name")).getOrElse("") + val boostedTypeSql = Option(result.getString("type")).getOrElse("") + + val boostedStamp = BoostedStamp(boostedUserSql, boostedTypeSql, boostedNameSql) + boostedStampList += boostedStamp + } + + statement.close() + conn.close() + boostedStampList.toList + } + + def forUser(userId: String): List[BoostedStamp] = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + ensureTable(statement) + + val result = statement.executeQuery(s"SELECT name,type FROM boosted_notifications WHERE userid = '$userId';") + val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() + + while (result.next()) { + val boostedNameSql = Option(result.getString("name")).getOrElse("") + val boostedTypeSql = Option(result.getString("type")).getOrElse("") + + val boostedStamp = BoostedStamp(userId, boostedTypeSql, boostedNameSql) + boostedStampList += boostedStamp + } + + statement.close() + conn.close() + boostedStampList.toList + } + + def subscribe(userId: String, name: String, boostedType: String): Unit = { + val conn = connectionProvider.cache() + val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() + val statement = conn.prepareStatement( + "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING") + statement.setString(1, userId) + statement.setString(2, name) + statement.setString(3, boostedType) + statement.executeUpdate() + statement.close() + conn.close() + } + + def unsubscribe(userId: String, name: String): Unit = { + val conn = connectionProvider.cache() + val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() + val statement = conn.prepareStatement("DELETE FROM boosted_notifications WHERE userid = ? AND LOWER(name) = LOWER(?)") + statement.setString(1, userId) + statement.setString(2, name) + statement.executeUpdate() + statement.close() + conn.close() + } + + def unsubscribeAll(userId: String): Unit = { + val conn = connectionProvider.cache() + val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() + val statement = conn.prepareStatement("DELETE FROM boosted_notifications WHERE userid = ?") + statement.setString(1, userId) + statement.executeUpdate() + statement.close() + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala new file mode 100644 index 0000000..1093c13 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala @@ -0,0 +1,365 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.{BoostedCache, DeathsCache, LevelsCache, ListCache} +import com.tibiabot.persistence.{CacheRepository, ConnectionProvider} + +import java.sql.Timestamp +import java.time.temporal.ChronoUnit +import java.time.{Instant, ZoneOffset, ZonedDateTime} +import scala.collection.mutable.ListBuffer + +/** JDBC implementation of CacheRepository (deaths + levels). Bodies moved + * verbatim from BotApp's getDeathsCache/addDeathsCache/removeDeathsCache and + * the levels equivalents — no behaviour change. */ +final class JdbcCacheRepository(connectionProvider: ConnectionProvider) extends CacheRepository { + + def getDeaths(world: String): List[DeathsCache] = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT world,name,time FROM deaths WHERE world = '$world';") + + val results = new ListBuffer[DeathsCache]() + while (result.next()) { + val world = Option(result.getString("world")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val time = Option(result.getString("time")).getOrElse("") + results += DeathsCache(world, name, time) + } + + statement.close() + conn.close() + results.toList + } + + def addDeath(world: String, name: String, time: String): Unit = { + val conn = connectionProvider.cache() + val statement = conn.prepareStatement("INSERT INTO deaths(world,name,time) VALUES (?, ?, ?);") + statement.setString(1, world) + statement.setString(2, name) + statement.setString(3, time) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def removeExpiredDeaths(now: ZonedDateTime): Unit = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT id,time from deaths;") + val results = new ListBuffer[Long]() + while (result.next()) { + val id = Option(result.getLong("id")).getOrElse(0L) + val timeDb = Option(result.getString("time")).getOrElse("") + val timeToDate = ZonedDateTime.parse(timeDb) + if (now.isAfter(timeToDate.plusMinutes(30)) && id != 0L) { + results += id + } + } + results.foreach { uid => + statement.executeUpdate(s"DELETE from deaths where id = $uid;") + } + statement.close() + conn.close() + } + + def getLevels(world: String): List[LevelsCache] = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT world,name,level,vocation,last_login,time FROM levels WHERE world = '$world';") + + val results = new ListBuffer[LevelsCache]() + while (result.next()) { + val world = Option(result.getString("world")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val level = Option(result.getString("level")).getOrElse("") + val vocation = Option(result.getString("vocation")).getOrElse("") + val lastLogin = Option(result.getString("last_login")).getOrElse("") + val time = Option(result.getString("time")).getOrElse("") + results += LevelsCache(world, name, level, vocation, lastLogin, time) + } + + statement.close() + conn.close() + results.toList + } + + def addLevel(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String): Unit = { + val conn = connectionProvider.cache() + val statement = conn.prepareStatement("INSERT INTO levels(world,name,level,vocation,last_login,time) VALUES (?, ?, ?, ?, ?, ?);") + statement.setString(1, world) + statement.setString(2, name) + statement.setString(3, level) + statement.setString(4, vocation) + statement.setString(5, lastLogin) + statement.setString(6, time) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def removeExpiredLevels(now: ZonedDateTime): Unit = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT id,time from levels;") + val results = new ListBuffer[Long]() + while (result.next()) { + val id = Option(result.getLong("id")).getOrElse(0L) + val timeDb = Option(result.getString("time")).getOrElse("") + val timeToDate = ZonedDateTime.parse(timeDb) + if (now.isAfter(timeToDate.plusHours(25)) && id != 0L) { + results += id + } + } + results.foreach { uid => + statement.executeUpdate(s"DELETE from levels where id = $uid;") + } + statement.close() + conn.close() + } + + def getList(world: String): List[ListCache] = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'list'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createListTable = + s"""CREATE TABLE list ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |former_worlds VARCHAR(255), + |name VARCHAR(255) NOT NULL, + |former_names VARCHAR(1000), + |level VARCHAR(255) NOT NULL, + |guild_name VARCHAR(255), + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + statement.executeUpdate(createListTable) + } + + val result = statement.executeQuery(s"SELECT name,former_names,world,former_worlds,guild_name,level,vocation,last_login,time FROM list WHERE world = '$world';") + + val results = new ListBuffer[ListCache]() + while (result.next()) { + + val guildName = Option(result.getString("guild_name")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val formerNames = Option(result.getString("former_names")).getOrElse("") + val formerNamesList = formerNames.split(",").toList + val world = Option(result.getString("world")).getOrElse("") + val formerWorlds = Option(result.getString("former_worlds")).getOrElse("") + val formerWorldsList = formerWorlds.split(",").toList + val level = Option(result.getString("level")).getOrElse("") + val vocation = Option(result.getString("vocation")).getOrElse("") + val lastLogin = Option(result.getString("last_login")).getOrElse("") + val updatedTimeTemporal = Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z")) + val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) + + results += ListCache(name, formerNamesList, world, formerWorldsList, guildName, level, vocation, lastLogin, updatedTime) + } + + statement.close() + conn.close() + results.toList + } + + def addToList(name: String, formerNames: List[String], world: String, formerWorlds: List[String], + guild: String, level: String, vocation: String, lastLogin: String, updatedTime: ZonedDateTime): Unit = { + val conn = connectionProvider.cache() + val selectStatement = conn.prepareStatement("SELECT name FROM list WHERE LOWER(name) = LOWER(?);") + selectStatement.setString(1, name) + val resultSet = selectStatement.executeQuery() + + if (resultSet.next()) { + // Update existing row + val updateStatement = conn.prepareStatement( + s""" + |UPDATE list + |SET former_names = ?, world = ?, former_worlds = ?, guild_name = ?, level = ?, vocation = ?, last_login = ?, time = ? + |WHERE LOWER(name) = LOWER(?); + |""".stripMargin + ) + updateStatement.setString(1, formerNames.mkString(",")) + updateStatement.setString(2, world.capitalize) + updateStatement.setString(3, formerWorlds.mkString(",")) + updateStatement.setString(4, guild) + updateStatement.setString(5, level) + updateStatement.setString(6, vocation) + updateStatement.setString(7, lastLogin) + updateStatement.setTimestamp(8, Timestamp.from(updatedTime.toInstant)) + updateStatement.setString(9, name) + updateStatement.executeUpdate() + updateStatement.close() + } else { + // Insert new row + val insertStatement = conn.prepareStatement( + s""" + |INSERT INTO list(name, former_names, world, former_worlds, guild_name, level, vocation, last_login, time) + |VALUES (?,?,?,?,?,?,?,?,?); + |""".stripMargin + ) + insertStatement.setString(1, name) + insertStatement.setString(2, formerNames.mkString(",")) + insertStatement.setString(3, world.capitalize) + insertStatement.setString(4, formerWorlds.mkString(",")) + insertStatement.setString(5, guild) + insertStatement.setString(6, level) + insertStatement.setString(7, vocation) + insertStatement.setString(8, lastLogin) + insertStatement.setTimestamp(9, Timestamp.from(updatedTime.toInstant)) + insertStatement.executeUpdate() + insertStatement.close() + } + + selectStatement.close() + conn.close() + } + + def removeExpiredList(now: ZonedDateTime): Unit = { + val conn = connectionProvider.cache() + + // Modify the DELETE statement to include a WHERE clause with the condition for time + val deleteStatement = conn.prepareStatement("DELETE FROM list WHERE time < ?;") + deleteStatement.setTimestamp(1, Timestamp.from(now.minus(7, ChronoUnit.DAYS).toInstant)) + deleteStatement.executeUpdate() + deleteStatement.close() + conn.close() + } + + def getBoosted(): List[BoostedCache] = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + + val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_info'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createListTable = + s"""CREATE TABLE boosted_info ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |boss VARCHAR(255) NOT NULL, + |bosschanged VARCHAR(255) NOT NULL, + |creature VARCHAR(255) NOT NULL, + |creaturechanged VARCHAR(255) NOT NULL + );""".stripMargin + + statement.executeUpdate(createListTable) + } + + // Check if the column already exists in the table + val bossChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'bosschanged'") + val bossChangedExists = bossChangedExistsQuery.next() + bossChangedExistsQuery.close() + + // Check if the column already exists in the table + val creatureChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'creaturechanged'") + val creatureChangedExists = creatureChangedExistsQuery.next() + creatureChangedExistsQuery.close() + + // Add the column if it doesn't exist + if (!bossChangedExists) { + statement.execute("ALTER TABLE boosted_info ADD COLUMN bosschanged VARCHAR(255) DEFAULT '0'") + } + + // Add the column if it doesn't exist + if (!creatureChangedExists) { + statement.execute("ALTER TABLE boosted_info ADD COLUMN creaturechanged VARCHAR(255) DEFAULT '0'") + } + + val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") + val results = new ListBuffer[BoostedCache]() + while (result.next()) { + val boss = Option(result.getString("boss")).getOrElse("None") + val creature = Option(result.getString("creature")).getOrElse("None") + val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") + val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") + results += BoostedCache(boss, creature, bossChanged, creatureChanged) + } + + if (results.isEmpty) { + // If the result list is empty, insert default values + val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") + insertStatement.setString(1, "None") // Default value for boss + insertStatement.setString(2, "None") // Default value for creature + insertStatement.setString(3, "0") + insertStatement.setString(4, "0") + insertStatement.executeUpdate() + insertStatement.close() + + results += BoostedCache("None", "None", "0", "0") + } + + statement.close() + conn.close() + results.toList + } + + def updateBoosted(boss: String, creature: String, bossChanged: String, creatureChanged: String): Unit = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + + val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") + + val results = new ListBuffer[BoostedCache]() + while (result.next()) { + val boss = Option(result.getString("boss")).getOrElse("None") + val creature = Option(result.getString("creature")).getOrElse("None") + val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") + val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") + + results += BoostedCache(boss, creature, bossChanged, creatureChanged) + } + statement.close() + + if (results.isEmpty) { + // If the result list is empty, insert default values + val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") + insertStatement.setString(1, "None") // Default value for boss + insertStatement.setString(2, "None") // Default value for creature + insertStatement.setString(3, "0") + insertStatement.setString(4, "0") + insertStatement.executeUpdate() + insertStatement.close() + } + + // update category if exists + if (boss != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET boss = ?;") + statement.setString(1, boss) + statement.executeUpdate() + statement.close() + } + if (creature != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET creature = ?;") + statement.setString(1, creature) + statement.executeUpdate() + statement.close() + } + if (bossChanged != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET bosschanged = ?;") + statement.setString(1, bossChanged) + statement.executeUpdate() + statement.close() + } + if (creatureChanged != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET creaturechanged = ?;") + statement.setString(1, creatureChanged) + statement.executeUpdate() + statement.close() + } + + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala new file mode 100644 index 0000000..1cc8dbc --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala @@ -0,0 +1,90 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.CustomSort +import com.tibiabot.persistence.{ConnectionProvider, CustomSortRepository} + +import java.time.ZonedDateTime +import scala.collection.mutable.ListBuffer + +/** JDBC implementation of CustomSortRepository. Bodies moved verbatim from + * BotApp's customSortConfig and the *OnlineListCategory*ToDatabase methods, + * with the Guild parameter reduced to guildId. */ +final class JdbcCustomSortRepository(connectionProvider: ConnectionProvider) extends CustomSortRepository { + + def getAll(guildId: String): List[CustomSort] = { + val conn = connectionProvider.guild(guildId) + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'online_list_categories'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createCustomSortTable = + s"""CREATE TABLE online_list_categories ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |entity VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |label VARCHAR(255) NOT NULL, + |emoji VARCHAR(255) NOT NULL, + |added VARCHAR(255) NOT NULL + |);""".stripMargin + + statement.executeUpdate(createCustomSortTable) + } + + val result = statement.executeQuery(s"SELECT entity,name,label,emoji FROM online_list_categories") + + val results = new ListBuffer[CustomSort]() + while (result.next()) { + val entity = Option(result.getString("entity")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val label = Option(result.getString("label")).getOrElse("") + val emoji = Option(result.getString("emoji")).getOrElse("") + + results += CustomSort(entity, name, label, emoji) + } + + statement.close() + conn.close() + results.toList + } + + def add(guildId: String, entity: String, name: String, label: String, emoji: String): Unit = { + val conn = connectionProvider.guild(guildId) + val query = "INSERT INTO online_list_categories(entity, name, label, emoji, added) VALUES (?, ?, ?, ?, ?);" + val statement = conn.prepareStatement(query) + statement.setString(1, entity) + statement.setString(2, name) + statement.setString(3, label) + statement.setString(4, emoji) + statement.setString(5, ZonedDateTime.now().toEpochSecond().toString) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def removeByNameEntity(guildId: String, entity: String, name: String): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE name = ? AND entity = ?;") + statement.setString(1, name) + statement.setString(2, entity) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def removeByLabel(guildId: String, label: String): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE LOWER(label) = LOWER(?);") + statement.setString(1, label) + statement.executeUpdate() + + statement.close() + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDeathScreenshotRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDeathScreenshotRepository.scala new file mode 100644 index 0000000..88d054d --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDeathScreenshotRepository.scala @@ -0,0 +1,135 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.DeathScreenshot +import com.tibiabot.persistence.{ConnectionProvider, DeathScreenshotRepository} +import com.typesafe.scalalogging.StrictLogging + +import java.sql.Timestamp +import java.time.{Instant, ZoneOffset, ZonedDateTime} +import scala.collection.mutable.ListBuffer + +/** JDBC implementation of DeathScreenshotRepository. store/get bodies moved + * verbatim from BotApp; deleteIfPermitted is BotApp.deleteDeathScreenshot's DB + * logic with the JDA permission check replaced by the `permitted` predicate. */ +final class JdbcDeathScreenshotRepository(connectionProvider: ConnectionProvider) + extends DeathScreenshotRepository with StrictLogging { + + def store(guildId: String, world: String, characterName: String, deathTime: Long, + screenshotUrl: String, addedBy: String, addedName: String, messageId: String): Unit = { + val conn = connectionProvider.guild(guildId) + try { + // Create table if it doesn't exist + val createTableStatement = conn.createStatement() + createTableStatement.execute( + s"""CREATE TABLE IF NOT EXISTS death_screenshots ( + | guild_id VARCHAR(100) NOT NULL, + | world VARCHAR(50) NOT NULL, + | character_name VARCHAR(255) NOT NULL, + | death_time BIGINT NOT NULL, + | screenshot_url TEXT NOT NULL, + | added_by VARCHAR(100) NOT NULL, + | added_name VARCHAR(100) NOT NULL, + | added_at TIMESTAMP NOT NULL, + | message_id VARCHAR(100) NOT NULL, + | PRIMARY KEY (guild_id, world, character_name, death_time, screenshot_url) + |)""".stripMargin) + createTableStatement.close() + + // Insert screenshot + val insertStatement = conn.prepareStatement( + "INSERT INTO death_screenshots (guild_id, world, character_name, death_time, screenshot_url, added_by, added_name, added_at, message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + insertStatement.setString(1, guildId) + insertStatement.setString(2, world) + insertStatement.setString(3, characterName) + insertStatement.setLong(4, deathTime) + insertStatement.setString(5, screenshotUrl) + insertStatement.setString(6, addedBy) + insertStatement.setString(7, addedName) + insertStatement.setTimestamp(8, Timestamp.from(Instant.now())) + insertStatement.setString(9, messageId) + insertStatement.executeUpdate() + insertStatement.close() + } catch { + case ex: Exception => logger.error(s"Failed to store death screenshot: ${ex.getMessage}") + } finally { + conn.close() + } + } + + def get(guildId: String, world: String, characterName: String, deathTime: Long): List[DeathScreenshot] = { + val conn = connectionProvider.guild(guildId) + val screenshots = ListBuffer[DeathScreenshot]() + try { + val selectStatement = conn.prepareStatement( + "SELECT * FROM death_screenshots WHERE guild_id = ? AND character_name = ? AND death_time = ? ORDER BY added_at ASC" + ) + selectStatement.setString(1, guildId) + selectStatement.setString(2, characterName) + selectStatement.setLong(3, deathTime) + val resultSet = selectStatement.executeQuery() + + while (resultSet.next()) { + screenshots += DeathScreenshot( + guildId = resultSet.getString("guild_id"), + world = resultSet.getString("world"), + characterName = resultSet.getString("character_name"), + deathTime = resultSet.getLong("death_time"), + screenshotUrl = resultSet.getString("screenshot_url"), + addedBy = resultSet.getString("added_by"), + addedName = resultSet.getString("added_name"), + addedAt = ZonedDateTime.ofInstant(resultSet.getTimestamp("added_at").toInstant, ZoneOffset.UTC), + messageId = resultSet.getString("message_id") + ) + } + resultSet.close() + selectStatement.close() + } catch { + case ex: Exception => + logger.error(s"Failed to get death screenshots: ${ex.getMessage}") + } finally { + conn.close() + } + screenshots.toList + } + + def deleteIfPermitted(guildId: String, characterName: String, deathTime: Long, screenshotUrl: String) + (permitted: String => Boolean): Boolean = { + val conn = connectionProvider.guild(guildId) + var deleted = false + try { + // First check who added the screenshot, then let the caller's predicate decide + val checkStatement = conn.prepareStatement( + "SELECT added_by FROM death_screenshots WHERE guild_id = ? AND character_name = ? AND death_time = ? AND screenshot_url = ?" + ) + checkStatement.setString(1, guildId) + checkStatement.setString(2, characterName) + checkStatement.setLong(3, deathTime) + checkStatement.setString(4, screenshotUrl) + val resultSet = checkStatement.executeQuery() + + if (resultSet.next()) { + val addedBy = resultSet.getString("added_by") + if (permitted(addedBy)) { + val deleteStatement = conn.prepareStatement( + "DELETE FROM death_screenshots WHERE guild_id = ? AND character_name = ? AND death_time = ? AND screenshot_url = ?" + ) + deleteStatement.setString(1, guildId) + deleteStatement.setString(2, characterName) + deleteStatement.setLong(3, deathTime) + deleteStatement.setString(4, screenshotUrl) + val rowsDeleted = deleteStatement.executeUpdate() + deleted = rowsDeleted > 0 + deleteStatement.close() + } + } + resultSet.close() + checkStatement.close() + } catch { + case ex: Exception => logger.error(s"Failed to delete death screenshot: ${ex.getMessage}") + } finally { + conn.close() + } + deleted + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala new file mode 100644 index 0000000..16fa717 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala @@ -0,0 +1,125 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.persistence.{ConnectionProvider, DiscordConfigRepository} + +import java.sql.Timestamp +import java.time.ZonedDateTime + +/** JDBC implementation of DiscordConfigRepository. Bodies moved verbatim from + * BotApp's discordRetrieveConfig/discordCreateConfig/discordUpdateConfig, with + * the Guild parameter reduced to guildId. */ +final class JdbcDiscordConfigRepository(connectionProvider: ConnectionProvider) extends DiscordConfigRepository { + + def getConfig(guildId: String): Map[String, String] = { + val conn = connectionProvider.guild(guildId) + val statement = conn.createStatement() + + val channelExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_channel'") + val channelExists = channelExistsQuery.next() + channelExistsQuery.close() + + // Add the column if it doesn't exist + if (!channelExists) { + statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_channel VARCHAR(255) DEFAULT '0'") + } + + val lastWorldExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'last_world'") + val lastWorldExists = lastWorldExistsQuery.next() + lastWorldExistsQuery.close() + + // Add the column if it doesn't exist + if (!lastWorldExists) { + statement.execute("ALTER TABLE discord_info ADD COLUMN last_world VARCHAR(255) DEFAULT '0'") + } + + val messageExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_messageid'") + val messageExists = messageExistsQuery.next() + messageExistsQuery.close() + + // Add the column if it doesn't exist + if (!messageExists) { + statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_messageid VARCHAR(255) DEFAULT '0'") + } + + val result = statement.executeQuery(s"SELECT * FROM discord_info") + var configMap = Map[String, String]() + while (result.next()) { + configMap += ("guild_name" -> result.getString("guild_name")) + configMap += ("guild_owner" -> result.getString("guild_owner")) + configMap += ("admin_category" -> result.getString("admin_category")) + configMap += ("admin_channel" -> result.getString("admin_channel")) + configMap += ("boosted_channel" -> result.getString("boosted_channel")) + configMap += ("boosted_messageid" -> result.getString("boosted_messageid")) + configMap += ("last_world" -> result.getString("last_world")) + configMap += ("flags" -> result.getString("flags")) + configMap += ("created" -> result.getString("created")) + } + + statement.close() + conn.close() + configMap + } + + def create(guildId: String, guildName: String, guildOwner: String, adminCategory: String, + adminChannel: String, boostedChannel: String, boostedMessageId: String, created: ZonedDateTime): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement("INSERT INTO discord_info(guild_name, guild_owner, admin_category, admin_channel, boosted_channel, boosted_messageid, flags, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(guild_name) DO UPDATE SET guild_owner = EXCLUDED.guild_owner, admin_category = EXCLUDED.admin_category, admin_channel = EXCLUDED.admin_channel, boosted_channel = EXCLUDED.boosted_channel, boosted_messageid = EXCLUDED.boosted_messageid, flags = EXCLUDED.flags, created = EXCLUDED.created;") + statement.setString(1, guildName) + statement.setString(2, guildOwner) + statement.setString(3, adminCategory) + statement.setString(4, adminChannel) + statement.setString(5, boostedChannel) + statement.setString(6, boostedMessageId) + statement.setString(7, "none") + statement.setTimestamp(8, Timestamp.from(created.toInstant)) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def update(guildId: String, adminCategory: String, adminChannel: String, boostedChannel: String, + boostedMessage: String, lastWorld: String): Unit = { + val conn = connectionProvider.guild(guildId) + // update category if exists + if (adminCategory != "") { + val statement = conn.prepareStatement("UPDATE discord_info SET admin_category = ?;") + statement.setString(1, adminCategory) + statement.executeUpdate() + statement.close() + } + if (adminChannel != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET admin_channel = ?;") + statement.setString(1, adminChannel) + statement.executeUpdate() + statement.close() + } + + if (boostedChannel != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET boosted_channel = ?;") + statement.setString(1, boostedChannel) + statement.executeUpdate() + statement.close() + } + + if (boostedMessage != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET boosted_messageid = ?;") + statement.setString(1, boostedMessage) + statement.executeUpdate() + statement.close() + } + + if (lastWorld != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET last_world = ?;") + statement.setString(1, lastWorld) + statement.executeUpdate() + statement.close() + } + + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala new file mode 100644 index 0000000..9324de0 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala @@ -0,0 +1,120 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.SatchelStamp +import com.tibiabot.persistence.{ConnectionProvider, GalthenRepository} + +import java.sql.Timestamp +import java.time.{Instant, ZoneOffset, ZonedDateTime} +import scala.collection.mutable.ListBuffer +import scala.util.Try + +/** JDBC implementation of GalthenRepository. Bodies moved verbatim from BotApp's + * getGalthenTable/addGalthen/delGalthen/delAllGalthen — no behaviour change. */ +final class JdbcGalthenRepository(connectionProvider: ConnectionProvider) extends GalthenRepository { + + def getStamps(userId: String): Option[List[SatchelStamp]] = { + val conn = connectionProvider.cache() + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = + statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'satchel'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createListTable = + s"""CREATE TABLE satchel ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |userid VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL, + |tag VARCHAR(255) + |);""".stripMargin + + statement.executeUpdate(createListTable) + } + + val result = statement.executeQuery(s"SELECT time,tag FROM satchel WHERE userid = '$userId';") + + val satchelStampList: ListBuffer[SatchelStamp] = ListBuffer() + + while (result.next()) { + val updatedTimeTemporal = + Try(Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z"))) + .getOrElse(Instant.parse("2022-01-01T01:00:00Z")) + val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) + val tag = Option(result.getString("tag")).getOrElse("") + + val satchelStamp = SatchelStamp(userId, updatedTime, tag) + satchelStampList += satchelStamp + } + + statement.close() + conn.close() + Some(satchelStampList.toList) + } + + def del(user: String, tag: String): Unit = { + val conn = connectionProvider.cache() + + val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ? AND COALESCE(tag, '') = ?;") + deleteStatement.setString(1, user) + deleteStatement.setString(2, tag) + deleteStatement.executeUpdate() + + deleteStatement.close() + conn.close() + } + + def delAll(user: String): Unit = { + val conn = connectionProvider.cache() + + val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ?;") + deleteStatement.setString(1, user) + deleteStatement.executeUpdate() + + deleteStatement.close() + conn.close() + } + + def add(user: String, when: ZonedDateTime, tag: String): Unit = { + val conn = connectionProvider.cache() + val selectStatement = conn.prepareStatement("SELECT time FROM satchel WHERE userid = ? AND tag = ?;") + selectStatement.setString(1, user) + selectStatement.setString(2, tag) + val resultSet = selectStatement.executeQuery() + + if (resultSet.next()) { + // Update existing row + val updateStatement = conn.prepareStatement( + s""" + |UPDATE satchel + |SET time = ? + |WHERE userid = ? AND tag = ?; + |""".stripMargin + ) + updateStatement.setTimestamp(1, Timestamp.from(when.toInstant)) + updateStatement.setString(2, user) + updateStatement.setString(3, tag) + updateStatement.executeUpdate() + updateStatement.close() + } else { + // Insert new row + val insertStatement = conn.prepareStatement( + s""" + |INSERT INTO satchel(userid, time, tag) + |VALUES (?,?,?); + |""".stripMargin + ) + insertStatement.setString(1, user) + insertStatement.setTimestamp(2, Timestamp.from(when.toInstant)) + insertStatement.setString(3, tag) + insertStatement.executeUpdate() + insertStatement.close() + } + + selectStatement.close() + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala new file mode 100644 index 0000000..bc4a9b0 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala @@ -0,0 +1,133 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.{Guilds, Players} +import com.tibiabot.persistence.{ConnectionProvider, HuntedAlliedRepository} +import org.postgresql.util.PSQLException + +import scala.collection.mutable.ListBuffer + +/** JDBC implementation of HuntedAlliedRepository. Bodies moved verbatim from + * BotApp's playerConfig/guildConfig/addHuntedToDatabase/addAllyToDatabase/ + * removeHuntedFromDatabase/removeAllyFromDatabase/updateHuntedOrAllyNameToDatabase, + * with the Guild parameter reduced to guildId. Table names are selected by the + * same option logic as before (kept verbatim; not user input). */ +final class JdbcHuntedAlliedRepository(connectionProvider: ConnectionProvider) extends HuntedAlliedRepository { + + def getPlayers(guildId: String, query: String): List[Players] = { + val conn = connectionProvider.guild(guildId) + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT name,reason,reason_text,added_by FROM $query") + + val results = new ListBuffer[Players]() + while (result.next()) { + val name = Option(result.getString("name")).getOrElse("") + val reason = Option(result.getString("reason")).getOrElse("") + val reasonText = Option(result.getString("reason_text")).getOrElse("") + val addedBy = Option(result.getString("added_by")).getOrElse("") + results += Players(name, reason, reasonText, addedBy) + } + + statement.close() + conn.close() + results.toList + } + + def getGuilds(guildId: String, query: String): List[Guilds] = { + val conn = connectionProvider.guild(guildId) + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT name,reason,reason_text,added_by FROM $query") + + val results = new ListBuffer[Guilds]() + while (result.next()) { + val name = Option(result.getString("name")).getOrElse("") + val reason = Option(result.getString("reason")).getOrElse("") + val reasonText = Option(result.getString("reason_text")).getOrElse("") + val addedBy = Option(result.getString("added_by")).getOrElse("") + results += Guilds(name, reason, reasonText, addedBy) + } + + statement.close() + conn.close() + results.toList + } + + def addHunted(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = { + val conn = connectionProvider.guild(guildId) + val table = (if (option == "guild") "hunted_guilds" else if (option == "player") "hunted_players").toString + val statement = conn.prepareStatement(s"INSERT INTO $table(name, reason, reason_text, added_by) VALUES (?,?,?,?) ON CONFLICT (name) DO NOTHING;") + statement.setString(1, name) + statement.setString(2, reason) + statement.setString(3, reasonText) + statement.setString(4, addedBy) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def addAllied(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = { + val conn = connectionProvider.guild(guildId) + val table = (if (option == "guild") "allied_guilds" else if (option == "player") "allied_players").toString + val statement = conn.prepareStatement(s"INSERT INTO $table(name, reason, reason_text, added_by) VALUES (?,?,?,?) ON CONFLICT (name) DO NOTHING;") + statement.setString(1, name) + statement.setString(2, reason) + statement.setString(3, reasonText) + statement.setString(4, addedBy) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def removeHunted(guildId: String, option: String, name: String): Unit = { + val conn = connectionProvider.guild(guildId) + val table = (if (option == "guild") "hunted_guilds" else if (option == "player") "hunted_players").toString + val statement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") + statement.setString(1, name) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def removeAllied(guildId: String, option: String, name: String): Unit = { + val conn = connectionProvider.guild(guildId) + val table = (if (option == "guild") "allied_guilds" else if (option == "player") "allied_players").toString + val statement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") + statement.setString(1, name) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def rename(guildId: String, option: String, oldName: String, newName: String): Unit = { + val conn = connectionProvider.guild(guildId) + val table = if (option == "hunted") "hunted_players" else if (option == "allied") "allied_players" + + val statement = conn.prepareStatement(s"UPDATE $table SET name = ? WHERE LOWER(name) = LOWER(?);") + statement.setString(1, newName) + statement.setString(2, oldName) + + try { + statement.executeUpdate() + } catch { + case e: PSQLException if e.getMessage.contains("duplicate key value") => + // Handle duplicate key error + val deleteStatement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") + deleteStatement.setString(1, newName) + deleteStatement.executeUpdate() + deleteStatement.close() + + // Retry the update within the same transaction + val retryStatement = conn.prepareStatement(s"UPDATE $table SET name = ? WHERE LOWER(name) = LOWER(?);") + retryStatement.setString(1, newName) + retryStatement.setString(2, oldName) + retryStatement.executeUpdate() + retryStatement.close() + } finally { + statement.close() + conn.close() + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala new file mode 100644 index 0000000..510dba8 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala @@ -0,0 +1,254 @@ +package com.tibiabot.persistence.jdbc + +import com.tibiabot.domain.Worlds +import com.tibiabot.persistence.{ConnectionProvider, WorldConfigRepository} + +import scala.collection.mutable.ListBuffer +import scala.util.{Failure, Success, Try} + +/** JDBC implementation of WorldConfigRepository. Bodies moved verbatim from + * BotApp's worldConfig/worldCreateConfig/worldRetrieveConfig/worldRemoveConfig, + * with the Guild parameter reduced to guildId. `mergedWorlds` (Config.mergedWorlds) + * is injected rather than read from Config so this stays decoupled from config + * loading and testable. */ +final class JdbcWorldConfigRepository(connectionProvider: ConnectionProvider, mergedWorlds: List[String]) extends WorldConfigRepository { + + def listWorlds(guildId: String): List[Worlds] = { + val conn = connectionProvider.guild(guildId) + val statement = conn.createStatement() + + // Check if the column already exists in the table + val columnExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'exiva_list'") + val columnExists = columnExistsQuery.next() + columnExistsQuery.close() + + // Add the column if it doesn't exist + if (!columnExists) { + statement.execute("ALTER TABLE worlds ADD COLUMN exiva_list VARCHAR(255) DEFAULT 'false'") + } + + // Check if the column already exists in the table + val allyPkExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'allypk_role'") + val allyPkExists = allyPkExistsQuery.next() + allyPkExistsQuery.close() + + // Add the allyPk if it doesn't exist + if (!allyPkExists) { + statement.execute("ALTER TABLE worlds ADD COLUMN allypk_role VARCHAR(255) DEFAULT '0'") + } + + // Check if the column already exists in the table + val masslogExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'masslog_role'") + val masslogExists = masslogExistsQuery.next() + masslogExistsQuery.close() + + // Add the allyPk if it doesn't exist + if (!masslogExists) { + statement.execute("ALTER TABLE worlds ADD COLUMN masslog_role VARCHAR(255) DEFAULT '0'") + } + + // Check if the column already exists in the table + val activityExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'activity_channel'") + val activityExists = activityExistsQuery.next() + activityExistsQuery.close() + + // Add the column if it doesn't exist + if (!activityExists) { + statement.execute("ALTER TABLE worlds ADD COLUMN activity_channel VARCHAR(255) DEFAULT '0'") + } + + // Check if the column already exists in the table + val onlineCombinedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'worlds' AND COLUMN_NAME = 'online_combined'") + val onlineCombinedExists = onlineCombinedExistsQuery.next() + onlineCombinedExistsQuery.close() + + // Add the column if it doesn't exist + if (!onlineCombinedExists) { + statement.execute("ALTER TABLE worlds ADD COLUMN online_combined VARCHAR(255) DEFAULT 'false'") + } + + val result = statement.executeQuery(s"SELECT name,allies_channel,enemies_channel,neutrals_channel,levels_channel,deaths_channel,category,fullbless_role,nemesis_role,allypk_role,masslog_role,fullbless_channel,nemesis_channel,fullbless_level,show_neutral_levels,show_neutral_deaths,show_allies_levels,show_allies_deaths,show_enemies_levels,show_enemies_deaths,detect_hunteds,levels_min,deaths_min,exiva_list,activity_channel,online_combined FROM worlds") + + val results = new ListBuffer[Worlds]() + while (result.next()) { + val name = Option(result.getString("name")).getOrElse("") + val alliesChannel = Option(result.getString("allies_channel")).getOrElse(null) + val enemiesChannel = Option(result.getString("enemies_channel")).getOrElse(null) + val neutralsChannel = Option(result.getString("neutrals_channel")).getOrElse(null) + val levelsChannel = Option(result.getString("levels_channel")).getOrElse(null) + val deathsChannel = Option(result.getString("deaths_channel")).getOrElse(null) + val category = Option(result.getString("category")).getOrElse(null) + val fullblessRole = Option(result.getString("fullbless_role")).getOrElse(null) + val nemesisRole = Option(result.getString("nemesis_role")).getOrElse(null) + val allyPkRole = Option(result.getString("allypk_role")).getOrElse(null) + val masslogRole = Option(result.getString("masslog_role")).getOrElse(null) + val fullblessChannel = Option(result.getString("fullbless_channel")).getOrElse(null) + val nemesisChannel = Option(result.getString("nemesis_channel")).getOrElse(null) + val fullblessLevel = Option(result.getInt("fullbless_level")).getOrElse(250) + val showNeutralLevels = Option(result.getString("show_neutral_levels")).getOrElse("true") + val showNeutralDeaths = Option(result.getString("show_neutral_deaths")).getOrElse("true") + val showAlliesLevels = Option(result.getString("show_allies_levels")).getOrElse("true") + val showAlliesDeaths = Option(result.getString("show_allies_deaths")).getOrElse("true") + val showEnemiesLevels = Option(result.getString("show_enemies_levels")).getOrElse("true") + val showEnemiesDeaths = Option(result.getString("show_enemies_deaths")).getOrElse("true") + val detectHunteds = Option(result.getString("detect_hunteds")).getOrElse("on") + val levelsMin = Option(result.getInt("levels_min")).getOrElse(8) + val deathsMin = Option(result.getInt("deaths_min")).getOrElse(8) + val exivaList = Option(result.getString("exiva_list")).getOrElse("false") + val activityChannel = Option(result.getString("activity_channel")).getOrElse(null) + val onlineCombined = Option(result.getString("online_combined")).getOrElse(null) + + // Ignore merged worlds (they are now effectively inactive and ignored but their data still exists in the db) + if (!mergedWorlds.exists(_.equalsIgnoreCase(name))) { + results += Worlds(name, alliesChannel, enemiesChannel, neutralsChannel, levelsChannel, deathsChannel, category, fullblessRole, nemesisRole, allyPkRole, masslogRole, fullblessChannel, nemesisChannel, fullblessLevel, showNeutralLevels, showNeutralDeaths, showAlliesLevels, showAlliesDeaths, showEnemiesLevels, showEnemiesDeaths, detectHunteds, levelsMin, deathsMin, exivaList, activityChannel, onlineCombined) + } + } + + statement.close() + conn.close() + results.toList + } + + def createWorld(guildId: String, world: String, alliesChannel: String, enemiesChannel: String, + neutralsChannels: String, levelsChannel: String, deathsChannel: String, category: String, + fullblessRole: String, nemesisRole: String, allyPkRole: String, masslogRole: String, + fullblessChannel: String, nemesisChannel: String, activityChannel: String): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement("INSERT INTO worlds(name, allies_channel, enemies_channel, neutrals_channel, levels_channel, deaths_channel, category, fullbless_role, nemesis_role, allypk_role, masslog_role, fullbless_channel, nemesis_channel, fullbless_level, show_neutral_levels, show_neutral_deaths, show_allies_levels, show_allies_deaths, show_enemies_levels, show_enemies_deaths, detect_hunteds, levels_min, deaths_min, exiva_list, activity_channel, online_combined) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO UPDATE SET allies_channel = ?, enemies_channel = ?, neutrals_channel = ?, levels_channel = ?, deaths_channel = ?, category = ?, fullbless_role = ?, nemesis_role = ?, allypk_role = ?, masslog_role = ?, fullbless_channel = ?, nemesis_channel = ?, fullbless_level = ?, show_neutral_levels = ?, show_neutral_deaths = ?, show_allies_levels = ?, show_allies_deaths = ?, show_enemies_levels = ?, show_enemies_deaths = ?, detect_hunteds = ?, levels_min = ?, deaths_min = ?, exiva_list = ?, activity_channel = ?, online_combined = ?;") + val formalQuery = world.toLowerCase().capitalize + statement.setString(1, formalQuery) + statement.setString(2, alliesChannel) + statement.setString(3, enemiesChannel) + statement.setString(4, neutralsChannels) + statement.setString(5, levelsChannel) + statement.setString(6, deathsChannel) + statement.setString(7, category) + statement.setString(8, fullblessRole) + statement.setString(9, nemesisRole) + statement.setString(10, allyPkRole) + statement.setString(11, masslogRole) + statement.setString(12, fullblessChannel) + statement.setString(13, nemesisChannel) + statement.setInt(14, 250) + statement.setString(15, "true") + statement.setString(16, "true") + statement.setString(17, "true") + statement.setString(18, "true") + statement.setString(19, "true") + statement.setString(20, "true") + statement.setString(21, "on") + statement.setInt(22, 8) + statement.setInt(23, 8) + statement.setString(24, "false") + statement.setString(25, activityChannel) + statement.setString(26, "true") + statement.setString(27, alliesChannel) + statement.setString(28, enemiesChannel) + statement.setString(29, neutralsChannels) + statement.setString(30, levelsChannel) + statement.setString(31, deathsChannel) + statement.setString(32, category) + statement.setString(33, fullblessRole) + statement.setString(34, nemesisRole) + statement.setString(35, allyPkRole) + statement.setString(36, masslogRole) + statement.setString(37, fullblessChannel) + statement.setString(38, nemesisChannel) + statement.setInt(39, 250) + statement.setString(40, "true") + statement.setString(41, "true") + statement.setString(42, "true") + statement.setString(43, "true") + statement.setString(44, "true") + statement.setString(45, "true") + statement.setString(46, "on") + statement.setInt(47, 8) + statement.setInt(48, 8) + statement.setString(49, "false") + statement.setString(50, activityChannel) + statement.setString(51, "true") + statement.executeUpdate() + + statement.close() + conn.close() + } + + def retrieveWorld(guildId: String, world: String): Map[String, String] = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement("SELECT * FROM worlds WHERE name = ?;") + val formalWorld = world.toLowerCase().capitalize + statement.setString(1, formalWorld) + val result = statement.executeQuery() + + var configMap = Map[String, String]() + while (result.next()) { + configMap += ("name" -> result.getString("name")) + configMap += ("allies_channel" -> result.getString("allies_channel")) + configMap += ("enemies_channel" -> result.getString("enemies_channel")) + configMap += ("neutrals_channel" -> result.getString("neutrals_channel")) + configMap += ("levels_channel" -> result.getString("levels_channel")) + configMap += ("deaths_channel" -> result.getString("deaths_channel")) + configMap += ("category" -> result.getString("category")) + configMap += ("fullbless_role" -> result.getString("fullbless_role")) + configMap += ("nemesis_role" -> result.getString("nemesis_role")) + configMap += ("allypk_role" -> result.getString("allypk_role")) + configMap += ("masslog_role" -> result.getString("masslog_role")) + configMap += ("fullbless_channel" -> result.getString("fullbless_channel")) + configMap += ("nemesis_channel" -> result.getString("nemesis_channel")) + configMap += ("fullbless_level" -> result.getInt("fullbless_level").toString) + configMap += ("show_neutral_levels" -> result.getString("show_neutral_levels")) + configMap += ("show_neutral_deaths" -> result.getString("show_neutral_deaths")) + configMap += ("show_allies_levels" -> result.getString("show_allies_levels")) + configMap += ("show_allies_deaths" -> result.getString("show_allies_deaths")) + configMap += ("show_enemies_levels" -> result.getString("show_enemies_levels")) + configMap += ("show_enemies_deaths" -> result.getString("show_enemies_deaths")) + configMap += ("detect_hunteds" -> result.getString("detect_hunteds")) + configMap += ("levels_min" -> result.getInt("levels_min").toString) + configMap += ("deaths_min" -> result.getInt("deaths_min").toString) + configMap += ("exiva_list" -> result.getString("exiva_list")) + configMap += ("activity_channel" -> result.getString("activity_channel")) + + val combinedOnlineValue: String = Try(result.getString("combined_online")) match { + case Success(value) => value // Column exists, use the retrieved value + case Failure(_) => "false" // Column doesn't exist, use the default value + } + configMap += ("combined_online" -> combinedOnlineValue) + } + statement.close() + conn.close() + configMap + } + + def removeWorld(guildId: String, world: String): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement("DELETE FROM worlds WHERE name = ?") + val formalName = world.toLowerCase().capitalize + statement.setString(1, formalName) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def updateWorldString(guildId: String, world: String, column: String, value: String): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement(s"UPDATE worlds SET $column = ? WHERE name = ?;") + statement.setString(1, value) + statement.setString(2, world) + statement.executeUpdate() + + statement.close() + conn.close() + } + + def updateWorldInt(guildId: String, world: String, column: String, value: Int): Unit = { + val conn = connectionProvider.guild(guildId) + val statement = conn.prepareStatement(s"UPDATE worlds SET $column = ? WHERE name = ?;") + statement.setInt(1, value) + statement.setString(2, world) + statement.executeUpdate() + + statement.close() + conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala new file mode 100644 index 0000000..5d7e634 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala @@ -0,0 +1,18 @@ +package com.tibiabot.presentation + +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.MessageEmbed + +/** Pure builder for the boosted-boss/creature embed. Moved verbatim from + * BotApp.createBoostedEmbed (callers still pass Config emoji strings as args; + * this function itself is Config-free). */ +object BoostedEmbeds { + def create(name: String, emoji: String, wikiUrl: String, thumbnail: String, embedText: String): MessageEmbed = { + val embed = new EmbedBuilder() + //embed.setTitle(s"$emoji $name $emoji", wikiUrl) + embed.setThumbnail(thumbnail) + embed.setColor(3092790) + embed.setDescription(embedText) + embed.build() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala new file mode 100644 index 0000000..88afca2 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala @@ -0,0 +1,21 @@ +package com.tibiabot.presentation + +import net.dv8tion.jda.api.EmbedBuilder + +/** Pure builder for the death-notification embed. The killer/colour/thumbnail + * DECISIONS stay in the stream; this just assembles the embed from them. + * Title = vocation emoji + linked name (extracted verbatim from + * TibiaBot.postToDiscordAndCleanUp). No Config, no JDA gateway. */ +object DeathEmbeds { + def build(charName: String, vocation: String, description: String, thumbnail: String, color: Int): EmbedBuilder = { + val embed = new EmbedBuilder() + embed.setTitle( + s"${Emojis.vocEmoji(vocation)} $charName ${Emojis.vocEmoji(vocation)}", + Urls.charUrl(charName) + ) + embed.setDescription(description) + embed.setThumbnail(thumbnail) + embed.setColor(color) + embed + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala new file mode 100644 index 0000000..5ec8d86 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala @@ -0,0 +1,40 @@ +package com.tibiabot.presentation + +/** Pure vocation -> Discord emoji mapping. + * + * IMPORTANT: two variants exist because the original code diverged, and this + * extraction preserves BOTH behaviours exactly rather than silently unifying + * them (pinned by EmojisSpec): + * + * - `vocEmoji` — TibiaBot's version; includes the `monk` vocation. + * - `vocEmojiWithoutMonk`— BotApp's version; predates monks and omits it, + * so a monk maps to "" there. + * + * Reconciling the two (i.e. adding monk to BotApp's path) would be a behaviour + * change and is intentionally left as a separate, explicit decision. + */ +object Emojis { + + /** Includes monk. Matches the original `TibiaBot.vocEmoji`. */ + def vocEmoji(vocation: String): String = + vocation.toLowerCase.split(' ').last match { + case "knight" => ":shield:" + case "druid" => ":snowflake:" + case "sorcerer" => ":fire:" + case "paladin" => ":bow_and_arrow:" + case "monk" => ":fist::skin-tone-3:" + case "none" => ":hatching_chick:" + case _ => "" + } + + /** Omits monk. Matches the original `BotApp.vocEmoji`. */ + def vocEmojiWithoutMonk(vocation: String): String = + vocation.toLowerCase.split(' ').last match { + case "knight" => ":shield:" + case "druid" => ":snowflake:" + case "sorcerer" => ":fire:" + case "paladin" => ":bow_and_arrow:" + case "none" => ":hatching_chick:" + case _ => "" + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/GalthenEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/GalthenEmbeds.scala new file mode 100644 index 0000000..1feffc5 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/GalthenEmbeds.scala @@ -0,0 +1,25 @@ +package com.tibiabot.presentation + +/** Helpers for the Galthen's Satchel cooldown embeds. + * + * NOTE: only the description-truncation logic is shared verbatim across the + * four Galthen call sites in BotListener, so that is what is extracted here. + * The embed construction itself has DIVERGED between call sites (different + * satchel emoji — `Config.satchelEmoji` vs a hardcoded id — tag formatting, + * colours, and "message you when..." text), so unifying it is deliberately + * deferred to avoid changing behaviour. + */ +object GalthenEmbeds { + + /** Join `lines` with newlines and cap the result at `limit` characters, + * cutting back to the last whole line so an entry is never split mid-way. + * Mirrors the repeated 4050-character truncation block in BotListener. */ + def truncate(lines: Seq[String], limit: Int = 4050): String = { + val joined = lines.mkString("\n") + if (joined.length > limit) { + val truncated = joined.substring(0, limit) + val lastNewLine = truncated.lastIndexOf("\n") + if (lastNewLine >= 0) truncated.substring(0, lastNewLine) else truncated + } else joined + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala new file mode 100644 index 0000000..3be059b --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala @@ -0,0 +1,22 @@ +package com.tibiabot.presentation + +/** Pure rendering helpers for the online list. (The full embed assembly in + * TibiaBot.onlineList depends on Config emoji constants + JDA channel state and + * stays there for now; this holds the Config-free, unit-testable bits.) */ +object OnlineListEmbeds { + + /** Format an online duration (in seconds) as a backticked "Xhr Ymin" / "Xmin" + * string. Moved verbatim from TibiaBot.onlineList. */ + def durationString(durationInSec: Long): String = { + val durationInMin = durationInSec / 60 + val durationStr = + if (durationInMin >= 60) { + val hours = durationInMin / 60 + val mins = durationInMin % 60 + s"${hours}hr ${mins}min" + } else { + s"${durationInMin}min" + } + s"`$durationStr`" + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/Urls.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/Urls.scala new file mode 100644 index 0000000..d529a51 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/Urls.scala @@ -0,0 +1,50 @@ +package com.tibiabot.presentation + +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +/** Pure builders for tibia.com community URLs. + * + * Extracted verbatim from the byte-identical `charUrl`/`guildUrl` helpers that + * were duplicated in both `BotApp` and `TibiaBot`. Behaviour is unchanged — + * pinned by UrlsSpec. + */ +object Urls { + + def charUrl(char: String): String = { + val encodedString = URLEncoder.encode(char, StandardCharsets.UTF_8.toString) + s"https://www.tibia.com/community/?name=${encodedString}" + } + + def guildUrl(guild: String): String = { + val encodedString = URLEncoder.encode(guild, StandardCharsets.UTF_8.toString) + s"https://www.tibia.com/community/?subtopic=guilds&page=view&GuildName=${encodedString}" + } + + /** Resolve a creature name to its TibiaWiki file/page name. + * + * Extracted verbatim from the name-parsing block shared by + * `BotApp.creatureImageUrl`, `BotApp.creatureWikiUrl` and + * `TibiaBot.creatureImageUrl`. The `mappings` (Config.creatureUrlMappings) + * are passed in so this stays decoupled from config loading and unit-testable. + */ + def creatureFileName(creature: String, mappings: Map[String, String]): String = + mappings.getOrElse(creature.toLowerCase, { + // Capitalise the start of each word, including after punctuation e.g. "Mooh'Tah Warrior", "Two-Headed Turtle" + val rx1 = """([^\w]\w)""".r + val parsed1 = rx1.replaceAllIn(creature, m => m.group(1).toUpperCase) + + // Lowercase the articles, prepositions etc., e.g. "The Voice of Ruin" + val rx2 = """( A| Of| The| In| On| To| And| With| From)(?=( ))""".r + val parsed2 = rx2.replaceAllIn(parsed1, m => m.group(1).toLowerCase) + + // Replace spaces with underscores and make sure the first letter is capitalised + parsed2.replaceAll(" ", "_").capitalize + }) + + def creatureImageUrl(creature: String, mappings: Map[String, String]): String = + s"https://www.tibiawiki.com.br/wiki/Special:Redirect/file/${creatureFileName(creature, mappings)}.gif" + + def creatureWikiUrl(creature: String, mappings: Map[String, String]): String = + s"https://www.tibiawiki.com.br/wiki/${creatureFileName(creature, mappings)}" +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/scheduler/ServerSaveSchedule.scala b/tibia-bot/src/main/scala/com/tibiabot/scheduler/ServerSaveSchedule.scala new file mode 100644 index 0000000..f5ba7a8 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/scheduler/ServerSaveSchedule.scala @@ -0,0 +1,26 @@ +package com.tibiabot.scheduler + +import java.time.{DayOfWeek, Duration, Instant, LocalTime} + +/** Pure scheduling decisions used by the periodic (server-save) job. */ +object ServerSaveSchedule { + + /** The post-server-save notification window: after 10:00 and before 10:45 (Berlin time). */ + def isServerSaveWindow(time: LocalTime): Boolean = + time.isAfter(LocalTime.of(10, 0)) && time.isBefore(LocalTime.of(10, 45)) + + /** The city where Rashid can be found on a given (Berlin minus 10h) weekday. */ + def rashidLocation(day: DayOfWeek): String = day match { + case DayOfWeek.MONDAY => "Svargrond" + case DayOfWeek.TUESDAY => "Liberty Bay" + case DayOfWeek.WEDNESDAY => "Port Hope" + case DayOfWeek.THURSDAY => "Ankrahmun" + case DayOfWeek.FRIDAY => "Darashia" + case DayOfWeek.SATURDAY => "Edron" + case DayOfWeek.SUNDAY => "Carlin" + } + + /** Show the Drome countdown only when it is in the future and within the next 3 days. */ + def shouldShowDrome(now: Instant, dromeTime: Instant): Boolean = + dromeTime.isAfter(now) && Duration.between(now, dromeTime).toDays <= 3 +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala b/tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala new file mode 100644 index 0000000..48164d6 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala @@ -0,0 +1,31 @@ +package com.tibiabot.state + +import com.tibiabot.domain.{PlayerCache, Players} + +/** + * The per-guild working state mutated by BOTH the per-world streams and command + * threads: activity tracking plus the hunted/allied player lists. + * + * Reads are lock-free on `@volatile` fields (so a running stream always sees the + * latest committed map); every read-modify-write goes through the synchronized + * `modify*` methods so a concurrent update to one guild's entry can never clobber + * a concurrent update to another guild's. + */ +final class StreamState { + private val lock = new Object() + + @volatile private var _activity: Map[String, List[PlayerCache]] = Map.empty + @volatile private var _huntedPlayers: Map[String, List[Players]] = Map.empty + @volatile private var _alliedPlayers: Map[String, List[Players]] = Map.empty + + def activityData: Map[String, List[PlayerCache]] = _activity + def huntedPlayersData: Map[String, List[Players]] = _huntedPlayers + def alliedPlayersData: Map[String, List[Players]] = _alliedPlayers + + def modifyActivityData(f: Map[String, List[PlayerCache]] => Map[String, List[PlayerCache]]): Unit = + lock.synchronized { _activity = f(_activity) } + def modifyHuntedPlayersData(f: Map[String, List[Players]] => Map[String, List[Players]]): Unit = + lock.synchronized { _huntedPlayers = f(_huntedPlayers) } + def modifyAlliedPlayersData(f: Map[String, List[Players]] => Map[String, List[Players]]): Unit = + lock.synchronized { _alliedPlayers = f(_alliedPlayers) } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala new file mode 100644 index 0000000..34a64d7 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala @@ -0,0 +1,25 @@ +package com.tibiabot.tibiadata + +import com.tibiabot.tibiadata.response.{BoostedResponse, CharacterResponse, CreatureResponse, CreaturesResponse, GuildResponse, HighscoresResponse, NewsResponse, NewsTickerResponse, WorldResponse, WorldsResponse} + +import scala.concurrent.Future + +/** Port over the TibiaData HTTP API, implemented by TibiaDataClient. Lets + * callers depend on the interface (and be given stubs in tests) rather than + * the concrete Akka-HTTP client. */ +trait TibiaApi { + def getWorld(world: String): Future[Either[String, WorldResponse]] + def getWorlds(): Future[Either[String, WorldsResponse]] + def getCreatures(): Future[Either[String, CreaturesResponse]] + def getBoostedBoss(): Future[Either[String, BoostedResponse]] + def getBoostedCreature(): Future[Either[String, CreatureResponse]] + def getHighscores(world: String, page: Int): Future[Either[String, HighscoresResponse]] + def getGuild(guild: String): Future[Either[String, GuildResponse]] + def getGuildWithInput(input: (String, String)): Future[(Either[String, GuildResponse], String, String)] + def getCharacter(name: String): Future[Either[String, CharacterResponse]] + def getKillerFallback(name: String): Future[Either[String, CharacterResponse]] + def getCharacterV2(input: (String, Int)): Future[Either[String, CharacterResponse]] + def getCharacterWithInput(input: (String, String, String)): Future[(Either[String, CharacterResponse], String, String, String)] + def getLatestNews(): Future[Either[String, NewsResponse]] + def getNewsTicker(): Future[Either[String, NewsTickerResponse]] +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala index 91446a8..0991015 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala @@ -22,9 +22,8 @@ import akka.http.scaladsl.model.headers.{Date => DateHeader} import java.time.{ZonedDateTime, ZoneId} import java.time.format.DateTimeFormatter -class TibiaDataClient extends JsonSupport with StrictLogging { +class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with StrictLogging with TibiaApi { - implicit private val system: ActorSystem = ActorSystem() implicit private val executionContext: ExecutionContextExecutor = system.dispatcher private val characterUrl = "https://api.tibiadata.com/v4/character/" diff --git a/tibia-bot/src/main/scala/com/tibiabot/tracking/BoundedMessageQueue.scala b/tibia-bot/src/main/scala/com/tibiabot/tracking/BoundedMessageQueue.scala new file mode 100644 index 0000000..67e3070 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/tracking/BoundedMessageQueue.scala @@ -0,0 +1,42 @@ +package com.tibiabot.tracking + +import scala.collection.mutable + +/** FIFO message queue with an optional size cap. + * + * Production currently uses an unbounded `mutable.Queue` (TibiaBot.scala 1827) + * drained one item per tick — under a burst (server save / masslog) it can + * grow without bound. `capacity = Int.MaxValue` reproduces today's behaviour + * exactly; a finite capacity drops messages instead of leaking memory. + * + * @param capacity max retained items (default: unbounded == current behaviour) + * @param dropNewest if true, reject the incoming item when full (tail drop); + * if false, evict the oldest queued item to make room. + */ +final class BoundedMessageQueue[T](capacity: Int = Int.MaxValue, dropNewest: Boolean = true) { + private val q = mutable.Queue.empty[T] + private var droppedCount = 0L + + def size: Int = q.size + def isEmpty: Boolean = q.isEmpty + def dropped: Long = droppedCount + + /** Enqueue an item. Returns true if it was retained, false if dropped. */ + def enqueue(item: T): Boolean = { + if (q.size < capacity) { + q.enqueue(item) + true + } else if (dropNewest) { + droppedCount += 1 + false + } else { + q.dequeue() // evict oldest + q.enqueue(item) + droppedCount += 1 + true + } + } + + /** Remove and return the head, or None if empty (FIFO). */ + def dequeueOption(): Option[T] = if (q.isEmpty) None else Some(q.dequeue()) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tracking/LevelTracker.scala b/tibia-bot/src/main/scala/com/tibiabot/tracking/LevelTracker.scala new file mode 100644 index 0000000..b33b373 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/tracking/LevelTracker.scala @@ -0,0 +1,55 @@ +package com.tibiabot.tracking + +import java.time.ZonedDateTime +import scala.collection.mutable + +/** Level-up dedup state extracted from `TibiaBot.recentLevels`. + * + * OPTIMIZED implementation: keyed by (name, level), keeping the record with the + * greatest lastLogin. The original `mutable.Set[CharLevel]` was scanned + * linearly with `.exists`/`.filter` on every character every cycle + * (TibiaBot.scala 625-659); here `shouldRecord` is O(1). Behaviour is + * identical because the original forall/exists over all matching records + * reduces exactly to a comparison against the newest matching lastLogin — + * locked in by LevelTrackerSpec. + */ +final case class LevelRecord( + name: String, + level: Int, + vocation: String, + lastLogin: ZonedDateTime, + time: ZonedDateTime +) + +final class LevelTracker { + // keyed by (name, level), holding the record with the greatest lastLogin. + private val recent = mutable.Map.empty[(String, Int), LevelRecord] + + def size: Int = recent.size + def snapshot: Set[LevelRecord] = recent.values.toSet + def load(records: Iterable[LevelRecord]): Unit = records.foreach(record) + + /** Should this (name, level) advancement be posted & recorded? + * + * Faithful to the gate at TibiaBot.scala 625-627 / 650-652: + * !exists(name,level) || all matching records have lastLogin < sheetLastLogin + * i.e. there is NO record for (name, level) whose lastLogin is at or after + * the current sheet login. With one kept record (the max lastLogin), this is + * simply: absent, or its lastLogin is before the sheet login. */ + def shouldRecord(name: String, level: Int, sheetLastLogin: ZonedDateTime): Boolean = + recent.get((name, level)).forall(_.lastLogin.isBefore(sheetLastLogin)) + + /** Keep the record with the greatest lastLogin for each (name, level). */ + def record(r: LevelRecord): Unit = { + val key = (r.name, r.level) + recent.get(key) match { + case Some(existing) if existing.lastLogin.isAfter(r.lastLogin) => // keep the newer one + case _ => recent.update(key, r) + } + } + + /** Remove records older than `expirySeconds` measured by recorded `time` + * (mirrors cleanUp() line 1736-1739). */ + def prune(now: ZonedDateTime, expirySeconds: Long): Unit = + recent.filterInPlace { case (_, r) => java.time.Duration.between(r.time, now).getSeconds < expirySeconds } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tracking/MasslogDetector.scala b/tibia-bot/src/main/scala/com/tibiabot/tracking/MasslogDetector.scala new file mode 100644 index 0000000..090f4e2 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/tracking/MasslogDetector.scala @@ -0,0 +1,38 @@ +package com.tibiabot.tracking + +/** Pure masslog threshold: how many recently-logged-in enemies (`zapCount`) it + * takes, relative to the total enemies online, to flag a "masslog". + * + * Extracted verbatim from the formula in `TibiaBot.onlineList`. `sensitivity` + * is fixed at 0 in the current code; the full table is preserved so behaviour + * is unchanged if it ever becomes configurable. Non-exhaustive cases (matching + * the original) are intentional. + */ +object MasslogDetector { + + val DefaultFloor = 3 + + /** Multiplier applied to the base percentage; lower = more sensitive. */ + def sensitivityModifier(sensitivity: Int): Double = sensitivity match { + case 0 => 1.20 // stricter + case 1 => 1.10 + case 2 => 1.00 // default + case 3 => 0.90 + case 4 => 0.80 // very sensitive + } + + /** Fraction of online enemies that must have just logged in, by enemy count. */ + def basePercentage(enemyCount: Int): Double = enemyCount match { + case n if n <= 5 => 0.60 + case n if n <= 10 => 0.55 + case n if n <= 20 => 0.40 + case _ => 0.32 + } + + /** Minimum number of just-logged-in enemies to trigger a masslog. */ + def requiredZapCount(enemyCount: Int, sensitivity: Int = 0, floor: Int = DefaultFloor): Int = + math.max(floor, math.ceil(enemyCount * basePercentage(enemyCount) * sensitivityModifier(sensitivity)).toInt) + + def isMasslog(zapCount: Int, enemyCount: Int, sensitivity: Int = 0, floor: Int = DefaultFloor): Boolean = + zapCount >= requiredZapCount(enemyCount, sensitivity, floor) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tracking/OnlineTracker.scala b/tibia-bot/src/main/scala/com/tibiabot/tracking/OnlineTracker.scala new file mode 100644 index 0000000..05ac04c --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/tracking/OnlineTracker.scala @@ -0,0 +1,72 @@ +package com.tibiabot.tracking + +import java.time.ZonedDateTime +import scala.collection.mutable + +/** Online-presence state extracted from `TibiaBot.currentOnline`. + * + * OPTIMIZED implementation: keyed by player name in an insertion-ordered map, + * so lookups/updates are O(1) instead of the original O(n) linear scans over a + * `mutable.Set`. The full-cycle rebuild is O(n) instead of O(n^2). Public API + * and observable behaviour are identical to the baseline — locked in by + * OnlineTrackerSpec. + * + * Mirrors TibiaBot.scala: + * - updateFromOnline -> lines 94-105 (duration carry-over + clear/addAll) + * - find -> lines 95, 204, 572, 646 + * - setGuild -> lines 204-208 + * - setFlag -> lines 646-648 + */ +final case class OnlinePlayer( + name: String, + level: Int, + vocation: String, + guildName: String, + time: ZonedDateTime, + duration: Long = 0L, + flag: String = "" +) + +final class OnlineTracker { + // keyed by name; LinkedHashMap keeps a stable order for snapshots (order is + // irrelevant downstream since onlineList re-sorts, but it keeps behaviour + // predictable and tests deterministic). + private val state = mutable.LinkedHashMap.empty[String, OnlinePlayer] + + def size: Int = state.size + def snapshot: List[OnlinePlayer] = state.values.toList + + /** Replace presence from a fresh online list, carrying over guildName / + * duration / flag for players already present. Players absent from `online` + * are dropped (they logged off). Incoming `level` is already parsed to Int, + * exactly as `player.level.toInt` in the flow. */ + def updateFromOnline(online: Seq[(String, Int, String)], now: ZonedDateTime): Unit = { + // build the next state reading from the *current* one, then swap in. + val rebuilt = mutable.LinkedHashMap.empty[String, OnlinePlayer] + online.foreach { case (name, level, vocation) => + val updated = state.get(name) match { + case Some(existing) => + val delta = now.toEpochSecond - existing.time.toEpochSecond + OnlinePlayer(name, level, vocation, existing.guildName, now, existing.duration + delta, existing.flag) + case None => + OnlinePlayer(name, level, vocation, "", now, 0L, "") + } + rebuilt.put(name, updated) + } + state.clear() + state ++= rebuilt + } + + /** Exact, case-sensitive lookup by name (matches `.find(_.name == x)`). */ + def find(name: String): Option[OnlinePlayer] = state.get(name) + + /** Update a player's guild only if it actually changed (lines 204-208). */ + def setGuild(name: String, guildName: String): Unit = + state.get(name).foreach { p => + if (p.guildName != guildName) state.update(name, p.copy(guildName = guildName)) + } + + /** Set a player's flag, e.g. the level-up marker (lines 646-648). */ + def setFlag(name: String, flag: String): Unit = + state.get(name).foreach { p => state.update(name, p.copy(flag = flag)) } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiClient.scala b/tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiClient.scala new file mode 100644 index 0000000..f0f3850 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiClient.scala @@ -0,0 +1,45 @@ +package com.tibiabot.wiki + +import com.tibiabot.domain.BossEntry +import io.circe.parser._ +import sttp.client3._ + +/** Fetches and parses Fandom wiki pages via the api.php parse endpoint. Does no + * I/O on construction; each method performs its own request. */ +final class FandomWikiClient extends WikiClient { + + def dreamScarBosses(): List[BossEntry] = + FandomWikiParser.parseDreamScarBosses(fetchHtml("Dream_Scar/Boss_of_the_Day")) + + def creatureNames(): List[String] = + FandomWikiParser.parseCreatureNames(fetchHtml("List_of_Creatures_(Ordered)")) + + /** Fetch the rendered HTML of a wiki page via the parse API. */ + private def fetchHtml(page: String): String = { + val backend = HttpURLConnectionBackend() + val apiUrl = + "https://tibia.fandom.com/api.php" + + "?action=parse" + + s"&page=$page" + + "&prop=text" + + "&format=json" + val response = basicRequest + .get(uri"$apiUrl") + .header("User-Agent", "Mozilla/5.0") + .send(backend) + val jsonStr = response.body.getOrElse( + throw new RuntimeException("Empty response from API") + ) + val parsed = parse(jsonStr).getOrElse( + throw new RuntimeException("Invalid JSON from API") + ) + parsed.hcursor + .downField("parse") + .downField("text") + .downField("*") + .as[String] + .getOrElse( + throw new RuntimeException("Could not extract HTML from API response") + ) + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiParser.scala b/tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiParser.scala new file mode 100644 index 0000000..cde8653 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/wiki/FandomWikiParser.scala @@ -0,0 +1,51 @@ +package com.tibiabot.wiki + +import com.tibiabot.domain.BossEntry +import org.jsoup.Jsoup + +import scala.jdk.CollectionConverters._ + +/** Pure HTML parsing for the Fandom wiki pages, split out from the HTTP fetch so + * it can be unit-tested with fixture HTML. Logic moved verbatim from BotApp's + * fetchDreamScarBosses / fetchCreatureNames. */ +object FandomWikiParser { + + def parseDreamScarBosses(html: String): List[BossEntry] = { + val doc = Jsoup.parse(html) + val table = doc.select("table.wikitable").first() + if (table == null) return Nil + table.select("tr") + .asScala + .drop(1) + .flatMap { row => + val cols = row.select("td").asScala + if (cols.size >= 2) { + Some(BossEntry(cols(0).text().trim, cols(1).text().trim)) + } else None + } + .toList + } + + def parseCreatureNames(html: String): List[String] = { + val doc = Jsoup.parse(html) + doc.select("a") + .asScala + .flatMap { link => + val href = link.attr("href") + val text = link.text().trim + // creature pages are /wiki/Creature_Name + if ( + href.startsWith("/wiki/") && + text.nonEmpty && + !text.contains(":") && + !href.contains("List_of_Creatures") + ) { + Some(text) + } else { + None + } + } + .distinct + .toList + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/wiki/WikiClient.scala b/tibia-bot/src/main/scala/com/tibiabot/wiki/WikiClient.scala new file mode 100644 index 0000000..79d4764 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/wiki/WikiClient.scala @@ -0,0 +1,12 @@ +package com.tibiabot.wiki + +import com.tibiabot.domain.BossEntry + +/** Port over the Fandom (TibiaWiki) pages the bot scrapes. Constructing an + * implementation must do NO I/O — fetches happen only when these are called. */ +trait WikiClient { + /** The Dream Courts boss-of-the-day table. */ + def dreamScarBosses(): List[BossEntry] + /** The ordered list of creature names. */ + def creatureNames(): List[String] +} diff --git a/tibia-bot/src/test/resources/tibiadata/boostablebosses.json b/tibia-bot/src/test/resources/tibiadata/boostablebosses.json new file mode 100644 index 0000000..b23586a --- /dev/null +++ b/tibia-bot/src/test/resources/tibiadata/boostablebosses.json @@ -0,0 +1 @@ +{"boostable_bosses":{"boosted":{"name":"Goshnar's Spite","image_url":"https://static.tibia.com/images/global/header/monsters/goshnarsspite.gif","featured":true},"boostable_boss_list":[{"name":"Abyssador","image_url":"https://static.tibia.com/images/library/abyssador.gif","featured":false},{"name":"Adventurer Group","image_url":"https://static.tibia.com/images/library/adventurergroup.gif","featured":false},{"name":"Ahau","image_url":"https://static.tibia.com/images/library/ahau.gif","featured":false},{"name":"Amenef The Burning","image_url":"https://static.tibia.com/images/library/ameneftheburning.gif","featured":false},{"name":"Anomaly","image_url":"https://static.tibia.com/images/library/anomaly.gif","featured":false},{"name":"Arbaziloth","image_url":"https://static.tibia.com/images/library/arbaziloth4spheres.gif","featured":false},{"name":"Black Vixen","image_url":"https://static.tibia.com/images/library/vixen.gif","featured":false},{"name":"Blight Mariner","image_url":"https://static.tibia.com/images/library/blightmariner.gif","featured":false},{"name":"Bloodback","image_url":"https://static.tibia.com/images/library/bloodhoof.gif","featured":false},{"name":"Bone Overlord","image_url":"https://static.tibia.com/images/library/boneoverlordbosstiary.gif","featured":false},{"name":"Brain Head","image_url":"https://static.tibia.com/images/library/brainhead.gif","featured":false},{"name":"Brokul","image_url":"https://static.tibia.com/images/library/brokul.gif","featured":false},{"name":"Chagorz","image_url":"https://static.tibia.com/images/library/chagorz.gif","featured":false},{"name":"Count Vlarkorth","image_url":"https://static.tibia.com/images/library/countvlarkort.gif","featured":false},{"name":"Court Warlock","image_url":"https://static.tibia.com/images/library/courtwarlock.gif","featured":false},{"name":"Darkfang","image_url":"https://static.tibia.com/images/library/darkfang.gif","featured":false},{"name":"Deathstrike","image_url":"https://static.tibia.com/images/library/deathstrike.gif","featured":false},{"name":"Dragon Pack","image_url":"https://static.tibia.com/images/library/representingdespor.gif","featured":false},{"name":"Drume","image_url":"https://static.tibia.com/images/library/drume.gif","featured":false},{"name":"Duke Krule","image_url":"https://static.tibia.com/images/library/dukekrule.gif","featured":false},{"name":"Earl Osam","image_url":"https://static.tibia.com/images/library/earlosam.gif","featured":false},{"name":"Ekatrix","image_url":"https://static.tibia.com/images/library/ekatrix.gif","featured":false},{"name":"Eldritch Dragon Lord","image_url":"https://static.tibia.com/images/library/eldritchdragonlord.gif","featured":false},{"name":"Eradicator","image_url":"https://static.tibia.com/images/library/eradicator.gif","featured":false},{"name":"Essence Of Malice","image_url":"https://static.tibia.com/images/library/essenceofmalice.gif","featured":false},{"name":"Faceless Bane","image_url":"https://static.tibia.com/images/library/facelessbane.gif","featured":false},{"name":"Ghulosh","image_url":"https://static.tibia.com/images/library/ghulosh.gif","featured":false},{"name":"Gnomevil","image_url":"https://static.tibia.com/images/library/gnomehorticulist.gif","featured":false},{"name":"Gorzindel","image_url":"https://static.tibia.com/images/library/gorzindel.gif","featured":false},{"name":"Goshnar's Cruelty","image_url":"https://static.tibia.com/images/library/goshnarscruelty.gif","featured":false},{"name":"Goshnar's Greed","image_url":"https://static.tibia.com/images/library/goshnarsgreed.gif","featured":false},{"name":"Goshnar's Hatred","image_url":"https://static.tibia.com/images/library/goshnarshatred.gif","featured":false},{"name":"Goshnar's Malice","image_url":"https://static.tibia.com/images/library/goshnarsmalice.gif","featured":false},{"name":"Goshnar's Spite","image_url":"https://static.tibia.com/images/library/goshnarsspite.gif","featured":true},{"name":"Grand Master Oberon","image_url":"https://static.tibia.com/images/library/grandmasteroberon.gif","featured":false},{"name":"Ice Horror","image_url":"https://static.tibia.com/images/library/icehorror.gif","featured":false},{"name":"Ichgahal","image_url":"https://static.tibia.com/images/library/ichgahal.gif","featured":false},{"name":"Irgix The Flimsy","image_url":"https://static.tibia.com/images/library/irgix.gif","featured":false},{"name":"Katex Blood Tongue","image_url":"https://static.tibia.com/images/library/katex.gif","featured":false},{"name":"King Zelos","image_url":"https://static.tibia.com/images/library/kingzelos.gif","featured":false},{"name":"Kusuma","image_url":"https://static.tibia.com/images/library/kusuma.gif","featured":false},{"name":"Lady Tenebris","image_url":"https://static.tibia.com/images/library/ladytenebris.gif","featured":false},{"name":"Lloyd","image_url":"https://static.tibia.com/images/library/lloyd.gif","featured":false},{"name":"Lokathmor","image_url":"https://static.tibia.com/images/library/lokathmor.gif","featured":false},{"name":"Lord Azaram","image_url":"https://static.tibia.com/images/library/lordazaram.gif","featured":false},{"name":"Lord Retro","image_url":"https://static.tibia.com/images/library/lordretro.gif","featured":false},{"name":"Magma Bubble","image_url":"https://static.tibia.com/images/library/magmacolossus.gif","featured":false},{"name":"Mazoran","image_url":"https://static.tibia.com/images/library/mazoran.gif","featured":false},{"name":"Mazzinor","image_url":"https://static.tibia.com/images/library/mazzinor.gif","featured":false},{"name":"Megasylvan Yselda","image_url":"https://static.tibia.com/images/library/yselda.gif","featured":false},{"name":"Melting Frozen Horror","image_url":"https://static.tibia.com/images/library/frozenhorror.gif","featured":false},{"name":"Mitmah Vanguard","image_url":"https://static.tibia.com/images/library/mitmahvanguard.gif","featured":false},{"name":"Murcion","image_url":"https://static.tibia.com/images/library/murcion.gif","featured":false},{"name":"Neferi The Spy","image_url":"https://static.tibia.com/images/library/neferithespy.gif","featured":false},{"name":"Outburst","image_url":"https://static.tibia.com/images/library/outburst.gif","featured":false},{"name":"Plagirath","image_url":"https://static.tibia.com/images/library/plagirath.gif","featured":false},{"name":"Ragiaz","image_url":"https://static.tibia.com/images/library/ragiaz.gif","featured":false},{"name":"Ratmiral Blackwhiskers","image_url":"https://static.tibia.com/images/library/ratmiral.gif","featured":false},{"name":"Ravenous Hunger","image_url":"https://static.tibia.com/images/library/ravenoushunger.gif","featured":false},{"name":"Razzagorn","image_url":"https://static.tibia.com/images/library/razzargorn.gif","featured":false},{"name":"Realityquake","image_url":"https://static.tibia.com/images/library/realityquake.gif","featured":false},{"name":"Rupture","image_url":"https://static.tibia.com/images/library/rupture.gif","featured":false},{"name":"Scarlett Etzel","image_url":"https://static.tibia.com/images/library/scarlettetzelstill.gif","featured":false},{"name":"Shadowpelt","image_url":"https://static.tibia.com/images/library/blackpelt.gif","featured":false},{"name":"Sharpclaw","image_url":"https://static.tibia.com/images/library/sharpclaw.gif","featured":false},{"name":"Shulgrax","image_url":"https://static.tibia.com/images/library/shulgrax.gif","featured":false},{"name":"Sir Baeloc","image_url":"https://static.tibia.com/images/library/sirbaeloc.gif","featured":false},{"name":"Sir Nictros","image_url":"https://static.tibia.com/images/library/sirnictros.gif","featured":false},{"name":"Sister Hetai","image_url":"https://static.tibia.com/images/library/sisterhetai.gif","featured":false},{"name":"Soul Of Dragonking Zyrtarch","image_url":"https://static.tibia.com/images/library/dragonkingzyrtrachkillable.gif","featured":false},{"name":"Srezz Yellow Eyes","image_url":"https://static.tibia.com/images/library/srezz.gif","featured":false},{"name":"Tarbaz","image_url":"https://static.tibia.com/images/library/tarbaz.gif","featured":false},{"name":"Tentugly","image_url":"https://static.tibia.com/images/library/fakeseamonster.gif","featured":false},{"name":"Thaian","image_url":"https://static.tibia.com/images/library/thaian.gif","featured":false},{"name":"The Blazing Rose","image_url":"https://static.tibia.com/images/library/blazingrose.gif","featured":false},{"name":"The Brainstealer","image_url":"https://static.tibia.com/images/library/brainstealer.gif","featured":false},{"name":"The Diamond Blossom","image_url":"https://static.tibia.com/images/library/diamondblossom.gif","featured":false},{"name":"The Dread Maiden","image_url":"https://static.tibia.com/images/library/dreadmaiden.gif","featured":false},{"name":"The Enraged Thorn Knight","image_url":"https://static.tibia.com/images/library/thornknight.gif","featured":false},{"name":"The False God","image_url":"https://static.tibia.com/images/library/falsegod.gif","featured":false},{"name":"The Fear Feaster","image_url":"https://static.tibia.com/images/library/fearfeaster.gif","featured":false},{"name":"The Flaming Orchid","image_url":"https://static.tibia.com/images/library/flamingorchid.gif","featured":false},{"name":"The Gravedigger","image_url":"https://static.tibia.com/images/library/thegravedigger.gif","featured":false},{"name":"The Lily Of Night","image_url":"https://static.tibia.com/images/library/lilyofnight.gif","featured":false},{"name":"The Mega Magmaoid","image_url":"https://static.tibia.com/images/library/megamagmaoid.gif","featured":false},{"name":"The Monster","image_url":"https://static.tibia.com/images/library/themonster.gif","featured":false},{"name":"The Moonlight Aster","image_url":"https://static.tibia.com/images/library/moonlightaster.gif","featured":false},{"name":"The Nightmare Beast","image_url":"https://static.tibia.com/images/library/nightmarebeast.gif","featured":false},{"name":"The Pale Worm","image_url":"https://static.tibia.com/images/library/paleworm.gif","featured":false},{"name":"The Rootkraken","image_url":"https://static.tibia.com/images/library/rootkraken.gif","featured":false},{"name":"The Sandking","image_url":"https://static.tibia.com/images/library/sandkingfinal.gif","featured":false},{"name":"The Scourge Of Oblivion","image_url":"https://static.tibia.com/images/library/scourgeofoblivion00.gif","featured":false},{"name":"The Souldespoiler","image_url":"https://static.tibia.com/images/library/souldespoiler.gif","featured":false},{"name":"The Source Of Corruption","image_url":"https://static.tibia.com/images/library/sourceofcorruption.gif","featured":false},{"name":"The Time Guardian","image_url":"https://static.tibia.com/images/library/timeguardian.gif","featured":false},{"name":"The Unarmored Voidborn","image_url":"https://static.tibia.com/images/library/voidbornvulnerable.gif","featured":false},{"name":"The Unwelcome","image_url":"https://static.tibia.com/images/library/theunwelcome.gif","featured":false},{"name":"The Winter Bloom","image_url":"https://static.tibia.com/images/library/winterbloom.gif","featured":false},{"name":"Timira The Many-Headed","image_url":"https://static.tibia.com/images/library/timira.gif","featured":false},{"name":"Tropical Desolator","image_url":"https://static.tibia.com/images/library/tropicaldesolator.gif","featured":false},{"name":"Unaz The Mean","image_url":"https://static.tibia.com/images/library/unaz.gif","featured":false},{"name":"Urmahlullu The Weakened","image_url":"https://static.tibia.com/images/library/urmahlulluweakest.gif","featured":false},{"name":"Utua Stone Sting","image_url":"https://static.tibia.com/images/library/utua.gif","featured":false},{"name":"Vemiath","image_url":"https://static.tibia.com/images/library/vemiath.gif","featured":false},{"name":"Vladrukh","image_url":"https://static.tibia.com/images/library/vladrukh.gif","featured":false},{"name":"Vok The Freakish","image_url":"https://static.tibia.com/images/library/vok.gif","featured":false},{"name":"Wrathful Archivist","image_url":"https://static.tibia.com/images/library/wrathfularchivist.gif","featured":false},{"name":"Yirkas Blue Scales","image_url":"https://static.tibia.com/images/library/yirkass.gif","featured":false},{"name":"Zamulosh","image_url":"https://static.tibia.com/images/library/zamulosh.gif","featured":false}]},"information":{"api":{"version":4,"release":"4.8.0","commit":"e3963de0848d8ce9d368a9cd54b306ccfddd0d15"},"timestamp":"2026-05-30T14:52:06Z","tibia_urls":["https://www.tibia.com/library/?subtopic=boostablebosses"],"status":{"http_code":200}}} \ No newline at end of file diff --git a/tibia-bot/src/test/resources/tibiadata/character.json b/tibia-bot/src/test/resources/tibiadata/character.json new file mode 100644 index 0000000..a57f592 --- /dev/null +++ b/tibia-bot/src/test/resources/tibiadata/character.json @@ -0,0 +1 @@ +{"character":{"character":{"name":"Abu Shusha","sex":"male","title":"None","unlocked_titles":9,"vocation":"Exalted Monk","level":131,"achievement_points":12,"world":"Antica","residence":"Yalahar","guild":{},"last_login":"2026-05-30T01:08:21Z","account_status":"Premium Account"},"deaths":[{"time":"2026-05-29T22:18:28Z","level":130,"killers":[{"name":"mammoth","player":false,"traded":false,"summon":""}],"assists":[],"reason":"Died at Level 130 by mammoth."},{"time":"2026-05-23T11:32:36Z","level":128,"killers":[{"name":"wyrm","player":false,"traded":false,"summon":""}],"assists":[],"reason":"Died at Level 128 by wyrm."},{"time":"2026-05-23T11:00:39Z","level":128,"killers":[{"name":"wyrm","player":false,"traded":false,"summon":""}],"assists":[],"reason":"Died at Level 128 by wyrm."},{"time":"2026-05-21T20:56:22Z","level":126,"killers":[{"name":"wyrm","player":false,"traded":false,"summon":""}],"assists":[],"reason":"Died at Level 126 by wyrm."}],"account_information":{"created":"2019-10-15T23:00:07Z","loyalty_title":"Warrior of Tibia"},"other_characters":[{"name":"Abu Shusha","world":"Antica","status":"online","deleted":false,"main":false,"traded":false},{"name":"Bezz Elfil","world":"Antica","status":"offline","deleted":false,"main":false,"traded":false},{"name":"Bezz Elkalb","world":"Antica","status":"offline","deleted":false,"main":false,"traded":false},{"name":"Elharam","world":"Antica","status":"offline","deleted":false,"main":false,"traded":false},{"name":"Omm Abdu","world":"Antica","status":"offline","deleted":false,"main":false,"traded":false},{"name":"Zobrelhara","world":"Antica","status":"offline","deleted":false,"main":true,"traded":false}]},"information":{"api":{"version":4,"release":"4.8.0","commit":"e3963de0848d8ce9d368a9cd54b306ccfddd0d15"},"timestamp":"2026-05-30T14:57:58Z","tibia_urls":["https://www.tibia.com/community/?subtopic=characters\u0026name=Abu+Shusha"],"status":{"http_code":200}}} \ No newline at end of file diff --git a/tibia-bot/src/test/resources/tibiadata/creatures.json b/tibia-bot/src/test/resources/tibiadata/creatures.json new file mode 100644 index 0000000..ef682f6 --- /dev/null +++ b/tibia-bot/src/test/resources/tibiadata/creatures.json @@ -0,0 +1 @@ +{"creatures":{"boosted":{"name":"Tiger","race":"tiger","image_url":"https://static.tibia.com/images/global/header/monsters/tiger.gif","featured":true},"creature_list":[{"name":"Acid Blobs","race":"acidblob","image_url":"https://static.tibia.com/images/library/acidblob.gif","featured":false},{"name":"Acolytes Of The Cult","race":"cultacolyte","image_url":"https://static.tibia.com/images/library/cultacolyte.gif","featured":false},{"name":"Adepts Of The Cult","race":"cultadept","image_url":"https://static.tibia.com/images/library/cultadept.gif","featured":false},{"name":"Adult Goannas","race":"adultgoanna","image_url":"https://static.tibia.com/images/library/adultgoanna.gif","featured":false},{"name":"Afflicted Striders","race":"afflictedstrider","image_url":"https://static.tibia.com/images/library/afflictedstrider.gif","featured":false},{"name":"Amazons","race":"amazon","image_url":"https://static.tibia.com/images/library/amazon.gif","featured":false},{"name":"Ancient Scarabs","race":"ancientscarab","image_url":"https://static.tibia.com/images/library/ancientscarab.gif","featured":false},{"name":"Angry Sugar Fairies","race":"angrysugarfairy","image_url":"https://static.tibia.com/images/library/angrysugarfairy.gif","featured":false},{"name":"Animated Feathers","race":"animatedfeather","image_url":"https://static.tibia.com/images/library/animatedfeather.gif","featured":false},{"name":"Arachnophobicas","race":"arachnophobica","image_url":"https://static.tibia.com/images/library/arachnophobica.gif","featured":false},{"name":"Arctic Fauns","race":"arcticfaun","image_url":"https://static.tibia.com/images/library/arcticfaun.gif","featured":false},{"name":"Armadiles","race":"armadile","image_url":"https://static.tibia.com/images/library/armadile.gif","featured":false},{"name":"Assassins","race":"assassin","image_url":"https://static.tibia.com/images/library/assassin.gif","featured":false},{"name":"Azure Frogs","race":"frogazure","image_url":"https://static.tibia.com/images/library/frogazure.gif","featured":false},{"name":"Badgers","race":"badger","image_url":"https://static.tibia.com/images/library/badger.gif","featured":false},{"name":"Bandits","race":"bandit","image_url":"https://static.tibia.com/images/library/bandit.gif","featured":false},{"name":"Banshees","race":"banshee","image_url":"https://static.tibia.com/images/library/banshee.gif","featured":false},{"name":"Barbarian Bloodwalkers","race":"barbarianbloodwalker","image_url":"https://static.tibia.com/images/library/barbarianbloodwalker.gif","featured":false},{"name":"Barbarian Brutetamers","race":"barbarianbrutetamer","image_url":"https://static.tibia.com/images/library/barbarianbrutetamer.gif","featured":false},{"name":"Barbarian Headsplitters","race":"barbarianheadsplitter","image_url":"https://static.tibia.com/images/library/barbarianheadsplitter.gif","featured":false},{"name":"Barbarian Skullhunters","race":"barbarianskullhunter","image_url":"https://static.tibia.com/images/library/barbarianskullhunter.gif","featured":false},{"name":"Barkless Devotees","race":"barklessdevotee","image_url":"https://static.tibia.com/images/library/barklessdevotee.gif","featured":false},{"name":"Barkless Fanatics","race":"barklessfanatic","image_url":"https://static.tibia.com/images/library/barklessfanatic.gif","featured":false},{"name":"Bashmus","race":"bashmu","image_url":"https://static.tibia.com/images/library/bashmu.gif","featured":false},{"name":"Bats","race":"bat","image_url":"https://static.tibia.com/images/library/bat.gif","featured":false},{"name":"Bears","race":"bear","image_url":"https://static.tibia.com/images/library/bear.gif","featured":false},{"name":"Behemoths","race":"behemoth","image_url":"https://static.tibia.com/images/library/behemoth.gif","featured":false},{"name":"Betrayed Wraiths","race":"wraith","image_url":"https://static.tibia.com/images/library/wraith.gif","featured":false},{"name":"Biting Books","race":"bitingbook","image_url":"https://static.tibia.com/images/library/bitingbook.gif","featured":false},{"name":"Black Knights","race":"blackknight","image_url":"https://static.tibia.com/images/library/blackknight.gif","featured":false},{"name":"Black Sphinx Acolytes","race":"blacksphinxacolyte","image_url":"https://static.tibia.com/images/library/blacksphinxacolyte.gif","featured":false},{"name":"Blemished Spawns","race":"blemishedspawn","image_url":"https://static.tibia.com/images/library/blemishedspawn.gif","featured":false},{"name":"Blightwalkers","race":"blightwalker","image_url":"https://static.tibia.com/images/library/blightwalker.gif","featured":false},{"name":"Bloated Man-maggots","race":"bloatedmanmaggot","image_url":"https://static.tibia.com/images/library/bloatedmanmaggot.gif","featured":false},{"name":"Blood Beasts","race":"bloodbeast","image_url":"https://static.tibia.com/images/library/bloodbeast.gif","featured":false},{"name":"Blood Crabs","race":"bloodcrab","image_url":"https://static.tibia.com/images/library/bloodcrab.gif","featured":false},{"name":"Blood Hands","race":"bloodhand","image_url":"https://static.tibia.com/images/library/bloodhand.gif","featured":false},{"name":"Blood Priests","race":"bloodpriest","image_url":"https://static.tibia.com/images/library/bloodpriest.gif","featured":false},{"name":"Blue Djinns","race":"bluedjinn","image_url":"https://static.tibia.com/images/library/bluedjinn.gif","featured":false},{"name":"Bluebeaks","race":"bluebeak","image_url":"https://static.tibia.com/images/library/bluebeak.gif","featured":false},{"name":"Boar Men","race":"boarman","image_url":"https://static.tibia.com/images/library/boarman.gif","featured":false},{"name":"Boars","race":"boar","image_url":"https://static.tibia.com/images/library/boar.gif","featured":false},{"name":"Bog Frogs","race":"bogfrog","image_url":"https://static.tibia.com/images/library/bogfrog.gif","featured":false},{"name":"Bog Raiders","race":"bograider","image_url":"https://static.tibia.com/images/library/bograider.gif","featured":false},{"name":"Bonebeasts","race":"bonebeast","image_url":"https://static.tibia.com/images/library/bonebeast.gif","featured":false},{"name":"Bonelords","race":"bonelord","image_url":"https://static.tibia.com/images/library/bonelord.gif","featured":false},{"name":"Bony Sea Devils","race":"bonyseadevil","image_url":"https://static.tibia.com/images/library/bonyseadevil.gif","featured":false},{"name":"Boogies","race":"boogy","image_url":"https://static.tibia.com/images/library/boogy.gif","featured":false},{"name":"Brachiodemons","race":"brachiodemon","image_url":"https://static.tibia.com/images/library/brachiodemon.gif","featured":false},{"name":"Brain Squids","race":"brainsquid","image_url":"https://static.tibia.com/images/library/brainsquid.gif","featured":false},{"name":"Braindeaths","race":"braindeath","image_url":"https://static.tibia.com/images/library/braindeath.gif","featured":false},{"name":"Bramble Wyrmlings","race":"bramblewyrmling","image_url":"https://static.tibia.com/images/library/bramblewyrmling.gif","featured":false},{"name":"Branchy Crawlers","race":"ghostlycrawler","image_url":"https://static.tibia.com/images/library/ghostlycrawler.gif","featured":false},{"name":"Breach Broods","race":"breachbrood","image_url":"https://static.tibia.com/images/library/breachbrood.gif","featured":false},{"name":"Brimstone Bugs","race":"brimstonebug","image_url":"https://static.tibia.com/images/library/brimstonebug.gif","featured":false},{"name":"Brinebrute Inferniarches","race":"brinebruteinferniarch","image_url":"https://static.tibia.com/images/library/brinebruteinferniarch.gif","featured":false},{"name":"Broken Shapers","race":"degeneratedshaper","image_url":"https://static.tibia.com/images/library/degeneratedshaper.gif","featured":false},{"name":"Broodrider Inferniarches","race":"broodriderinferniarch","image_url":"https://static.tibia.com/images/library/broodriderinferniarch.gif","featured":false},{"name":"Bugs","race":"bug","image_url":"https://static.tibia.com/images/library/bug.gif","featured":false},{"name":"Bulltaur Alchemists","race":"bulltauralchemist","image_url":"https://static.tibia.com/images/library/bulltauralchemist.gif","featured":false},{"name":"Bulltaur Brutes","race":"bulltaurbrute","image_url":"https://static.tibia.com/images/library/bulltaurbrute.gif","featured":false},{"name":"Bulltaur Forgepriests","race":"bulltaurforgepriest","image_url":"https://static.tibia.com/images/library/bulltaurforgepriest.gif","featured":false},{"name":"Burning Books","race":"burningcursedbook","image_url":"https://static.tibia.com/images/library/burningcursedbook.gif","featured":false},{"name":"Burning Gladiators","race":"burninggladiator","image_url":"https://static.tibia.com/images/library/burninggladiator.gif","featured":false},{"name":"Burster Spectres","race":"bursterspectre","image_url":"https://static.tibia.com/images/library/bursterspectre.gif","featured":false},{"name":"Butterflies","race":"butterflypurple","image_url":"https://static.tibia.com/images/library/butterflypurple.gif","featured":false},{"name":"Calamaries","race":"calamary","image_url":"https://static.tibia.com/images/library/calamary.gif","featured":false},{"name":"Candy Floss Elementals","race":"candyflosselemental","image_url":"https://static.tibia.com/images/library/candyflosselemental.gif","featured":false},{"name":"Candy Horrors","race":"candyhorror","image_url":"https://static.tibia.com/images/library/candyhorror.gif","featured":false},{"name":"Capricious Phantoms","race":"capriciousphantom","image_url":"https://static.tibia.com/images/library/capriciousphantom.gif","featured":false},{"name":"Carniphilas","race":"carniphila","image_url":"https://static.tibia.com/images/library/carniphila.gif","featured":false},{"name":"Carnisylvan Saplings","race":"carnisylvansapling","image_url":"https://static.tibia.com/images/library/carnisylvansapling.gif","featured":false},{"name":"Carnivostriches","race":"carnivostrich","image_url":"https://static.tibia.com/images/library/carnivostrich.gif","featured":false},{"name":"Carrion Worms","race":"carrionworm","image_url":"https://static.tibia.com/images/library/carrionworm.gif","featured":false},{"name":"Cats","race":"cat","image_url":"https://static.tibia.com/images/library/cat.gif","featured":false},{"name":"Cave Chimeras","race":"cavechimera","image_url":"https://static.tibia.com/images/library/cavechimera.gif","featured":false},{"name":"Cave Devourers","race":"cavedevourer","image_url":"https://static.tibia.com/images/library/cavedevourer.gif","featured":false},{"name":"Cave Rats","race":"caverat","image_url":"https://static.tibia.com/images/library/caverat.gif","featured":false},{"name":"Centipedes","race":"centipede","image_url":"https://static.tibia.com/images/library/centipede.gif","featured":false},{"name":"Chakoya Toolshapers","race":"chakoyatoolshaper","image_url":"https://static.tibia.com/images/library/chakoyatoolshaper.gif","featured":false},{"name":"Chakoya Tribewardens","race":"chakoyatribewarden","image_url":"https://static.tibia.com/images/library/chakoyatribewarden.gif","featured":false},{"name":"Chakoya Windcallers","race":"chakoyawindcaller","image_url":"https://static.tibia.com/images/library/chakoyawindcaller.gif","featured":false},{"name":"Chasm Spawns","race":"chasmspawn","image_url":"https://static.tibia.com/images/library/chasmspawn.gif","featured":false},{"name":"Chickens","race":"chicken","image_url":"https://static.tibia.com/images/library/chicken.gif","featured":false},{"name":"Chocolate Blobs","race":"chocolateblob","image_url":"https://static.tibia.com/images/library/chocolateblob.gif","featured":false},{"name":"Choking Fears","race":"chokingfear","image_url":"https://static.tibia.com/images/library/chokingfear.gif","featured":false},{"name":"Cinder Wyrmlings","race":"cinderwyrmling","image_url":"https://static.tibia.com/images/library/cinderwyrmling.gif","featured":false},{"name":"Clay Guardians","race":"clayguardian","image_url":"https://static.tibia.com/images/library/clayguardian.gif","featured":false},{"name":"Cliff Striders","race":"cliffstrider","image_url":"https://static.tibia.com/images/library/cliffstrider.gif","featured":false},{"name":"Cloaks Of Terror","race":"cloakofterror","image_url":"https://static.tibia.com/images/library/cloakofterror.gif","featured":false},{"name":"Clomps","race":"clomp","image_url":"https://static.tibia.com/images/library/clomp.gif","featured":false},{"name":"Cobra Assassins","race":"cobraassassin","image_url":"https://static.tibia.com/images/library/cobraassassin.gif","featured":false},{"name":"Cobra Scouts","race":"cobrascout","image_url":"https://static.tibia.com/images/library/cobrascout.gif","featured":false},{"name":"Cobra Viziers","race":"cobravizier","image_url":"https://static.tibia.com/images/library/cobravizier.gif","featured":false},{"name":"Cobras","race":"cobra","image_url":"https://static.tibia.com/images/library/cobra.gif","featured":false},{"name":"Converters","race":"converter","image_url":"https://static.tibia.com/images/library/converter.gif","featured":false},{"name":"Corym Charlatans","race":"charlatan","image_url":"https://static.tibia.com/images/library/charlatan.gif","featured":false},{"name":"Corym Skirmishers","race":"skirmisher","image_url":"https://static.tibia.com/images/library/skirmisher.gif","featured":false},{"name":"Corym Vanguards","race":"vanguard","image_url":"https://static.tibia.com/images/library/vanguard.gif","featured":false},{"name":"Courage Leeches","race":"courageleech","image_url":"https://static.tibia.com/images/library/courageleech.gif","featured":false},{"name":"Crabs","race":"crab","image_url":"https://static.tibia.com/images/library/crab.gif","featured":false},{"name":"Crape Men","race":"crapeman","image_url":"https://static.tibia.com/images/library/crapeman.gif","featured":false},{"name":"Crawlers","race":"crawler","image_url":"https://static.tibia.com/images/library/crawler.gif","featured":false},{"name":"Crazed Beggars","race":"crazedbeggar","image_url":"https://static.tibia.com/images/library/crazedbeggar.gif","featured":false},{"name":"Crazed Summer Rearguards","race":"crazedsummerrearguard","image_url":"https://static.tibia.com/images/library/crazedsummerrearguard.gif","featured":false},{"name":"Crazed Summer Vanguards","race":"crazedsummervanguard","image_url":"https://static.tibia.com/images/library/crazedsummervanguard.gif","featured":false},{"name":"Crazed Winter Rearguards","race":"crazedwinterrearguard","image_url":"https://static.tibia.com/images/library/crazedwinterrearguard.gif","featured":false},{"name":"Crazed Winter Vanguards","race":"crazedwintervanguard","image_url":"https://static.tibia.com/images/library/crazedwintervanguard.gif","featured":false},{"name":"Cream Blobs","race":"creamblob","image_url":"https://static.tibia.com/images/library/creamblob.gif","featured":false},{"name":"Creepy Crawlers","race":"creepycrawler","image_url":"https://static.tibia.com/images/library/creepycrawler.gif","featured":false},{"name":"Crocodiles","race":"crocodile","image_url":"https://static.tibia.com/images/library/crocodile.gif","featured":false},{"name":"Crusaders","race":"crusader","image_url":"https://static.tibia.com/images/library/crusader.gif","featured":false},{"name":"Crustaceae Giganticae","race":"crustaceagigantica","image_url":"https://static.tibia.com/images/library/crustaceagigantica.gif","featured":false},{"name":"Crypt Constructs","race":"cryptconstruct","image_url":"https://static.tibia.com/images/library/cryptconstruct.gif","featured":false},{"name":"Crypt Defilers","race":"cryptdefiler","image_url":"https://static.tibia.com/images/library/cryptdefiler.gif","featured":false},{"name":"Crypt Fiends","race":"cryptfiend","image_url":"https://static.tibia.com/images/library/cryptfiend.gif","featured":false},{"name":"Crypt Mages","race":"cryptmage","image_url":"https://static.tibia.com/images/library/cryptmage.gif","featured":false},{"name":"Crypt Shamblers","race":"cryptshambler","image_url":"https://static.tibia.com/images/library/cryptshambler.gif","featured":false},{"name":"Crypt Wardens","race":"cryptwarden","image_url":"https://static.tibia.com/images/library/cryptwarden.gif","featured":false},{"name":"Crypt Warriors","race":"cryptwarrior","image_url":"https://static.tibia.com/images/library/cryptwarrior.gif","featured":false},{"name":"Crystal Spiders","race":"crystalspider","image_url":"https://static.tibia.com/images/library/crystalspider.gif","featured":false},{"name":"Crystalcrushers","race":"crystalcrusher","image_url":"https://static.tibia.com/images/library/crystalcrusher.gif","featured":false},{"name":"Cult Believers","race":"cultbeliever","image_url":"https://static.tibia.com/images/library/cultbeliever.gif","featured":false},{"name":"Cult Enforcers","race":"cultenforcer","image_url":"https://static.tibia.com/images/library/cultenforcer.gif","featured":false},{"name":"Cult Scholars","race":"cultscholar","image_url":"https://static.tibia.com/images/library/cultscholar.gif","featured":false},{"name":"Cunning Werepanthers","race":"cunningwerepanther","image_url":"https://static.tibia.com/images/library/cunningwerepanther.gif","featured":false},{"name":"Cursed Apes","race":"cursedape","image_url":"https://static.tibia.com/images/library/cursedape.gif","featured":false},{"name":"Cursed Books","race":"cursedbook","image_url":"https://static.tibia.com/images/library/cursedbook.gif","featured":false},{"name":"Cursed Prospectors","race":"cursedprospector","image_url":"https://static.tibia.com/images/library/cursedprospector.gif","featured":false},{"name":"Cyclopes","race":"cyclops","image_url":"https://static.tibia.com/images/library/cyclops.gif","featured":false},{"name":"Cyclopes Drone","race":"cyclopsdrone","image_url":"https://static.tibia.com/images/library/cyclopsdrone.gif","featured":false},{"name":"Cyclopes Smith","race":"cyclopssmith","image_url":"https://static.tibia.com/images/library/cyclopssmith.gif","featured":false},{"name":"Cyclursuses","race":"cyclursus","image_url":"https://static.tibia.com/images/library/cyclursus.gif","featured":false},{"name":"Dark Apprentices","race":"darkapprentice","image_url":"https://static.tibia.com/images/library/darkapprentice.gif","featured":false},{"name":"Dark Carnisylvans","race":"carnisylvandark","image_url":"https://static.tibia.com/images/library/carnisylvandark.gif","featured":false},{"name":"Dark Fauns","race":"darkfaun","image_url":"https://static.tibia.com/images/library/darkfaun.gif","featured":false},{"name":"Dark Magicians","race":"darkmagician","image_url":"https://static.tibia.com/images/library/darkmagician.gif","featured":false},{"name":"Dark Monks","race":"darkmonk","image_url":"https://static.tibia.com/images/library/darkmonk.gif","featured":false},{"name":"Dark Torturers","race":"darktorturer","image_url":"https://static.tibia.com/images/library/darktorturer.gif","featured":false},{"name":"Darklight Constructs","race":"darklightconstruct","image_url":"https://static.tibia.com/images/library/darklightconstruct.gif","featured":false},{"name":"Darklight Emitters","race":"darklightemitter","image_url":"https://static.tibia.com/images/library/darklightemitter.gif","featured":false},{"name":"Darklight Matters","race":"darklightmatter","image_url":"https://static.tibia.com/images/library/darklightmatter.gif","featured":false},{"name":"Darklight Sources","race":"darklightsource","image_url":"https://static.tibia.com/images/library/darklightsource.gif","featured":false},{"name":"Darklight Strikers","race":"darklightstriker","image_url":"https://static.tibia.com/images/library/darklightstriker.gif","featured":false},{"name":"Dawnfire Asuras","race":"asura","image_url":"https://static.tibia.com/images/library/asura.gif","featured":false},{"name":"Death Blobs","race":"deathblob","image_url":"https://static.tibia.com/images/library/deathblob.gif","featured":false},{"name":"Deathling Scouts","race":"deathlingscout","image_url":"https://static.tibia.com/images/library/deathlingscout.gif","featured":false},{"name":"Deathling Spellsingers","race":"deathlingspellsinger","image_url":"https://static.tibia.com/images/library/deathlingspellsinger.gif","featured":false},{"name":"Deepling Guards","race":"deeplingguard","image_url":"https://static.tibia.com/images/library/deeplingguard.gif","featured":false},{"name":"Deepling Scouts","race":"deeplingscout","image_url":"https://static.tibia.com/images/library/deeplingscout.gif","featured":false},{"name":"Deepling Spellsingers","race":"deeplingspellsinger","image_url":"https://static.tibia.com/images/library/deeplingspellsinger.gif","featured":false},{"name":"Deepling Warriors","race":"deeplingwarrior","image_url":"https://static.tibia.com/images/library/deeplingwarrior.gif","featured":false},{"name":"Deepling Workers","race":"deeplingworker","image_url":"https://static.tibia.com/images/library/deeplingworker.gif","featured":false},{"name":"Deepworms","race":"deepworm","image_url":"https://static.tibia.com/images/library/deepworm.gif","featured":false},{"name":"Deer","race":"deer","image_url":"https://static.tibia.com/images/library/deer.gif","featured":false},{"name":"Defilers","race":"defiler","image_url":"https://static.tibia.com/images/library/defiler.gif","featured":false},{"name":"Demon Outcasts","race":"demonoutcast","image_url":"https://static.tibia.com/images/library/demonoutcast.gif","featured":false},{"name":"Demon Skeletons","race":"demonskeleton","image_url":"https://static.tibia.com/images/library/demonskeleton.gif","featured":false},{"name":"Demons","race":"demon","image_url":"https://static.tibia.com/images/library/demon.gif","featured":false},{"name":"Destroyers","race":"destroyer","image_url":"https://static.tibia.com/images/library/destroyer.gif","featured":false},{"name":"Devourers","race":"devourer","image_url":"https://static.tibia.com/images/library/devourer.gif","featured":false},{"name":"Diabolic Imps","race":"diabolicimp","image_url":"https://static.tibia.com/images/library/diabolicimp.gif","featured":false},{"name":"Diamond Servants","race":"diamondservant","image_url":"https://static.tibia.com/images/library/diamondservant.gif","featured":false},{"name":"Diremaws","race":"diremaw","image_url":"https://static.tibia.com/images/library/diremaw.gif","featured":false},{"name":"Distorted Phantoms","race":"distortedphantom","image_url":"https://static.tibia.com/images/library/distortedphantom.gif","featured":false},{"name":"Dogs","race":"dog","image_url":"https://static.tibia.com/images/library/dog.gif","featured":false},{"name":"Dragolisks","race":"dragolisk","image_url":"https://static.tibia.com/images/library/dragolisk.gif","featured":false},{"name":"Dragon Hatchlings","race":"dragonhatchling","image_url":"https://static.tibia.com/images/library/dragonhatchling.gif","featured":false},{"name":"Dragon Lord Hatchlings","race":"dragonlordhatchling","image_url":"https://static.tibia.com/images/library/dragonlordhatchling.gif","featured":false},{"name":"Dragon Lords","race":"dragonlord","image_url":"https://static.tibia.com/images/library/dragonlord.gif","featured":false},{"name":"Dragonlings","race":"dragonling","image_url":"https://static.tibia.com/images/library/dragonling.gif","featured":false},{"name":"Dragons","race":"dragon","image_url":"https://static.tibia.com/images/library/dragon.gif","featured":false},{"name":"Draken Abominations","race":"drakenabomination","image_url":"https://static.tibia.com/images/library/drakenabomination.gif","featured":false},{"name":"Draken Elites","race":"drakenelite","image_url":"https://static.tibia.com/images/library/drakenelite.gif","featured":false},{"name":"Draken Spellweavers","race":"drakenspellweaver","image_url":"https://static.tibia.com/images/library/drakenspellweaver.gif","featured":false},{"name":"Draken Warmasters","race":"drakenwarmaster","image_url":"https://static.tibia.com/images/library/drakenwarmaster.gif","featured":false},{"name":"Draptors","race":"draptor","image_url":"https://static.tibia.com/images/library/draptor.gif","featured":false},{"name":"Dread Intruders","race":"dreadintruder","image_url":"https://static.tibia.com/images/library/dreadintruder.gif","featured":false},{"name":"Drillworms","race":"drillworm","image_url":"https://static.tibia.com/images/library/drillworm.gif","featured":false},{"name":"Dromedaries","race":"dromedary","image_url":"https://static.tibia.com/images/library/dromedary.gif","featured":false},{"name":"Druid's Apparitions","race":"apparitionofadruid","image_url":"https://static.tibia.com/images/library/apparitionofadruid.gif","featured":false},{"name":"Dwarf Geomancers","race":"dwarfgeomancer","image_url":"https://static.tibia.com/images/library/dwarfgeomancer.gif","featured":false},{"name":"Dwarf Guards","race":"dwarfguard","image_url":"https://static.tibia.com/images/library/dwarfguard.gif","featured":false},{"name":"Dwarf Miners","race":"dwarfminer","image_url":"https://static.tibia.com/images/library/dwarfminer.gif","featured":false},{"name":"Dwarf Soldiers","race":"dwarfsoldier","image_url":"https://static.tibia.com/images/library/dwarfsoldier.gif","featured":false},{"name":"Dwarfs","race":"dwarf","image_url":"https://static.tibia.com/images/library/dwarf.gif","featured":false},{"name":"Dworc Fleshhunters","race":"dworcfleshhunter","image_url":"https://static.tibia.com/images/library/dworcfleshhunter.gif","featured":false},{"name":"Dworc Shadowstalkers","race":"norcferatudworc","image_url":"https://static.tibia.com/images/library/norcferatudworc.gif","featured":false},{"name":"Dworc Venomsnipers","race":"dworcvenomsniper","image_url":"https://static.tibia.com/images/library/dworcvenomsniper.gif","featured":false},{"name":"Dworc Voodoomasters","race":"dworcvoodoomaster","image_url":"https://static.tibia.com/images/library/dworcvoodoomaster.gif","featured":false},{"name":"Earth Elementals","race":"earthelemental","image_url":"https://static.tibia.com/images/library/earthelemental.gif","featured":false},{"name":"Efreet","race":"efreet","image_url":"https://static.tibia.com/images/library/efreet.gif","featured":false},{"name":"Elder Bonelords","race":"elderbonelord","image_url":"https://static.tibia.com/images/library/elderbonelord.gif","featured":false},{"name":"Elder Wyrms","race":"elderwyrm","image_url":"https://static.tibia.com/images/library/elderwyrm.gif","featured":false},{"name":"Elephants","race":"elephant","image_url":"https://static.tibia.com/images/library/elephant.gif","featured":false},{"name":"Elf Arcanists","race":"elfarcanist","image_url":"https://static.tibia.com/images/library/elfarcanist.gif","featured":false},{"name":"Elf Scouts","race":"elfscout","image_url":"https://static.tibia.com/images/library/elfscout.gif","featured":false},{"name":"Elves","race":"elf","image_url":"https://static.tibia.com/images/library/elf.gif","featured":false},{"name":"Emerald Damselflies","race":"emeralddamselfly","image_url":"https://static.tibia.com/images/library/emeralddamselfly.gif","featured":false},{"name":"Emerald Tortoises","race":"emeraldtortoise","image_url":"https://static.tibia.com/images/library/emeraldtortoise.gif","featured":false},{"name":"Energetic Books","race":"energeticbook","image_url":"https://static.tibia.com/images/library/energeticbook.gif","featured":false},{"name":"Energuardians Of Tales","race":"energuardianoftales","image_url":"https://static.tibia.com/images/library/energuardianoftales.gif","featured":false},{"name":"Energy Elementals","race":"energyelemental","image_url":"https://static.tibia.com/images/library/energyelemental.gif","featured":false},{"name":"Enfeebled Silencers","race":"enfeebledsilencer","image_url":"https://static.tibia.com/images/library/enfeebledsilencer.gif","featured":false},{"name":"Enlighteneds Of The Cult","race":"cultpriest","image_url":"https://static.tibia.com/images/library/cultpriest.gif","featured":false},{"name":"Enraged Crystal Golems","race":"crystalgolem","image_url":"https://static.tibia.com/images/library/crystalgolem.gif","featured":false},{"name":"Enslaved Dwarfs","race":"enslaveddwarf","image_url":"https://static.tibia.com/images/library/enslaveddwarf.gif","featured":false},{"name":"Evil Prospectors","race":"evilprospector","image_url":"https://static.tibia.com/images/library/evilprospector.gif","featured":false},{"name":"Execowtioners","race":"execowtioner","image_url":"https://static.tibia.com/images/library/execowtioner.gif","featured":false},{"name":"Exotic Bats","race":"caribbeanbat","image_url":"https://static.tibia.com/images/library/caribbeanbat.gif","featured":false},{"name":"Exotic Cave Spiders","race":"caribbeancavespider","image_url":"https://static.tibia.com/images/library/caribbeancavespider.gif","featured":false},{"name":"Eyeless Devourers","race":"eyelessdevourer","image_url":"https://static.tibia.com/images/library/eyelessdevourer.gif","featured":false},{"name":"Falcon Knights","race":"falconknight","image_url":"https://static.tibia.com/images/library/falconknight.gif","featured":false},{"name":"Falcon Paladins","race":"falconpaladin","image_url":"https://static.tibia.com/images/library/falconpaladin.gif","featured":false},{"name":"Fauns","race":"faun","image_url":"https://static.tibia.com/images/library/faun.gif","featured":false},{"name":"Feral Sphinxes","race":"feralsphinx","image_url":"https://static.tibia.com/images/library/feralsphinx.gif","featured":false},{"name":"Feral Werecrocodiles","race":"feralwerecrocodile","image_url":"https://static.tibia.com/images/library/feralwerecrocodile.gif","featured":false},{"name":"Feversleeps","race":"feversleep","image_url":"https://static.tibia.com/images/library/feversleep.gif","featured":false},{"name":"Filth Toads","race":"filthtoad","image_url":"https://static.tibia.com/images/library/filthtoad.gif","featured":false},{"name":"Fire Devils","race":"firedevil","image_url":"https://static.tibia.com/images/library/firedevil.gif","featured":false},{"name":"Fire Elementals","race":"fireelemental","image_url":"https://static.tibia.com/images/library/fireelemental.gif","featured":false},{"name":"Firestarters","race":"firestarter","image_url":"https://static.tibia.com/images/library/firestarter.gif","featured":false},{"name":"Fish","race":"fish","image_url":"https://static.tibia.com/images/library/fish.gif","featured":false},{"name":"Flamingos","race":"flamingo","image_url":"https://static.tibia.com/images/library/flamingo.gif","featured":false},{"name":"Flimsy Lost Souls","race":"lostsoulweak","image_url":"https://static.tibia.com/images/library/lostsoulweak.gif","featured":false},{"name":"Floating Savants","race":"floatingsavant","image_url":"https://static.tibia.com/images/library/floatingsavant.gif","featured":false},{"name":"Flying Books","race":"flyingbook","image_url":"https://static.tibia.com/images/library/flyingbook.gif","featured":false},{"name":"Foam Stalkers","race":"foamstalker","image_url":"https://static.tibia.com/images/library/foamstalker.gif","featured":false},{"name":"Forest Furies","race":"forestfury","image_url":"https://static.tibia.com/images/library/forestfury.gif","featured":false},{"name":"Foxes","race":"fox","image_url":"https://static.tibia.com/images/library/fox.gif","featured":false},{"name":"Frazzlemaws","race":"frazzlemaw","image_url":"https://static.tibia.com/images/library/frazzlemaw.gif","featured":false},{"name":"Freakish Lost Souls","race":"lostsoulhard","image_url":"https://static.tibia.com/images/library/lostsoulhard.gif","featured":false},{"name":"Frost Dragon Hatchlings","race":"frostdragonhatchling","image_url":"https://static.tibia.com/images/library/frostdragonhatchling.gif","featured":false},{"name":"Frost Dragons","race":"frostdragon","image_url":"https://static.tibia.com/images/library/frostdragon.gif","featured":false},{"name":"Frost Flower Asuras","race":"frostflowerasura","image_url":"https://static.tibia.com/images/library/frostflowerasura.gif","featured":false},{"name":"Frost Giantesses","race":"frostgiantess","image_url":"https://static.tibia.com/images/library/frostgiantess.gif","featured":false},{"name":"Frost Giants","race":"frostgiant","image_url":"https://static.tibia.com/images/library/frostgiant.gif","featured":false},{"name":"Frost Trolls","race":"frosttroll","image_url":"https://static.tibia.com/images/library/frosttroll.gif","featured":false},{"name":"Fruit Drops","race":"fruitdrop","image_url":"https://static.tibia.com/images/library/fruitdrop.gif","featured":false},{"name":"Furies","race":"fury","image_url":"https://static.tibia.com/images/library/fury.gif","featured":false},{"name":"Gang Members","race":"gangmember","image_url":"https://static.tibia.com/images/library/gangmember.gif","featured":false},{"name":"Gargoyles","race":"gargoyle","image_url":"https://static.tibia.com/images/library/gargoyle.gif","featured":false},{"name":"Gazer Spectres","race":"gazerspectre","image_url":"https://static.tibia.com/images/library/gazerspectre.gif","featured":false},{"name":"Gazers","race":"gazer","image_url":"https://static.tibia.com/images/library/gazer.gif","featured":false},{"name":"Ghastly Dragons","race":"ghastlydragon","image_url":"https://static.tibia.com/images/library/ghastlydragon.gif","featured":false},{"name":"Ghosts","race":"ghost","image_url":"https://static.tibia.com/images/library/ghost.gif","featured":false},{"name":"Ghouls","race":"ghoul","image_url":"https://static.tibia.com/images/library/ghoul.gif","featured":false},{"name":"Giant Spiders","race":"giantspider","image_url":"https://static.tibia.com/images/library/giantspider.gif","featured":false},{"name":"Gingerbread Men","race":"gingerbreadman","image_url":"https://static.tibia.com/images/library/gingerbreadman.gif","featured":false},{"name":"Girtablilu Warriors","race":"girtabliluwarrior","image_url":"https://static.tibia.com/images/library/girtabliluwarrior.gif","featured":false},{"name":"Gladiators","race":"gladiator","image_url":"https://static.tibia.com/images/library/gladiator.gif","featured":false},{"name":"Gloom Maws","race":"batface","image_url":"https://static.tibia.com/images/library/batface.gif","featured":false},{"name":"Gloom Wolves","race":"gloomwolf","image_url":"https://static.tibia.com/images/library/gloomwolf.gif","featured":false},{"name":"Glooth Anemones","race":"gloothanemone","image_url":"https://static.tibia.com/images/library/gloothanemone.gif","featured":false},{"name":"Glooth Bandits","race":"gloothbandit","image_url":"https://static.tibia.com/images/library/gloothbandit.gif","featured":false},{"name":"Glooth Blobs","race":"gloothblob","image_url":"https://static.tibia.com/images/library/gloothblob.gif","featured":false},{"name":"Glooth Brigands","race":"gloothbrigand","image_url":"https://static.tibia.com/images/library/gloothbrigand.gif","featured":false},{"name":"Glooth Golems","race":"gloothgolem","image_url":"https://static.tibia.com/images/library/gloothgolem.gif","featured":false},{"name":"Gnarlhounds","race":"gnarlhound","image_url":"https://static.tibia.com/images/library/gnarlhound.gif","featured":false},{"name":"Goblin Assassins","race":"goblinassassin","image_url":"https://static.tibia.com/images/library/goblinassassin.gif","featured":false},{"name":"Goblin Scavengers","race":"goblinscavenger","image_url":"https://static.tibia.com/images/library/goblinscavenger.gif","featured":false},{"name":"Goblins","race":"goblin","image_url":"https://static.tibia.com/images/library/goblin.gif","featured":false},{"name":"Goggle Cakes","race":"gogglecake","image_url":"https://static.tibia.com/images/library/gogglecake.gif","featured":false},{"name":"Golden Servants","race":"goldenservant","image_url":"https://static.tibia.com/images/library/goldenservant.gif","featured":false},{"name":"Gore Horns","race":"gorehorn","image_url":"https://static.tibia.com/images/library/gorehorn.gif","featured":false},{"name":"Gorerillas","race":"gorerilla","image_url":"https://static.tibia.com/images/library/gorerilla.gif","featured":false},{"name":"Gorger Inferniarches","race":"gorgerinferniarch","image_url":"https://static.tibia.com/images/library/gorgerinferniarch.gif","featured":false},{"name":"Gozzlers","race":"gozzler","image_url":"https://static.tibia.com/images/library/gozzler.gif","featured":false},{"name":"Grave Robbers","race":"graverobber","image_url":"https://static.tibia.com/images/library/graverobber.gif","featured":false},{"name":"Gravediggers","race":"gravedigger","image_url":"https://static.tibia.com/images/library/gravedigger.gif","featured":false},{"name":"Green Djinns","race":"greendjinn","image_url":"https://static.tibia.com/images/library/greendjinn.gif","featured":false},{"name":"Grim Reapers","race":"grimreaper","image_url":"https://static.tibia.com/images/library/grimreaper.gif","featured":false},{"name":"Grimeleeches","race":"grimeleech","image_url":"https://static.tibia.com/images/library/grimeleech.gif","featured":false},{"name":"Gryphons","race":"gryphon","image_url":"https://static.tibia.com/images/library/gryphon.gif","featured":false},{"name":"Guardians Of Tales","race":"guardianoftales","image_url":"https://static.tibia.com/images/library/guardianoftales.gif","featured":false},{"name":"Guzzlemaws","race":"guzzlemaw","image_url":"https://static.tibia.com/images/library/guzzlemaw.gif","featured":false},{"name":"Hands Of Cursed Fate","race":"handofcursedfate","image_url":"https://static.tibia.com/images/library/handofcursedfate.gif","featured":false},{"name":"Harpies","race":"harpy","image_url":"https://static.tibia.com/images/library/harpy.gif","featured":false},{"name":"Haunted Hunters","race":"hauntedhunter","image_url":"https://static.tibia.com/images/library/hauntedhunter.gif","featured":false},{"name":"Haunted Treelings","race":"hauntedtreeling","image_url":"https://static.tibia.com/images/library/hauntedtreeling.gif","featured":false},{"name":"Hawk Hoppers","race":"hawkhopper","image_url":"https://static.tibia.com/images/library/hawkhopper.gif","featured":false},{"name":"Headpeckers","race":"headpecker","image_url":"https://static.tibia.com/images/library/headpecker.gif","featured":false},{"name":"Headwalkers","race":"headwalker","image_url":"https://static.tibia.com/images/library/headwalker.gif","featured":false},{"name":"Hellfire Fighters","race":"hellfirefighter","image_url":"https://static.tibia.com/images/library/hellfirefighter.gif","featured":false},{"name":"Hellflayers","race":"hellflayer","image_url":"https://static.tibia.com/images/library/hellflayer.gif","featured":false},{"name":"Hellhounds","race":"hellhound","image_url":"https://static.tibia.com/images/library/hellhound.gif","featured":false},{"name":"Hellhunter Inferniarches","race":"hellhunterinferniarch","image_url":"https://static.tibia.com/images/library/hellhunterinferniarch.gif","featured":false},{"name":"Hellspawns","race":"hellspawn","image_url":"https://static.tibia.com/images/library/hellspawn.gif","featured":false},{"name":"Heroes","race":"hero","image_url":"https://static.tibia.com/images/library/hero.gif","featured":false},{"name":"Hideous Fungi","race":"hideousfungus","image_url":"https://static.tibia.com/images/library/hideousfungus.gif","featured":false},{"name":"Honey Elementals","race":"honeyelemental","image_url":"https://static.tibia.com/images/library/honeyelemental.gif","featured":false},{"name":"Hulking Carnisylvans","race":"carnisylvanhulking","image_url":"https://static.tibia.com/images/library/carnisylvanhulking.gif","featured":false},{"name":"Hulking Prehemoths","race":"hulkingprehemoth","image_url":"https://static.tibia.com/images/library/hulkingprehemoth.gif","featured":false},{"name":"Humongous Fungi","race":"humongousfungus","image_url":"https://static.tibia.com/images/library/humongousfungus.gif","featured":false},{"name":"Hunters","race":"hunter","image_url":"https://static.tibia.com/images/library/hunter.gif","featured":false},{"name":"Huskies","race":"husky","image_url":"https://static.tibia.com/images/library/husky.gif","featured":false},{"name":"Hyaenas","race":"hyaena","image_url":"https://static.tibia.com/images/library/hyaena.gif","featured":false},{"name":"Hydras","race":"hydra","image_url":"https://static.tibia.com/images/library/hydra.gif","featured":false},{"name":"Ice Golems","race":"icegolem","image_url":"https://static.tibia.com/images/library/icegolem.gif","featured":false},{"name":"Ice Witches","race":"icewitch","image_url":"https://static.tibia.com/images/library/icewitch.gif","featured":false},{"name":"Icecold Books","race":"icecoldbook","image_url":"https://static.tibia.com/images/library/icecoldbook.gif","featured":false},{"name":"Iks Aucars","race":"iksaucar","image_url":"https://static.tibia.com/images/library/iksaucar.gif","featured":false},{"name":"Iks Chukas","race":"ikschuka","image_url":"https://static.tibia.com/images/library/ikschuka.gif","featured":false},{"name":"Iks Pututus","race":"ikspututu","image_url":"https://static.tibia.com/images/library/ikspututu.gif","featured":false},{"name":"Iks Yapunacs","race":"iksyapunac","image_url":"https://static.tibia.com/images/library/iksyapunac.gif","featured":false},{"name":"Imperials","race":"imperial","image_url":"https://static.tibia.com/images/library/imperial.gif","featured":false},{"name":"Infernal Demons","race":"infernaldemon","image_url":"https://static.tibia.com/images/library/infernaldemon.gif","featured":false},{"name":"Infernal Phantoms","race":"infernalphantom","image_url":"https://static.tibia.com/images/library/infernalphantom.gif","featured":false},{"name":"Infernalists","race":"infernalist","image_url":"https://static.tibia.com/images/library/infernalist.gif","featured":false},{"name":"Infernoid Blobs","race":"infernoidblob","image_url":"https://static.tibia.com/images/library/infernoidblob.gif","featured":false},{"name":"Infernoid Hounds","race":"infernoidhound","image_url":"https://static.tibia.com/images/library/infernoidhound.gif","featured":false},{"name":"Infernoid Souls","race":"infernoidsoul","image_url":"https://static.tibia.com/images/library/infernoidsoul.gif","featured":false},{"name":"Infernoid Spirituals","race":"infernoidspiritual","image_url":"https://static.tibia.com/images/library/infernoidspiritual.gif","featured":false},{"name":"Ink Blobs","race":"inkblob","image_url":"https://static.tibia.com/images/library/inkblob.gif","featured":false},{"name":"Insane Sirens","race":"insanesiren","image_url":"https://static.tibia.com/images/library/insanesiren.gif","featured":false},{"name":"Insect Swarms","race":"insectswarm","image_url":"https://static.tibia.com/images/library/insectswarm.gif","featured":false},{"name":"Insectoid Scouts","race":"insectoidscout","image_url":"https://static.tibia.com/images/library/insectoidscout.gif","featured":false},{"name":"Insectoid Workers","race":"insectoidworker","image_url":"https://static.tibia.com/images/library/insectoidworker.gif","featured":false},{"name":"Instable Breach Broods","race":"instablebreachbrood","image_url":"https://static.tibia.com/images/library/instablebreachbrood.gif","featured":false},{"name":"Instable Sparkions","race":"instablesparkion","image_url":"https://static.tibia.com/images/library/instablesparkion.gif","featured":false},{"name":"Iron Servants","race":"ironservant","image_url":"https://static.tibia.com/images/library/ironservant.gif","featured":false},{"name":"Ironblights","race":"ironblight","image_url":"https://static.tibia.com/images/library/ironblight.gif","featured":false},{"name":"Island Trolls","race":"islandtroll","image_url":"https://static.tibia.com/images/library/islandtroll.gif","featured":false},{"name":"Jellyfish","race":"jellyfish","image_url":"https://static.tibia.com/images/library/jellyfish.gif","featured":false},{"name":"Juggernauts","race":"juggernaut","image_url":"https://static.tibia.com/images/library/juggernaut.gif","featured":false},{"name":"Jungle Moas","race":"junglemoa","image_url":"https://static.tibia.com/images/library/junglemoa.gif","featured":false},{"name":"Juvenile Bashmus","race":"juvenilebashmu","image_url":"https://static.tibia.com/images/library/juvenilebashmu.gif","featured":false},{"name":"Killer Caimans","race":"killercaiman","image_url":"https://static.tibia.com/images/library/killercaiman.gif","featured":false},{"name":"Knight's Apparitions","race":"knightsapparition","image_url":"https://static.tibia.com/images/library/knightsapparition.gif","featured":false},{"name":"Knowledge Elementals","race":"knowledgeelemental","image_url":"https://static.tibia.com/images/library/knowledgeelemental.gif","featured":false},{"name":"Kollos","race":"kollos","image_url":"https://static.tibia.com/images/library/kollos.gif","featured":false},{"name":"Kongras","race":"kongra","image_url":"https://static.tibia.com/images/library/kongra.gif","featured":false},{"name":"Ladybugs","race":"ladybug","image_url":"https://static.tibia.com/images/library/ladybug.gif","featured":false},{"name":"Lamassus","race":"lamassu","image_url":"https://static.tibia.com/images/library/lamassu.gif","featured":false},{"name":"Lancer Beetles","race":"lancerbeetle","image_url":"https://static.tibia.com/images/library/lancerbeetle.gif","featured":false},{"name":"Larvas","race":"larva","image_url":"https://static.tibia.com/images/library/larva.gif","featured":false},{"name":"Lava Golems","race":"lavagolem","image_url":"https://static.tibia.com/images/library/lavagolem.gif","featured":false},{"name":"Lava Lurkers","race":"lavablob","image_url":"https://static.tibia.com/images/library/lavablob.gif","featured":false},{"name":"Lavafungi","race":"lavafungus","image_url":"https://static.tibia.com/images/library/lavafungus.gif","featured":false},{"name":"Lavaworms","race":"lavaworm","image_url":"https://static.tibia.com/images/library/lavaworm.gif","featured":false},{"name":"Leaf Golems","race":"leafgolem","image_url":"https://static.tibia.com/images/library/leafgolem.gif","featured":false},{"name":"Liches","race":"lich","image_url":"https://static.tibia.com/images/library/lich.gif","featured":false},{"name":"Liodiles","race":"liodileman","image_url":"https://static.tibia.com/images/library/liodileman.gif","featured":false},{"name":"Lion Hydras","race":"lionhydra","image_url":"https://static.tibia.com/images/library/lionhydra.gif","featured":false},{"name":"Lions","race":"lion","image_url":"https://static.tibia.com/images/library/lion.gif","featured":false},{"name":"Lizard Chosens","race":"lizardchosen","image_url":"https://static.tibia.com/images/library/lizardchosen.gif","featured":false},{"name":"Lizard Commanders","race":"lizardcommander","image_url":"https://static.tibia.com/images/library/lizardcommander.gif","featured":false},{"name":"Lizard Dragon Priests","race":"lizarddragonpriest","image_url":"https://static.tibia.com/images/library/lizarddragonpriest.gif","featured":false},{"name":"Lizard Executioners","race":"lizardexecutioner","image_url":"https://static.tibia.com/images/library/lizardexecutioner.gif","featured":false},{"name":"Lizard Henchmans","race":"lizardhenchman","image_url":"https://static.tibia.com/images/library/lizardhenchman.gif","featured":false},{"name":"Lizard High Guards","race":"lizardhighguard","image_url":"https://static.tibia.com/images/library/lizardhighguard.gif","featured":false},{"name":"Lizard Legionnaires","race":"lizardlegionnaire","image_url":"https://static.tibia.com/images/library/lizardlegionnaire.gif","featured":false},{"name":"Lizard Magicians","race":"lizardmagician","image_url":"https://static.tibia.com/images/library/lizardmagician.gif","featured":false},{"name":"Lizard Sentinels","race":"lizardsentinel","image_url":"https://static.tibia.com/images/library/lizardsentinel.gif","featured":false},{"name":"Lizard Snakecharmers","race":"lizardsnakecharmer","image_url":"https://static.tibia.com/images/library/lizardsnakecharmer.gif","featured":false},{"name":"Lizard Swordmasters","race":"lizardswordmaster","image_url":"https://static.tibia.com/images/library/lizardswordmaster.gif","featured":false},{"name":"Lizard Templars","race":"lizardtemplar","image_url":"https://static.tibia.com/images/library/lizardtemplar.gif","featured":false},{"name":"Lizard Zaoguns","race":"lizardzaogun","image_url":"https://static.tibia.com/images/library/lizardzaogun.gif","featured":false},{"name":"Lost Bashers","race":"lostdwarfbasher","image_url":"https://static.tibia.com/images/library/lostdwarfbasher.gif","featured":false},{"name":"Lost Berserkers","race":"lostberserker","image_url":"https://static.tibia.com/images/library/lostberserker.gif","featured":false},{"name":"Lost Hushers","race":"lostdwarfhusher","image_url":"https://static.tibia.com/images/library/lostdwarfhusher.gif","featured":false},{"name":"Lost Souls","race":"lostsoul","image_url":"https://static.tibia.com/images/library/lostsoul.gif","featured":false},{"name":"Lost Throwers","race":"lostthrower","image_url":"https://static.tibia.com/images/library/lostthrower.gif","featured":false},{"name":"Lumbering Carnivors","race":"lumberingcarnivor","image_url":"https://static.tibia.com/images/library/lumberingcarnivor.gif","featured":false},{"name":"Mad Scientists","race":"madscientist","image_url":"https://static.tibia.com/images/library/madscientist.gif","featured":false},{"name":"Magma Crawlers","race":"magmacrawler","image_url":"https://static.tibia.com/images/library/magmacrawler.gif","featured":false},{"name":"Makaras","race":"makara","image_url":"https://static.tibia.com/images/library/makara.gif","featured":false},{"name":"Mammoths","race":"mammoth","image_url":"https://static.tibia.com/images/library/mammoth.gif","featured":false},{"name":"Manta Rays","race":"mantaray","image_url":"https://static.tibia.com/images/library/mantaray.gif","featured":false},{"name":"Manticores","race":"manticore","image_url":"https://static.tibia.com/images/library/manticore.gif","featured":false},{"name":"Mantosauruses","race":"mantosaurus","image_url":"https://static.tibia.com/images/library/mantosaurus.gif","featured":false},{"name":"Many Faces","race":"manyfaces","image_url":"https://static.tibia.com/images/library/manyfaces.gif","featured":false},{"name":"Marid","race":"marid","image_url":"https://static.tibia.com/images/library/marid.gif","featured":false},{"name":"Marsh Stalkers","race":"marshstalker","image_url":"https://static.tibia.com/images/library/marshstalker.gif","featured":false},{"name":"Massive Earth Elementals","race":"earthelementalmassive","image_url":"https://static.tibia.com/images/library/earthelementalmassive.gif","featured":false},{"name":"Massive Energy Elementals","race":"energyelementalmassive","image_url":"https://static.tibia.com/images/library/energyelementalmassive.gif","featured":false},{"name":"Massive Fire Elementals","race":"hellfireelemental","image_url":"https://static.tibia.com/images/library/hellfireelemental.gif","featured":false},{"name":"Massive Water Elementals","race":"waterelementalmassive","image_url":"https://static.tibia.com/images/library/waterelementalmassive.gif","featured":false},{"name":"Mean Lost Souls","race":"lostsoulmedium","image_url":"https://static.tibia.com/images/library/lostsoulmedium.gif","featured":false},{"name":"Meandering Mushrooms","race":"meanderingmushroom","image_url":"https://static.tibia.com/images/library/meanderingmushroom.gif","featured":false},{"name":"Medusae","race":"medusa","image_url":"https://static.tibia.com/images/library/medusa.gif","featured":false},{"name":"Mega Dragons","race":"megadragon","image_url":"https://static.tibia.com/images/library/megadragon.gif","featured":false},{"name":"Menacing Carnivors","race":"menacingcarnivor","image_url":"https://static.tibia.com/images/library/menacingcarnivor.gif","featured":false},{"name":"Mercurial Menaces","race":"mercurialmenace","image_url":"https://static.tibia.com/images/library/mercurialmenace.gif","featured":false},{"name":"Mercury Blobs","race":"mercuryblob","image_url":"https://static.tibia.com/images/library/mercuryblob.gif","featured":false},{"name":"Merlkins","race":"merlkin","image_url":"https://static.tibia.com/images/library/merlkin.gif","featured":false},{"name":"Metal Gargoyles","race":"metalgargoyle","image_url":"https://static.tibia.com/images/library/metalgargoyle.gif","featured":false},{"name":"Midnight Asuras","race":"asuranight","image_url":"https://static.tibia.com/images/library/asuranight.gif","featured":false},{"name":"Midnight Panthers","race":"midnightpanther","image_url":"https://static.tibia.com/images/library/midnightpanther.gif","featured":false},{"name":"Minotaur Amazons","race":"minotauramazon","image_url":"https://static.tibia.com/images/library/minotauramazon.gif","featured":false},{"name":"Minotaur Archers","race":"minotaurarcher","image_url":"https://static.tibia.com/images/library/minotaurarcher.gif","featured":false},{"name":"Minotaur Cult Followers","race":"minotaurcultfollower","image_url":"https://static.tibia.com/images/library/minotaurcultfollower.gif","featured":false},{"name":"Minotaur Cult Prophets","race":"minotaurcultprophet","image_url":"https://static.tibia.com/images/library/minotaurcultprophet.gif","featured":false},{"name":"Minotaur Cult Zealots","race":"minotaurcultzealot","image_url":"https://static.tibia.com/images/library/minotaurcultzealot.gif","featured":false},{"name":"Minotaur Guards","race":"minotaurguard","image_url":"https://static.tibia.com/images/library/minotaurguard.gif","featured":false},{"name":"Minotaur Hunters","race":"minotaurhunter","image_url":"https://static.tibia.com/images/library/minotaurhunter.gif","featured":false},{"name":"Minotaur Mages","race":"minotaurmage","image_url":"https://static.tibia.com/images/library/minotaurmage.gif","featured":false},{"name":"Minotaurs","race":"minotaur","image_url":"https://static.tibia.com/images/library/minotaur.gif","featured":false},{"name":"Mirror Images","race":"mirrorimage","image_url":"https://static.tibia.com/images/library/mirrorimage.gif","featured":false},{"name":"Misguided Bullies","race":"misguidedmelee","image_url":"https://static.tibia.com/images/library/misguidedmelee.gif","featured":false},{"name":"Misguided Thieves","race":"misguidedranged","image_url":"https://static.tibia.com/images/library/misguidedranged.gif","featured":false},{"name":"Mitmah Scouts","race":"mitmahscout","image_url":"https://static.tibia.com/images/library/mitmahscout.gif","featured":false},{"name":"Mitmah Seers","race":"mitmahseer","image_url":"https://static.tibia.com/images/library/mitmahseer.gif","featured":false},{"name":"Monk's Apparitions","race":"monksapparition","image_url":"https://static.tibia.com/images/library/monksapparition.gif","featured":false},{"name":"Monks","race":"monk","image_url":"https://static.tibia.com/images/library/monk.gif","featured":false},{"name":"Monks Of The Order","race":"lionmonk","image_url":"https://static.tibia.com/images/library/lionmonk.gif","featured":false},{"name":"Mooh'tah Warriors","race":"moohtahwarrior","image_url":"https://static.tibia.com/images/library/moohtahwarrior.gif","featured":false},{"name":"Moohtants","race":"moohtant","image_url":"https://static.tibia.com/images/library/moohtant.gif","featured":false},{"name":"Mould Phantoms","race":"mouldphantom","image_url":"https://static.tibia.com/images/library/mouldphantom.gif","featured":false},{"name":"Mummies","race":"mummy","image_url":"https://static.tibia.com/images/library/mummy.gif","featured":false},{"name":"Mutated Bats","race":"mutatedbat","image_url":"https://static.tibia.com/images/library/mutatedbat.gif","featured":false},{"name":"Mutated Humans","race":"mutatedhuman","image_url":"https://static.tibia.com/images/library/mutatedhuman.gif","featured":false},{"name":"Mutated Rats","race":"mutatedrat","image_url":"https://static.tibia.com/images/library/mutatedrat.gif","featured":false},{"name":"Mutated Tigers","race":"mutatedtiger","image_url":"https://static.tibia.com/images/library/mutatedtiger.gif","featured":false},{"name":"Mycobiontic Beetles","race":"mycobionticbeetle","image_url":"https://static.tibia.com/images/library/mycobionticbeetle.gif","featured":false},{"name":"Naga Archers","race":"nagaarcher","image_url":"https://static.tibia.com/images/library/nagaarcher.gif","featured":false},{"name":"Naga Warriors","race":"nagawarrior","image_url":"https://static.tibia.com/images/library/nagawarrior.gif","featured":false},{"name":"Necromancers","race":"necromancer","image_url":"https://static.tibia.com/images/library/necromancer.gif","featured":false},{"name":"Nibblemaws","race":"nibblemaw","image_url":"https://static.tibia.com/images/library/nibblemaw.gif","featured":false},{"name":"Night Harpies","race":"nightharpy","image_url":"https://static.tibia.com/images/library/nightharpy.gif","featured":false},{"name":"Nightfiends","race":"nightfiend","image_url":"https://static.tibia.com/images/library/nightfiend.gif","featured":false},{"name":"Nighthunters","race":"nighthunter","image_url":"https://static.tibia.com/images/library/nighthunter.gif","featured":false},{"name":"Nightmare Scions","race":"nightmarescion","image_url":"https://static.tibia.com/images/library/nightmarescion.gif","featured":false},{"name":"Nightmares","race":"nightmare","image_url":"https://static.tibia.com/images/library/nightmare.gif","featured":false},{"name":"Nightstalkers","race":"nightstalker","image_url":"https://static.tibia.com/images/library/nightstalker.gif","featured":false},{"name":"Noble Lions","race":"noblelion","image_url":"https://static.tibia.com/images/library/noblelion.gif","featured":false},{"name":"Nomads","race":"nomad","image_url":"https://static.tibia.com/images/library/nomad.gif","featured":false},{"name":"Norcferatu Heartlesses","race":"norcferatuheartless","image_url":"https://static.tibia.com/images/library/norcferatuheartless.gif","featured":false},{"name":"Norcferatu Nightweavers","race":"norcferatunightweaver","image_url":"https://static.tibia.com/images/library/norcferatunightweaver.gif","featured":false},{"name":"Northern Pikes","race":"northernpike","image_url":"https://static.tibia.com/images/library/northernpike.gif","featured":false},{"name":"Novices Of The Cult","race":"cultnovice","image_url":"https://static.tibia.com/images/library/cultnovice.gif","featured":false},{"name":"Noxious Ripptors","race":"noxiousripptor","image_url":"https://static.tibia.com/images/library/noxiousripptor.gif","featured":false},{"name":"Nymphs","race":"nymph","image_url":"https://static.tibia.com/images/library/nymph.gif","featured":false},{"name":"Ogre Brutes","race":"ogrebrute","image_url":"https://static.tibia.com/images/library/ogrebrute.gif","featured":false},{"name":"Ogre Rowdies","race":"ogrerowdy","image_url":"https://static.tibia.com/images/library/ogrerowdy.gif","featured":false},{"name":"Ogre Ruffians","race":"ogreruffian","image_url":"https://static.tibia.com/images/library/ogreruffian.gif","featured":false},{"name":"Ogre Sages","race":"ogresage","image_url":"https://static.tibia.com/images/library/ogresage.gif","featured":false},{"name":"Ogre Savages","race":"ogresavage","image_url":"https://static.tibia.com/images/library/ogresavage.gif","featured":false},{"name":"Ogre Shamans","race":"ogreshaman","image_url":"https://static.tibia.com/images/library/ogreshaman.gif","featured":false},{"name":"Omnivoras","race":"omnivora","image_url":"https://static.tibia.com/images/library/omnivora.gif","featured":false},{"name":"Oozing Carcasses","race":"oozingcarcass","image_url":"https://static.tibia.com/images/library/oozingcarcass.gif","featured":false},{"name":"Oozing Corpuses","race":"oozingcorpus","image_url":"https://static.tibia.com/images/library/oozingcorpus.gif","featured":false},{"name":"Orc Berserkers","race":"orcberserker","image_url":"https://static.tibia.com/images/library/orcberserker.gif","featured":false},{"name":"Orc Cult Fanatics","race":"orccultfanatic","image_url":"https://static.tibia.com/images/library/orccultfanatic.gif","featured":false},{"name":"Orc Cult Inquisitors","race":"orccultinquisitor","image_url":"https://static.tibia.com/images/library/orccultinquisitor.gif","featured":false},{"name":"Orc Cult Minions","race":"orccultminion","image_url":"https://static.tibia.com/images/library/orccultminion.gif","featured":false},{"name":"Orc Cult Priests","race":"orccultpriest","image_url":"https://static.tibia.com/images/library/orccultpriest.gif","featured":false},{"name":"Orc Cultists","race":"orccultist","image_url":"https://static.tibia.com/images/library/orccultist.gif","featured":false},{"name":"Orc Leaders","race":"orcleader","image_url":"https://static.tibia.com/images/library/orcleader.gif","featured":false},{"name":"Orc Marauders","race":"orcmarauder","image_url":"https://static.tibia.com/images/library/orcmarauder.gif","featured":false},{"name":"Orc Riders","race":"orcrider","image_url":"https://static.tibia.com/images/library/orcrider.gif","featured":false},{"name":"Orc Shamans","race":"orcshaman","image_url":"https://static.tibia.com/images/library/orcshaman.gif","featured":false},{"name":"Orc Spearmen","race":"orcspearman","image_url":"https://static.tibia.com/images/library/orcspearman.gif","featured":false},{"name":"Orc Warlords","race":"orcwarlord","image_url":"https://static.tibia.com/images/library/orcwarlord.gif","featured":false},{"name":"Orc Warriors","race":"orcwarrior","image_url":"https://static.tibia.com/images/library/orcwarrior.gif","featured":false},{"name":"Orclops Bloodbreakers","race":"norcferatuorclops","image_url":"https://static.tibia.com/images/library/norcferatuorclops.gif","featured":false},{"name":"Orclops Doomhaulers","race":"orclops","image_url":"https://static.tibia.com/images/library/orclops.gif","featured":false},{"name":"Orclops Ravagers","race":"orclopsravager","image_url":"https://static.tibia.com/images/library/orclopsravager.gif","featured":false},{"name":"Orcs","race":"orc","image_url":"https://static.tibia.com/images/library/orc.gif","featured":false},{"name":"Orewalkers","race":"orewalker","image_url":"https://static.tibia.com/images/library/orewalker.gif","featured":false},{"name":"Paladin's Apparitions","race":"paladinsapparition","image_url":"https://static.tibia.com/images/library/paladinsapparition.gif","featured":false},{"name":"Pandas","race":"panda","image_url":"https://static.tibia.com/images/library/panda.gif","featured":false},{"name":"Parders","race":"parder","image_url":"https://static.tibia.com/images/library/parder.gif","featured":false},{"name":"Parrots","race":"parrot","image_url":"https://static.tibia.com/images/library/parrot.gif","featured":false},{"name":"Penguins","race":"penguin","image_url":"https://static.tibia.com/images/library/penguin.gif","featured":false},{"name":"Phantasms","race":"phantasm","image_url":"https://static.tibia.com/images/library/phantasm.gif","featured":false},{"name":"Pigeons","race":"pigeon","image_url":"https://static.tibia.com/images/library/pigeon.gif","featured":false},{"name":"Pigs","race":"pig","image_url":"https://static.tibia.com/images/library/pig.gif","featured":false},{"name":"Pirat Bombardiers","race":"piratbombardier","image_url":"https://static.tibia.com/images/library/piratbombardier.gif","featured":false},{"name":"Pirat Cutthroats","race":"piratcutthroat","image_url":"https://static.tibia.com/images/library/piratcutthroat.gif","featured":false},{"name":"Pirat Mates","race":"piratmate","image_url":"https://static.tibia.com/images/library/piratmate.gif","featured":false},{"name":"Pirat Scoundrels","race":"piratscoundrel","image_url":"https://static.tibia.com/images/library/piratscoundrel.gif","featured":false},{"name":"Pirate Buccaneers","race":"piratebuccaneer","image_url":"https://static.tibia.com/images/library/piratebuccaneer.gif","featured":false},{"name":"Pirate Cooks","race":"cook","image_url":"https://static.tibia.com/images/library/cook.gif","featured":false},{"name":"Pirate Corsairs","race":"piratecorsair","image_url":"https://static.tibia.com/images/library/piratecorsair.gif","featured":false},{"name":"Pirate Cutthroats","race":"piratecutthroat","image_url":"https://static.tibia.com/images/library/piratecutthroat.gif","featured":false},{"name":"Pirate Ghosts","race":"pirateghost","image_url":"https://static.tibia.com/images/library/pirateghost.gif","featured":false},{"name":"Pirate Gunners","race":"gunner","image_url":"https://static.tibia.com/images/library/gunner.gif","featured":false},{"name":"Pirate Marauders","race":"piratemarauder","image_url":"https://static.tibia.com/images/library/piratemarauder.gif","featured":false},{"name":"Pirate Navigators","race":"navigator","image_url":"https://static.tibia.com/images/library/navigator.gif","featured":false},{"name":"Pirate Quartermasters","race":"quartermaster","image_url":"https://static.tibia.com/images/library/quartermaster.gif","featured":false},{"name":"Pirate Skeletons","race":"pirateskeleton","image_url":"https://static.tibia.com/images/library/pirateskeleton.gif","featured":false},{"name":"Pixies","race":"pixie","image_url":"https://static.tibia.com/images/library/pixie.gif","featured":false},{"name":"Plaguesmiths","race":"plaguesmith","image_url":"https://static.tibia.com/images/library/plaguesmith.gif","featured":false},{"name":"Poachers","race":"poacher","image_url":"https://static.tibia.com/images/library/poacher.gif","featured":false},{"name":"Poison Spiders","race":"poisonspider","image_url":"https://static.tibia.com/images/library/poisonspider.gif","featured":false},{"name":"Poisonous Carnisylvans","race":"carnisylvanpoisonous","image_url":"https://static.tibia.com/images/library/carnisylvanpoisonous.gif","featured":false},{"name":"Polar Bears","race":"polarbear","image_url":"https://static.tibia.com/images/library/polarbear.gif","featured":false},{"name":"Pookas","race":"pooka","image_url":"https://static.tibia.com/images/library/pooka.gif","featured":false},{"name":"Priestesses","race":"priestess","image_url":"https://static.tibia.com/images/library/priestess.gif","featured":false},{"name":"Priestesses Of The Wild Sun","race":"priestessofthewildsun","image_url":"https://static.tibia.com/images/library/priestessofthewildsun.gif","featured":false},{"name":"Putrid Mummies","race":"putridmummy","image_url":"https://static.tibia.com/images/library/putridmummy.gif","featured":false},{"name":"Quara Constrictor Scouts","race":"quaraconstrictorscout","image_url":"https://static.tibia.com/images/library/quaraconstrictorscout.gif","featured":false},{"name":"Quara Constrictors","race":"quaraconstrictor","image_url":"https://static.tibia.com/images/library/quaraconstrictor.gif","featured":false},{"name":"Quara Hydromancer Scouts","race":"quarahydromancerscout","image_url":"https://static.tibia.com/images/library/quarahydromancerscout.gif","featured":false},{"name":"Quara Hydromancers","race":"quarahydromancer","image_url":"https://static.tibia.com/images/library/quarahydromancer.gif","featured":false},{"name":"Quara Looters","race":"quaralooter","image_url":"https://static.tibia.com/images/library/quaralooter.gif","featured":false},{"name":"Quara Mantassin Scouts","race":"quaramantassinscout","image_url":"https://static.tibia.com/images/library/quaramantassinscout.gif","featured":false},{"name":"Quara Mantassins","race":"quaramantassin","image_url":"https://static.tibia.com/images/library/quaramantassin.gif","featured":false},{"name":"Quara Pincher Scouts","race":"quarapincherscout","image_url":"https://static.tibia.com/images/library/quarapincherscout.gif","featured":false},{"name":"Quara Pinchers","race":"quarapincher","image_url":"https://static.tibia.com/images/library/quarapincher.gif","featured":false},{"name":"Quara Plunderers","race":"quaraplunderer","image_url":"https://static.tibia.com/images/library/quaraplunderer.gif","featured":false},{"name":"Quara Predator Scouts","race":"quarapredatorscout","image_url":"https://static.tibia.com/images/library/quarapredatorscout.gif","featured":false},{"name":"Quara Predators","race":"quarapredator","image_url":"https://static.tibia.com/images/library/quarapredator.gif","featured":false},{"name":"Quara Raiders","race":"quararaider","image_url":"https://static.tibia.com/images/library/quararaider.gif","featured":false},{"name":"Rabbits","race":"rabbit","image_url":"https://static.tibia.com/images/library/rabbit.gif","featured":false},{"name":"Rabid Wolves","race":"rabidwolf","image_url":"https://static.tibia.com/images/library/rabidwolf.gif","featured":false},{"name":"Rage Squids","race":"ragingbrainsquid","image_url":"https://static.tibia.com/images/library/ragingbrainsquid.gif","featured":false},{"name":"Ragged Rabid Wolves","race":"raggedrabidwolf","image_url":"https://static.tibia.com/images/library/raggedrabidwolf.gif","featured":false},{"name":"Rats","race":"rat","image_url":"https://static.tibia.com/images/library/rat.gif","featured":false},{"name":"Raubritter Chasteners","race":"raubritterchastener","image_url":"https://static.tibia.com/images/library/raubritterchastener.gif","featured":false},{"name":"Raubritter Marksmen","race":"raubrittermarksman","image_url":"https://static.tibia.com/images/library/raubrittermarksman.gif","featured":false},{"name":"Raubritter Skirmishers","race":"raubritterskirmisher","image_url":"https://static.tibia.com/images/library/raubritterskirmisher.gif","featured":false},{"name":"Reality Reavers","race":"realityreaver","image_url":"https://static.tibia.com/images/library/realityreaver.gif","featured":false},{"name":"Redeemed Souls","race":"redeemedsoul","image_url":"https://static.tibia.com/images/library/redeemedsoul.gif","featured":false},{"name":"Renegade Knights","race":"renegadeknight","image_url":"https://static.tibia.com/images/library/renegadeknight.gif","featured":false},{"name":"Retching Horrors","race":"retchinghorror","image_url":"https://static.tibia.com/images/library/retchinghorror.gif","featured":false},{"name":"Rhindeer","race":"rhindeer","image_url":"https://static.tibia.com/images/library/rhindeer.gif","featured":false},{"name":"Ripper Spectres","race":"ripperspectre","image_url":"https://static.tibia.com/images/library/ripperspectre.gif","featured":false},{"name":"Roaming Dreads","race":"roamingdread","image_url":"https://static.tibia.com/images/library/roamingdread.gif","featured":false},{"name":"Roaring Lions","race":"roaringlion","image_url":"https://static.tibia.com/images/library/roaringlion.gif","featured":false},{"name":"Rootthing Amber Shapers","race":"rootthingambershaper","image_url":"https://static.tibia.com/images/library/rootthingambershaper.gif","featured":false},{"name":"Rootthing Bug Trackers","race":"rootthingbugtracker","image_url":"https://static.tibia.com/images/library/rootthingbugtracker.gif","featured":false},{"name":"Rootthing Nutshells","race":"rootthingnutshell","image_url":"https://static.tibia.com/images/library/rootthingnutshell.gif","featured":false},{"name":"Rorcs","race":"rorc","image_url":"https://static.tibia.com/images/library/rorc.gif","featured":false},{"name":"Rot Elementals","race":"rotelemental","image_url":"https://static.tibia.com/images/library/rotelemental.gif","featured":false},{"name":"Rotten Golems","race":"rottengolem","image_url":"https://static.tibia.com/images/library/rottengolem.gif","featured":false},{"name":"Rotten Man-maggots","race":"rottenmanmaggot","image_url":"https://static.tibia.com/images/library/rottenmanmaggot.gif","featured":false},{"name":"Rotworms","race":"rotworm","image_url":"https://static.tibia.com/images/library/rotworm.gif","featured":false},{"name":"Rustheap Golems","race":"rustheapgolem","image_url":"https://static.tibia.com/images/library/rustheapgolem.gif","featured":false},{"name":"Sabreteeth","race":"sabretooth","image_url":"https://static.tibia.com/images/library/sabretooth.gif","featured":false},{"name":"Salamanders","race":"salamander","image_url":"https://static.tibia.com/images/library/salamander.gif","featured":false},{"name":"Sandcrawlers","race":"sandcrawler","image_url":"https://static.tibia.com/images/library/sandcrawler.gif","featured":false},{"name":"Sandstone Scorpions","race":"sandstonescorpion","image_url":"https://static.tibia.com/images/library/sandstonescorpion.gif","featured":false},{"name":"Scarabs","race":"scarab","image_url":"https://static.tibia.com/images/library/scarab.gif","featured":false},{"name":"Scorpions","race":"scorpion","image_url":"https://static.tibia.com/images/library/scorpion.gif","featured":false},{"name":"Sea Captains","race":"seacaptain","image_url":"https://static.tibia.com/images/library/seacaptain.gif","featured":false},{"name":"Sea Serpents","race":"seaserpent","image_url":"https://static.tibia.com/images/library/seaserpent.gif","featured":false},{"name":"Seacrest Serpents","race":"seacrest","image_url":"https://static.tibia.com/images/library/seacrest.gif","featured":false},{"name":"Seagulls","race":"seagull","image_url":"https://static.tibia.com/images/library/seagull.gif","featured":false},{"name":"Serpent Spawns","race":"serpentspawn","image_url":"https://static.tibia.com/images/library/serpentspawn.gif","featured":false},{"name":"Shadow Pupils","race":"shadowpupil","image_url":"https://static.tibia.com/images/library/shadowpupil.gif","featured":false},{"name":"Shaper Matriarches","race":"shapermatriarch","image_url":"https://static.tibia.com/images/library/shapermatriarch.gif","featured":false},{"name":"Sharks","race":"shark","image_url":"https://static.tibia.com/images/library/shark.gif","featured":false},{"name":"Sheep","race":"sheep","image_url":"https://static.tibia.com/images/library/sheep.gif","featured":false},{"name":"Shell Drakes","race":"shelldrake","image_url":"https://static.tibia.com/images/library/shelldrake.gif","featured":false},{"name":"Shock Heads","race":"shockhead","image_url":"https://static.tibia.com/images/library/shockhead.gif","featured":false},{"name":"Shrieking Cry-stals","race":"shriekingcrystal","image_url":"https://static.tibia.com/images/library/shriekingcrystal.gif","featured":false},{"name":"Sibangs","race":"sibang","image_url":"https://static.tibia.com/images/library/sibang.gif","featured":false},{"name":"Sights Of Surrender","race":"sightofsurrender","image_url":"https://static.tibia.com/images/library/sightofsurrender.gif","featured":false},{"name":"Silencers","race":"silencer","image_url":"https://static.tibia.com/images/library/silencer.gif","featured":false},{"name":"Silver Rabbits","race":"silverrabbit","image_url":"https://static.tibia.com/images/library/silverrabbit.gif","featured":false},{"name":"Sineater Inferniarches","race":"sineaterinferniarch","image_url":"https://static.tibia.com/images/library/sineaterinferniarch.gif","featured":false},{"name":"Skeleton Elite Warriors","race":"skeletonelite","image_url":"https://static.tibia.com/images/library/skeletonelite.gif","featured":false},{"name":"Skeleton Warriors","race":"skeletonwarrior","image_url":"https://static.tibia.com/images/library/skeletonwarrior.gif","featured":false},{"name":"Skeletons","race":"skeleton","image_url":"https://static.tibia.com/images/library/skeleton.gif","featured":false},{"name":"Skunks","race":"skunk","image_url":"https://static.tibia.com/images/library/skunk.gif","featured":false},{"name":"Slimes","race":"slime","image_url":"https://static.tibia.com/images/library/slime.gif","featured":false},{"name":"Slugs","race":"slug","image_url":"https://static.tibia.com/images/library/slug.gif","featured":false},{"name":"Smugglers","race":"smuggler","image_url":"https://static.tibia.com/images/library/smuggler.gif","featured":false},{"name":"Snakes","race":"snake","image_url":"https://static.tibia.com/images/library/snake.gif","featured":false},{"name":"Sons Of Verminor","race":"sonofverminor","image_url":"https://static.tibia.com/images/library/sonofverminor.gif","featured":false},{"name":"Sopping Carcasses","race":"soppingcarcass","image_url":"https://static.tibia.com/images/library/soppingcarcass.gif","featured":false},{"name":"Sopping Corpuses","race":"soppingcorpus","image_url":"https://static.tibia.com/images/library/soppingcorpus.gif","featured":false},{"name":"Sorcerer's Apparitions","race":"sorcerersapparition","image_url":"https://static.tibia.com/images/library/sorcerersapparition.gif","featured":false},{"name":"Soul-broken Harbingers","race":"soulbrokenharbinger","image_url":"https://static.tibia.com/images/library/soulbrokenharbinger.gif","featured":false},{"name":"Souleaters","race":"souleater","image_url":"https://static.tibia.com/images/library/souleater.gif","featured":false},{"name":"Sparkions","race":"sparkion","image_url":"https://static.tibia.com/images/library/sparkion.gif","featured":false},{"name":"Spectres","race":"spectre","image_url":"https://static.tibia.com/images/library/spectre.gif","featured":false},{"name":"Spellreaper Inferniarches","race":"spellreaperinferniarch","image_url":"https://static.tibia.com/images/library/spellreaperinferniarch.gif","featured":false},{"name":"Sphinxes","race":"sphinx","image_url":"https://static.tibia.com/images/library/sphinx.gif","featured":false},{"name":"Spiders","race":"spider","image_url":"https://static.tibia.com/images/library/spider.gif","featured":false},{"name":"Spidris","race":"spidris","image_url":"https://static.tibia.com/images/library/spidris.gif","featured":false},{"name":"Spiky Carnivors","race":"spikycarnivor","image_url":"https://static.tibia.com/images/library/spikycarnivor.gif","featured":false},{"name":"Spit Nettles","race":"spitnettle","image_url":"https://static.tibia.com/images/library/spitnettle.gif","featured":false},{"name":"Spitters","race":"spitter","image_url":"https://static.tibia.com/images/library/spitter.gif","featured":false},{"name":"Squid Wardens","race":"squidwarden","image_url":"https://static.tibia.com/images/library/squidwarden.gif","featured":false},{"name":"Squirrels","race":"squirrel","image_url":"https://static.tibia.com/images/library/squirrel.gif","featured":false},{"name":"Stabilizing Dread Intruders","race":"stabilizingdreadintruder","image_url":"https://static.tibia.com/images/library/stabilizingdreadintruder.gif","featured":false},{"name":"Stabilizing Reality Reavers","race":"stabilizingrealityreaver","image_url":"https://static.tibia.com/images/library/stabilizingrealityreaver.gif","featured":false},{"name":"Stags","race":"stag","image_url":"https://static.tibia.com/images/library/stag.gif","featured":false},{"name":"Stalkers","race":"stalker","image_url":"https://static.tibia.com/images/library/stalker.gif","featured":false},{"name":"Stalking Stalks","race":"stalkingstalk","image_url":"https://static.tibia.com/images/library/stalkingstalk.gif","featured":false},{"name":"Stampors","race":"stampor","image_url":"https://static.tibia.com/images/library/stampor.gif","featured":false},{"name":"Stone Devourers","race":"stonedevourer","image_url":"https://static.tibia.com/images/library/stonedevourer.gif","featured":false},{"name":"Stone Golems","race":"stonegolem","image_url":"https://static.tibia.com/images/library/stonegolem.gif","featured":false},{"name":"Stone Rhinos","race":"stonerhino","image_url":"https://static.tibia.com/images/library/stonerhino.gif","featured":false},{"name":"Streaked Devourers","race":"streakeddevourer","image_url":"https://static.tibia.com/images/library/streakeddevourer.gif","featured":false},{"name":"Sugar Cube Workers","race":"sugarcubeworker","image_url":"https://static.tibia.com/images/library/sugarcubeworker.gif","featured":false},{"name":"Sugar Cubes","race":"sugarcube","image_url":"https://static.tibia.com/images/library/sugarcube.gif","featured":false},{"name":"Sulphiders","race":"sulphider","image_url":"https://static.tibia.com/images/library/sulphider.gif","featured":false},{"name":"Sulphur Spouters","race":"sulphurspouter","image_url":"https://static.tibia.com/images/library/sulphurspouter.gif","featured":false},{"name":"Swamp Trolls","race":"swamptroll","image_url":"https://static.tibia.com/images/library/swamptroll.gif","featured":false},{"name":"Swamplings","race":"swampling","image_url":"https://static.tibia.com/images/library/swampling.gif","featured":false},{"name":"Swan Maidens","race":"swanmaiden","image_url":"https://static.tibia.com/images/library/swanmaiden.gif","featured":false},{"name":"Swarmers","race":"swarmer","image_url":"https://static.tibia.com/images/library/swarmer.gif","featured":false},{"name":"Tainted Souls","race":"taintedsoul","image_url":"https://static.tibia.com/images/library/taintedsoul.gif","featured":false},{"name":"Tarantulas","race":"tarantula","image_url":"https://static.tibia.com/images/library/tarantula.gif","featured":false},{"name":"Tarnished Spirits","race":"tarnishedspirit","image_url":"https://static.tibia.com/images/library/tarnishedspirit.gif","featured":false},{"name":"Terramites","race":"terramite","image_url":"https://static.tibia.com/images/library/terramite.gif","featured":false},{"name":"Terror Birds","race":"terrorbird","image_url":"https://static.tibia.com/images/library/terrorbird.gif","featured":false},{"name":"Terrorsleeps","race":"terrorsleep","image_url":"https://static.tibia.com/images/library/terrorsleep.gif","featured":false},{"name":"Thanatursuses","race":"thanatursus","image_url":"https://static.tibia.com/images/library/thanatursus.gif","featured":false},{"name":"Thornback Tortoises","race":"thornbacktortoise","image_url":"https://static.tibia.com/images/library/thornbacktortoise.gif","featured":false},{"name":"Tigers","race":"tiger","image_url":"https://static.tibia.com/images/library/tiger.gif","featured":true},{"name":"Toads","race":"toad","image_url":"https://static.tibia.com/images/library/toad.gif","featured":false},{"name":"Tortoises","race":"tortoise","image_url":"https://static.tibia.com/images/library/tortoise.gif","featured":false},{"name":"Tremendous Tyrants","race":"tremendoustyrant","image_url":"https://static.tibia.com/images/library/tremendoustyrant.gif","featured":false},{"name":"Troll Champions","race":"trollchampion","image_url":"https://static.tibia.com/images/library/trollchampion.gif","featured":false},{"name":"Trolls","race":"troll","image_url":"https://static.tibia.com/images/library/troll.gif","featured":false},{"name":"True Dawnfire Asuras","race":"truedawnfire","image_url":"https://static.tibia.com/images/library/truedawnfire.gif","featured":false},{"name":"True Frost Flower Asuras","race":"truefrostflower","image_url":"https://static.tibia.com/images/library/truefrostflower.gif","featured":false},{"name":"True Midnight Asuras","race":"truemidnight","image_url":"https://static.tibia.com/images/library/truemidnight.gif","featured":false},{"name":"Truffle Cooks","race":"trufflecook","image_url":"https://static.tibia.com/images/library/trufflecook.gif","featured":false},{"name":"Truffles","race":"truffle","image_url":"https://static.tibia.com/images/library/truffle.gif","featured":false},{"name":"Tunnel Tyrants","race":"tunneltyrant","image_url":"https://static.tibia.com/images/library/tunneltyrant.gif","featured":false},{"name":"Turbulent Elementals","race":"turbulentelemental","image_url":"https://static.tibia.com/images/library/turbulentelemental.gif","featured":false},{"name":"Twisted Pookas","race":"twistedpooka","image_url":"https://static.tibia.com/images/library/twistedpooka.gif","featured":false},{"name":"Twisted Shapers","race":"twistedshaper","image_url":"https://static.tibia.com/images/library/twistedshaper.gif","featured":false},{"name":"Two-headed Turtles","race":"twoheadedturtle","image_url":"https://static.tibia.com/images/library/twoheadedturtle.gif","featured":false},{"name":"Undead Cavebears","race":"undeadcavebear","image_url":"https://static.tibia.com/images/library/undeadcavebear.gif","featured":false},{"name":"Undead Dragons","race":"undeaddragon","image_url":"https://static.tibia.com/images/library/undeaddragon.gif","featured":false},{"name":"Undead Elite Gladiators","race":"undeadelitegladiator","image_url":"https://static.tibia.com/images/library/undeadelitegladiator.gif","featured":false},{"name":"Undead Gladiators","race":"undeadgladiator","image_url":"https://static.tibia.com/images/library/undeadgladiator.gif","featured":false},{"name":"Undertakers","race":"undertaker","image_url":"https://static.tibia.com/images/library/undertaker.gif","featured":false},{"name":"Usurper Archers","race":"usurperarcher","image_url":"https://static.tibia.com/images/library/usurperarcher.gif","featured":false},{"name":"Usurper Commanders","race":"usurpercommander","image_url":"https://static.tibia.com/images/library/usurpercommander.gif","featured":false},{"name":"Usurper Knights","race":"usurperknight","image_url":"https://static.tibia.com/images/library/usurperknight.gif","featured":false},{"name":"Usurper Warlocks","race":"usurperwarlock","image_url":"https://static.tibia.com/images/library/usurperwarlock.gif","featured":false},{"name":"Valkyries","race":"valkyrie","image_url":"https://static.tibia.com/images/library/valkyrie.gif","featured":false},{"name":"Vampire Brides","race":"vampirebride","image_url":"https://static.tibia.com/images/library/vampirebride.gif","featured":false},{"name":"Vampire Viscounts","race":"vampireviscount","image_url":"https://static.tibia.com/images/library/vampireviscount.gif","featured":false},{"name":"Vampires","race":"vampire","image_url":"https://static.tibia.com/images/library/vampire.gif","featured":false},{"name":"Vargs","race":"varg","image_url":"https://static.tibia.com/images/library/varg.gif","featured":false},{"name":"Varnished Diremaws","race":"varnisheddiremaw","image_url":"https://static.tibia.com/images/library/varnisheddiremaw.gif","featured":false},{"name":"Venerable Girtablilus","race":"girtablilu","image_url":"https://static.tibia.com/images/library/girtablilu.gif","featured":false},{"name":"Vexclaws","race":"vexclaw","image_url":"https://static.tibia.com/images/library/vexclaw.gif","featured":false},{"name":"Vibrant Phantoms","race":"vibrantphantom","image_url":"https://static.tibia.com/images/library/vibrantphantom.gif","featured":false},{"name":"Vicious Manbats","race":"viscountmanbat","image_url":"https://static.tibia.com/images/library/viscountmanbat.gif","featured":false},{"name":"Vicious Squires","race":"vicioussquire","image_url":"https://static.tibia.com/images/library/vicioussquire.gif","featured":false},{"name":"Vile Grandmasters","race":"vilegrandmaster","image_url":"https://static.tibia.com/images/library/vilegrandmaster.gif","featured":false},{"name":"Vulcongras","race":"vulcongra","image_url":"https://static.tibia.com/images/library/vulcongra.gif","featured":false},{"name":"Wailing Widows","race":"wailingwidow","image_url":"https://static.tibia.com/images/library/wailingwidow.gif","featured":false},{"name":"Walkers","race":"walker","image_url":"https://static.tibia.com/images/library/walker.gif","featured":false},{"name":"Walking Dreads","race":"walkingdread","image_url":"https://static.tibia.com/images/library/walkingdread.gif","featured":false},{"name":"Walking Pillars","race":"walkingpillar","image_url":"https://static.tibia.com/images/library/walkingpillar.gif","featured":false},{"name":"Wandering Pillars","race":"wanderingpillar","image_url":"https://static.tibia.com/images/library/wanderingpillar.gif","featured":false},{"name":"War Golems","race":"wargolem","image_url":"https://static.tibia.com/images/library/wargolem.gif","featured":false},{"name":"War Wolves","race":"warwolf","image_url":"https://static.tibia.com/images/library/warwolf.gif","featured":false},{"name":"Wardragons","race":"wardragon","image_url":"https://static.tibia.com/images/library/wardragon.gif","featured":false},{"name":"Warlocks","race":"warlock","image_url":"https://static.tibia.com/images/library/warlock.gif","featured":false},{"name":"Waspoids","race":"waspoid","image_url":"https://static.tibia.com/images/library/waspoid.gif","featured":false},{"name":"Wasps","race":"wasp","image_url":"https://static.tibia.com/images/library/wasp.gif","featured":false},{"name":"Water Buffalos","race":"waterbuffalo","image_url":"https://static.tibia.com/images/library/waterbuffalo.gif","featured":false},{"name":"Water Elementals","race":"waterelemental","image_url":"https://static.tibia.com/images/library/waterelemental.gif","featured":false},{"name":"Weakened Frazzlemaws","race":"weakenedfrazzlemaw","image_url":"https://static.tibia.com/images/library/weakenedfrazzlemaw.gif","featured":false},{"name":"Weepers","race":"weeper","image_url":"https://static.tibia.com/images/library/weeper.gif","featured":false},{"name":"Werebadgers","race":"werebadger","image_url":"https://static.tibia.com/images/library/werebadger.gif","featured":false},{"name":"Werebears","race":"werebear","image_url":"https://static.tibia.com/images/library/werebear.gif","featured":false},{"name":"Wereboars","race":"wereboar","image_url":"https://static.tibia.com/images/library/wereboar.gif","featured":false},{"name":"Werecrocodiles","race":"werecrocodile","image_url":"https://static.tibia.com/images/library/werecrocodile.gif","featured":false},{"name":"Werefoxes","race":"werefox","image_url":"https://static.tibia.com/images/library/werefox.gif","featured":false},{"name":"Werehyaena Shamans","race":"werehyaenashaman","image_url":"https://static.tibia.com/images/library/werehyaenashaman.gif","featured":false},{"name":"Werehyaenas","race":"werehyaena","image_url":"https://static.tibia.com/images/library/werehyaena.gif","featured":false},{"name":"Werelionesses","race":"werelioness","image_url":"https://static.tibia.com/images/library/werelioness.gif","featured":false},{"name":"Werelions","race":"werelion","image_url":"https://static.tibia.com/images/library/werelion.gif","featured":false},{"name":"Werepanthers","race":"werepanther","image_url":"https://static.tibia.com/images/library/werepanther.gif","featured":false},{"name":"Weretigers","race":"weretiger","image_url":"https://static.tibia.com/images/library/weretiger.gif","featured":false},{"name":"Werewolves","race":"werewolf","image_url":"https://static.tibia.com/images/library/werewolf.gif","featured":false},{"name":"White Deer","race":"whitedeer","image_url":"https://static.tibia.com/images/library/whitedeer.gif","featured":false},{"name":"White Lions","race":"whitelion","image_url":"https://static.tibia.com/images/library/whitelion.gif","featured":false},{"name":"White Shades","race":"whiteshade","image_url":"https://static.tibia.com/images/library/whiteshade.gif","featured":false},{"name":"White Tigers","race":"whitetiger","image_url":"https://static.tibia.com/images/library/whitetiger.gif","featured":false},{"name":"White Weretigers","race":"whiteweretiger","image_url":"https://static.tibia.com/images/library/whiteweretiger.gif","featured":false},{"name":"Wigglers","race":"wiggler","image_url":"https://static.tibia.com/images/library/wiggler.gif","featured":false},{"name":"Wild Warriors","race":"wildwarrior","image_url":"https://static.tibia.com/images/library/wildwarrior.gif","featured":false},{"name":"Wilting Leaf Golems","race":"wiltingleafgolem","image_url":"https://static.tibia.com/images/library/wiltingleafgolem.gif","featured":false},{"name":"Winter Wolves","race":"winterwolf","image_url":"https://static.tibia.com/images/library/winterwolf.gif","featured":false},{"name":"Wisps","race":"wisp","image_url":"https://static.tibia.com/images/library/wisp.gif","featured":false},{"name":"Witches","race":"witch","image_url":"https://static.tibia.com/images/library/witch.gif","featured":false},{"name":"Wolves","race":"wolf","image_url":"https://static.tibia.com/images/library/wolf.gif","featured":false},{"name":"Worker Golems","race":"workergolem","image_url":"https://static.tibia.com/images/library/workergolem.gif","featured":false},{"name":"Worm Priestesses","race":"wormpriest","image_url":"https://static.tibia.com/images/library/wormpriest.gif","featured":false},{"name":"Wyrms","race":"wyrm","image_url":"https://static.tibia.com/images/library/wyrm.gif","featured":false},{"name":"Wyverns","race":"wyvern","image_url":"https://static.tibia.com/images/library/wyvern.gif","featured":false},{"name":"Yielothax","race":"yielothax","image_url":"https://static.tibia.com/images/library/yielothax.gif","featured":false},{"name":"Young Goannas","race":"younggoanna","image_url":"https://static.tibia.com/images/library/younggoanna.gif","featured":false},{"name":"Young Sea Serpents","race":"youngseaserpent","image_url":"https://static.tibia.com/images/library/youngseaserpent.gif","featured":false},{"name":"Zombies","race":"zombie","image_url":"https://static.tibia.com/images/library/zombie.gif","featured":false}]},"information":{"api":{"version":4,"release":"4.8.0","commit":"e3963de0848d8ce9d368a9cd54b306ccfddd0d15"},"timestamp":"2026-05-30T14:52:45Z","tibia_urls":["https://www.tibia.com/library/?subtopic=creatures"],"status":{"http_code":200}}} \ No newline at end of file diff --git a/tibia-bot/src/test/resources/tibiadata/guild.json b/tibia-bot/src/test/resources/tibiadata/guild.json new file mode 100644 index 0000000..a7af12a --- /dev/null +++ b/tibia-bot/src/test/resources/tibiadata/guild.json @@ -0,0 +1 @@ +{"guild":{"name":"Wrath","world":"Gladera","logo_url":"https://static.tibia.com/images/guildlogos/Wrath.gif","description":"The wrath It signals us that something potentially harmful is happening and mobilizes us to action, prepares us to get moving towards eliminating the source of the obstacle.\n\n\n\nNever let a bad situation bring out the worst in you. Choose to stay positive and be the strong person that God created you to be.","guildhalls":[{"name":"Sky Lane, Guild 1","world":"Gladera","paid_until":"2026-06-22"}],"active":true,"founded":"2023-01-29","open_applications":true,"homepage":"","in_war":false,"disband_date":"","disband_condition":"","players_online":27,"players_offline":558,"members_total":585,"members_invited":0,"members":[{"name":"Wrath Bilokasz","title":"","rank":"Soul","vocation":"Master Sorcerer","level":2029,"joined":"2026-04-03","status":"offline"},{"name":"Kenny Dark","title":"","rank":"Supremacy","vocation":"Elite Knight","level":1043,"joined":"2024-10-02","status":"offline"},{"name":"Larri","title":"","rank":"Supremacy","vocation":"Royal Paladin","level":1396,"joined":"2024-01-09","status":"offline"},{"name":"Neena","title":"","rank":"Supremacy","vocation":"Master Sorcerer","level":1419,"joined":"2025-02-11","status":"offline"},{"name":"Nene Star","title":"","rank":"Supremacy","vocation":"Elder Druid","level":1218,"joined":"2024-04-24","status":"online"},{"name":"Wrath Saga","title":"","rank":"Supremacy","vocation":"Elite Knight","level":2167,"joined":"2024-12-01","status":"offline"},{"name":"Azthran","title":"","rank":"Powerful","vocation":"Elite Knight","level":910,"joined":"2024-11-24","status":"online"},{"name":"Bathim","title":"","rank":"Powerful","vocation":"Elder Druid","level":849,"joined":"2025-09-08","status":"offline"},{"name":"Drak ko apocalipsis","title":"","rank":"Powerful","vocation":"Elite Knight","level":914,"joined":"2025-02-02","status":"offline"},{"name":"Jhony Barreiro","title":"","rank":"Powerful","vocation":"Royal Paladin","level":798,"joined":"2025-01-03","status":"online"},{"name":"Nostro Ademus","title":"","rank":"Powerful","vocation":"Royal Paladin","level":1099,"joined":"2024-09-28","status":"offline"},{"name":"Sir Kratus","title":"","rank":"Powerful","vocation":"Elite Knight","level":1157,"joined":"2025-12-11","status":"offline"},{"name":"Abul Djabar","title":"","rank":"Fury","vocation":"Royal Paladin","level":1226,"joined":"2024-10-04","status":"offline"},{"name":"Alesek","title":"","rank":"Fury","vocation":"Elite Knight","level":1137,"joined":"2024-11-19","status":"offline"},{"name":"Drako Pro","title":"","rank":"Fury","vocation":"Elder Druid","level":2061,"joined":"2026-04-17","status":"offline"},{"name":"Eternal Rokhaar","title":"","rank":"Fury","vocation":"Royal Paladin","level":2053,"joined":"2026-04-17","status":"offline"},{"name":"Gold One Hit","title":"","rank":"Fury","vocation":"Royal Paladin","level":1006,"joined":"2025-11-10","status":"offline"},{"name":"Ohh Yeah","title":"","rank":"Fury","vocation":"Elder Druid","level":1367,"joined":"2026-04-19","status":"offline"},{"name":"Ohh Yos","title":"","rank":"Fury","vocation":"Elite Knight","level":1956,"joined":"2026-04-30","status":"offline"},{"name":"Saldt","title":"","rank":"Fury","vocation":"Elder Druid","level":1060,"joined":"2024-12-13","status":"offline"},{"name":"Scarione","title":"","rank":"Fury","vocation":"Elder Druid","level":634,"joined":"2025-02-05","status":"offline"},{"name":"Vattimo","title":"","rank":"Fury","vocation":"Elite Knight","level":1546,"joined":"2025-11-04","status":"offline"},{"name":"Zeiy","title":"","rank":"Fury","vocation":"Master Sorcerer","level":1353,"joined":"2025-11-18","status":"offline"},{"name":"Abylenee","title":"","rank":"Legion","vocation":"Royal Paladin","level":970,"joined":"2024-10-09","status":"offline"},{"name":"Aluderbel Saothar","title":"","rank":"Legion","vocation":"Royal Paladin","level":742,"joined":"2025-06-13","status":"online"},{"name":"Bullthadoz","title":"","rank":"Legion","vocation":"Elite Knight","level":1223,"joined":"2026-05-07","status":"offline"},{"name":"Chadronet","title":"","rank":"Legion","vocation":"Royal Paladin","level":1193,"joined":"2026-04-15","status":"offline"},{"name":"Colossus Akaligon","title":"","rank":"Legion","vocation":"Elder Druid","level":1188,"joined":"2024-11-20","status":"offline"},{"name":"Darinnew","title":"","rank":"Legion","vocation":"Elder Druid","level":1253,"joined":"2026-04-15","status":"offline"},{"name":"Darkside Rafa","title":"","rank":"Legion","vocation":"Elite Knight","level":1082,"joined":"2024-10-04","status":"offline"},{"name":"Dragon Arkonia","title":"","rank":"Legion","vocation":"Master Sorcerer","level":1070,"joined":"2024-10-04","status":"offline"},{"name":"Dran Mix","title":"","rank":"Legion","vocation":"Elite Knight","level":860,"joined":"2025-06-13","status":"offline"},{"name":"Farahom","title":"","rank":"Legion","vocation":"Elite Knight","level":1032,"joined":"2026-04-15","status":"offline"},{"name":"Ghael Ek","title":"","rank":"Legion","vocation":"Elite Knight","level":888,"joined":"2025-12-11","status":"offline"},{"name":"Godzard","title":"","rank":"Legion","vocation":"Royal Paladin","level":679,"joined":"2025-06-20","status":"offline"},{"name":"Ithamesil Aulurim","title":"","rank":"Legion","vocation":"Royal Paladin","level":811,"joined":"2025-07-11","status":"offline"},{"name":"Jhonson Ek","title":"","rank":"Legion","vocation":"Elite Knight","level":977,"joined":"2025-08-18","status":"offline"},{"name":"John Mcklaine","title":"","rank":"Legion","vocation":"Exalted Monk","level":567,"joined":"2025-05-03","status":"offline"},{"name":"Kamoa","title":"","rank":"Legion","vocation":"Elite Knight","level":840,"joined":"2025-05-01","status":"offline"},{"name":"Knight Caracol","title":"","rank":"Legion","vocation":"Elite Knight","level":665,"joined":"2025-02-13","status":"offline"},{"name":"Lejonet fran Norden","title":"","rank":"Legion","vocation":"Knight","level":968,"joined":"2025-01-10","status":"offline"},{"name":"Natural Rustic","title":"","rank":"Legion","vocation":"Master Sorcerer","level":839,"joined":"2024-10-15","status":"offline"},{"name":"Neowi","title":"","rank":"Legion","vocation":"Elite Knight","level":974,"joined":"2026-03-28","status":"offline"},{"name":"Nizquito","title":"","rank":"Legion","vocation":"Elite Knight","level":1152,"joined":"2026-01-10","status":"offline"},{"name":"Piieniu","title":"","rank":"Legion","vocation":"Elite Knight","level":1040,"joined":"2025-03-27","status":"online"},{"name":"Salladix","title":"","rank":"Legion","vocation":"Royal Paladin","level":1128,"joined":"2024-11-21","status":"offline"},{"name":"Scadush","title":"","rank":"Legion","vocation":"Royal Paladin","level":657,"joined":"2025-06-28","status":"offline"},{"name":"Sriita gatita","title":"","rank":"Legion","vocation":"Master Sorcerer","level":497,"joined":"2025-09-26","status":"offline"},{"name":"Teruu","title":"","rank":"Legion","vocation":"Elite Knight","level":720,"joined":"2025-09-11","status":"offline"},{"name":"The Wileen","title":"","rank":"Legion","vocation":"Elite Knight","level":788,"joined":"2025-10-30","status":"offline"},{"name":"Thorr Ek","title":"","rank":"Legion","vocation":"Elite Knight","level":859,"joined":"2025-07-30","status":"online"},{"name":"Aaokii","title":"","rank":"Trooper","vocation":"Druid","level":597,"joined":"2025-12-19","status":"offline"},{"name":"Aksar","title":"","rank":"Trooper","vocation":"Elite Knight","level":335,"joined":"2026-03-16","status":"offline"},{"name":"Al Ra","title":"","rank":"Trooper","vocation":"Elite Knight","level":865,"joined":"2025-01-25","status":"offline"},{"name":"Alena Garke","title":"","rank":"Trooper","vocation":"Elite Knight","level":506,"joined":"2026-05-13","status":"offline"},{"name":"Alex mas Magnifico","title":"","rank":"Trooper","vocation":"Elite Knight","level":389,"joined":"2026-05-20","status":"offline"},{"name":"Alexreider","title":"","rank":"Trooper","vocation":"Elder Druid","level":433,"joined":"2025-09-20","status":"offline"},{"name":"Ali Jose","title":"","rank":"Trooper","vocation":"Elder Druid","level":659,"joined":"2026-03-03","status":"offline"},{"name":"Aliivanfa","title":"","rank":"Trooper","vocation":"Elite Knight","level":452,"joined":"2026-05-03","status":"offline"},{"name":"Alisichka","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":921,"joined":"2025-09-19","status":"offline"},{"name":"Amrz","title":"","rank":"Trooper","vocation":"Elite Knight","level":854,"joined":"2026-05-25","status":"offline"},{"name":"Angelozo","title":"","rank":"Trooper","vocation":"Elite Knight","level":719,"joined":"2025-11-12","status":"online"},{"name":"Angels Jokr","title":"","rank":"Trooper","vocation":"Elder Druid","level":617,"joined":"2025-03-27","status":"offline"},{"name":"Aquilitos","title":"","rank":"Trooper","vocation":"Elite Knight","level":830,"joined":"2025-02-02","status":"offline"},{"name":"Aralo Royal Knight","title":"","rank":"Trooper","vocation":"Elite Knight","level":762,"joined":"2025-09-26","status":"offline"},{"name":"Arcadez","title":"","rank":"Trooper","vocation":"Elite Knight","level":697,"joined":"2025-07-29","status":"offline"},{"name":"Armando Tim","title":"","rank":"Trooper","vocation":"Elite Knight","level":394,"joined":"2026-03-22","status":"offline"},{"name":"Arthak Cirdan","title":"","rank":"Trooper","vocation":"Elite Knight","level":510,"joined":"2026-04-02","status":"offline"},{"name":"Atarashii Gakko","title":"","rank":"Trooper","vocation":"Elite Knight","level":606,"joined":"2025-04-11","status":"offline"},{"name":"Aurazo","title":"","rank":"Trooper","vocation":"Elder Druid","level":739,"joined":"2026-04-29","status":"offline"},{"name":"Axiris","title":"","rank":"Trooper","vocation":"Elite Knight","level":652,"joined":"2025-11-24","status":"offline"},{"name":"Aymh","title":"","rank":"Trooper","vocation":"Elite Knight","level":424,"joined":"2025-12-20","status":"offline"},{"name":"Azgair Zoldyck","title":"","rank":"Trooper","vocation":"Elder Druid","level":647,"joined":"2025-10-01","status":"offline"},{"name":"Azrazeh Aika","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":230,"joined":"2025-05-12","status":"offline"},{"name":"Aztrev","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":756,"joined":"2025-02-02","status":"offline"},{"name":"Badtimes","title":"","rank":"Trooper","vocation":"Exalted Monk","level":390,"joined":"2026-04-29","status":"offline"},{"name":"Bara de pan","title":"","rank":"Trooper","vocation":"Elite Knight","level":506,"joined":"2026-01-24","status":"offline"},{"name":"Beast Martin","title":"","rank":"Trooper","vocation":"Elite Knight","level":301,"joined":"2026-02-03","status":"offline"},{"name":"Beeoowulff","title":"","rank":"Trooper","vocation":"Elite Knight","level":353,"joined":"2026-05-04","status":"offline"},{"name":"Blaaah","title":"","rank":"Trooper","vocation":"Royal Paladin","level":488,"joined":"2026-02-02","status":"offline"},{"name":"Blackxnake","title":"","rank":"Trooper","vocation":"Elite Knight","level":468,"joined":"2025-11-24","status":"offline"},{"name":"Blingen","title":"","rank":"Trooper","vocation":"Royal Paladin","level":419,"joined":"2026-03-20","status":"offline"},{"name":"Blood Blade","title":"","rank":"Trooper","vocation":"Elite Knight","level":449,"joined":"2025-06-10","status":"offline"},{"name":"Bluelight Sky","title":"","rank":"Trooper","vocation":"Royal Paladin","level":740,"joined":"2026-02-08","status":"offline"},{"name":"Bruzle","title":"","rank":"Trooper","vocation":"Royal Paladin","level":405,"joined":"2026-02-27","status":"offline"},{"name":"Capibara el sorcerer","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":505,"joined":"2026-03-18","status":"offline"},{"name":"Capitan Templario","title":"","rank":"Trooper","vocation":"Elite Knight","level":767,"joined":"2025-05-31","status":"offline"},{"name":"Capitan Zapata","title":"","rank":"Trooper","vocation":"Elder Druid","level":530,"joined":"2025-07-22","status":"offline"},{"name":"Chaparro de gladera","title":"","rank":"Trooper","vocation":"Paladin","level":279,"joined":"2025-12-02","status":"offline"},{"name":"Cheroka","title":"","rank":"Trooper","vocation":"Royal Paladin","level":560,"joined":"2026-02-14","status":"offline"},{"name":"Chikitriix","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":625,"joined":"2026-05-15","status":"offline"},{"name":"Child of Emery","title":"","rank":"Trooper","vocation":"Elite Knight","level":913,"joined":"2024-11-19","status":"offline"},{"name":"Chinita","title":"","rank":"Trooper","vocation":"Elder Druid","level":843,"joined":"2025-11-19","status":"offline"},{"name":"Choliqueta","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":417,"joined":"2026-04-21","status":"offline"},{"name":"Cloud Siphiroth","title":"","rank":"Trooper","vocation":"Elder Druid","level":597,"joined":"2026-05-08","status":"offline"},{"name":"Craxs","title":"","rank":"Trooper","vocation":"Elite Knight","level":260,"joined":"2026-04-04","status":"offline"},{"name":"Cryszus","title":"","rank":"Trooper","vocation":"Elite Knight","level":410,"joined":"2026-05-19","status":"offline"},{"name":"Dark Wen","title":"","rank":"Trooper","vocation":"Elite Knight","level":1087,"joined":"2025-05-19","status":"offline"},{"name":"Demonis Hunter","title":"","rank":"Trooper","vocation":"Royal Paladin","level":709,"joined":"2026-01-16","status":"offline"},{"name":"Denex He","title":"","rank":"Trooper","vocation":"Royal Paladin","level":498,"joined":"2026-02-25","status":"offline"},{"name":"Denniszitiz","title":"","rank":"Trooper","vocation":"Elite Knight","level":674,"joined":"2025-12-06","status":"offline"},{"name":"Dihna","title":"","rank":"Trooper","vocation":"Elite Knight","level":480,"joined":"2026-04-20","status":"offline"},{"name":"Dohkho de Libra","title":"","rank":"Trooper","vocation":"Elite Knight","level":843,"joined":"2025-10-23","status":"offline"},{"name":"Don leche Apocalipsis","title":"","rank":"Trooper","vocation":"Royal Paladin","level":888,"joined":"2025-02-04","status":"offline"},{"name":"Dope Sorc","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":881,"joined":"2026-03-23","status":"offline"},{"name":"Dopeshot","title":"","rank":"Trooper","vocation":"Royal Paladin","level":568,"joined":"2025-12-16","status":"offline"},{"name":"Dotzly Archer","title":"","rank":"Trooper","vocation":"Royal Paladin","level":582,"joined":"2025-12-22","status":"offline"},{"name":"Dukur Bulu","title":"","rank":"Trooper","vocation":"Exalted Monk","level":265,"joined":"2026-05-04","status":"offline"},{"name":"Ed Doluve","title":"","rank":"Trooper","vocation":"Elder Druid","level":372,"joined":"2026-02-19","status":"offline"},{"name":"El Charro","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":692,"joined":"2026-01-24","status":"offline"},{"name":"Elder Heero","title":"","rank":"Trooper","vocation":"Elder Druid","level":828,"joined":"2025-07-31","status":"offline"},{"name":"Elendil Veidt","title":"","rank":"Trooper","vocation":"Royal Paladin","level":1364,"joined":"2025-04-05","status":"offline"},{"name":"Elite Tavi","title":"","rank":"Trooper","vocation":"Elite Knight","level":508,"joined":"2026-04-06","status":"offline"},{"name":"Elke Gard","title":"","rank":"Trooper","vocation":"Royal Paladin","level":476,"joined":"2025-12-17","status":"offline"},{"name":"Ender Oso","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":493,"joined":"2025-12-19","status":"offline"},{"name":"Erickgabreu","title":"","rank":"Trooper","vocation":"Elite Knight","level":398,"joined":"2026-03-27","status":"offline"},{"name":"Erito Onokishi Masaichi","title":"","rank":"Trooper","vocation":"Elite Knight","level":657,"joined":"2025-11-22","status":"offline"},{"name":"Esdeathc vortigen","title":"","rank":"Trooper","vocation":"Royal Paladin","level":486,"joined":"2026-03-18","status":"offline"},{"name":"Evil Fire Avengerr","title":"","rank":"Trooper","vocation":"Royal Paladin","level":538,"joined":"2026-04-06","status":"offline"},{"name":"Fabzx","title":"","rank":"Trooper","vocation":"Elite Knight","level":771,"joined":"2026-05-06","status":"offline"},{"name":"Farahon of Magera","title":"","rank":"Trooper","vocation":"Royal Paladin","level":747,"joined":"2026-05-06","status":"offline"},{"name":"Ferchohr","title":"","rank":"Trooper","vocation":"Elite Knight","level":174,"joined":"2026-05-07","status":"online"},{"name":"Ferkonha","title":"","rank":"Trooper","vocation":"Elite Knight","level":733,"joined":"2026-05-24","status":"offline"},{"name":"Ficho the Avenger","title":"","rank":"Trooper","vocation":"Elite Knight","level":676,"joined":"2026-05-27","status":"offline"},{"name":"Flarek","title":"","rank":"Trooper","vocation":"Elite Knight","level":938,"joined":"2025-08-05","status":"offline"},{"name":"Frosty One","title":"","rank":"Trooper","vocation":"Elder Druid","level":1070,"joined":"2025-05-04","status":"offline"},{"name":"Full Alchemist Knight","title":"","rank":"Trooper","vocation":"Elite Knight","level":822,"joined":"2025-02-02","status":"offline"},{"name":"Gallebaib","title":"","rank":"Trooper","vocation":"Elite Knight","level":668,"joined":"2026-01-11","status":"offline"},{"name":"Gerar Aprendiz","title":"","rank":"Trooper","vocation":"Elite Knight","level":819,"joined":"2026-04-17","status":"offline"},{"name":"Gerune","title":"","rank":"Trooper","vocation":"Elite Knight","level":471,"joined":"2026-03-17","status":"offline"},{"name":"Gojje","title":"","rank":"Trooper","vocation":"Elite Knight","level":902,"joined":"2025-08-22","status":"offline"},{"name":"Gomessin","title":"","rank":"Trooper","vocation":"Royal Paladin","level":515,"joined":"2025-07-10","status":"offline"},{"name":"Gonfose","title":"","rank":"Trooper","vocation":"Royal Paladin","level":716,"joined":"2025-08-15","status":"online"},{"name":"Grimm Auditore","title":"","rank":"Trooper","vocation":"Elder Druid","level":844,"joined":"2025-06-21","status":"offline"},{"name":"Guajo","title":"","rank":"Trooper","vocation":"Elite Knight","level":908,"joined":"2025-12-17","status":"offline"},{"name":"Gypse Girl","title":"","rank":"Trooper","vocation":"Elder Druid","level":739,"joined":"2026-05-15","status":"offline"},{"name":"Haldhr","title":"","rank":"Trooper","vocation":"Elite Knight","level":811,"joined":"2024-12-06","status":"offline"},{"name":"Hario Stark","title":"","rank":"Trooper","vocation":"Royal Paladin","level":434,"joined":"2026-04-03","status":"offline"},{"name":"Helium","title":"","rank":"Trooper","vocation":"Elder Druid","level":743,"joined":"2026-05-25","status":"offline"},{"name":"Hero Spartan","title":"","rank":"Trooper","vocation":"Elite Knight","level":479,"joined":"2026-02-28","status":"offline"},{"name":"Hero Vo","title":"","rank":"Trooper","vocation":"Elite Knight","level":915,"joined":"2025-08-13","status":"offline"},{"name":"Hifufugi","title":"","rank":"Trooper","vocation":"Royal Paladin","level":619,"joined":"2025-12-27","status":"offline"},{"name":"Higashi Knight","title":"","rank":"Trooper","vocation":"Elite Knight","level":396,"joined":"2026-03-12","status":"offline"},{"name":"Horusx","title":"","rank":"Trooper","vocation":"Royal Paladin","level":665,"joined":"2025-11-23","status":"offline"},{"name":"Hyetta Frenzied Flame","title":"","rank":"Trooper","vocation":"Exalted Monk","level":676,"joined":"2025-06-02","status":"offline"},{"name":"Ii Pro","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":789,"joined":"2026-04-20","status":"offline"},{"name":"Inayam","title":"","rank":"Trooper","vocation":"Exalted Monk","level":323,"joined":"2026-05-10","status":"offline"},{"name":"Ingnorancia","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":729,"joined":"2025-11-02","status":"offline"},{"name":"Insane Kahzz","title":"","rank":"Trooper","vocation":"Elder Druid","level":802,"joined":"2025-11-28","status":"offline"},{"name":"Inzult","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":683,"joined":"2026-01-27","status":"offline"},{"name":"Jackos Dane","title":"","rank":"Trooper","vocation":"Elite Knight","level":754,"joined":"2025-04-20","status":"offline"},{"name":"Jane Melvar","title":"","rank":"Trooper","vocation":"Elite Knight","level":465,"joined":"2026-04-04","status":"offline"},{"name":"Jazziel Beast Bonecrusher","title":"","rank":"Trooper","vocation":"Elite Knight","level":533,"joined":"2024-11-19","status":"offline"},{"name":"Jebediah Dewolfe","title":"","rank":"Trooper","vocation":"Elite Knight","level":593,"joined":"2026-03-13","status":"offline"},{"name":"Jughhead","title":"","rank":"Trooper","vocation":"Elite Knight","level":1180,"joined":"2024-07-27","status":"offline"},{"name":"Kanmalli","title":"","rank":"Trooper","vocation":"Knight","level":481,"joined":"2026-03-13","status":"offline"},{"name":"Karolaing","title":"","rank":"Trooper","vocation":"Royal Paladin","level":866,"joined":"2025-03-23","status":"offline"},{"name":"Kavis Scol","title":"","rank":"Trooper","vocation":"Royal Paladin","level":672,"joined":"2025-04-30","status":"offline"},{"name":"Kimok Li","title":"","rank":"Trooper","vocation":"Elder Druid","level":635,"joined":"2025-12-30","status":"offline"},{"name":"Kira Lilith","title":"","rank":"Trooper","vocation":"Elder Druid","level":713,"joined":"2026-05-10","status":"offline"},{"name":"Kizuchi","title":"","rank":"Trooper","vocation":"Royal Paladin","level":531,"joined":"2026-03-20","status":"offline"},{"name":"Lady Pulgosa","title":"","rank":"Trooper","vocation":"Elder Druid","level":649,"joined":"2026-01-19","status":"offline"},{"name":"Lalo Jona","title":"","rank":"Trooper","vocation":"Elite Knight","level":574,"joined":"2026-01-23","status":"offline"},{"name":"Lara Snow","title":"","rank":"Trooper","vocation":"Royal Paladin","level":935,"joined":"2026-05-08","status":"offline"},{"name":"Lil Aleckz","title":"","rank":"Trooper","vocation":"Elder Druid","level":958,"joined":"2026-03-31","status":"offline"},{"name":"Locuts retorna","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":384,"joined":"2026-04-07","status":"offline"},{"name":"Lord Carys","title":"","rank":"Trooper","vocation":"Elite Knight","level":546,"joined":"2026-05-08","status":"offline"},{"name":"Lord Delaude","title":"","rank":"Trooper","vocation":"Elite Knight","level":750,"joined":"2026-04-22","status":"online"},{"name":"Lord Lewandowski","title":"","rank":"Trooper","vocation":"Elite Knight","level":335,"joined":"2026-03-30","status":"offline"},{"name":"Lord Moshunter","title":"","rank":"Trooper","vocation":"Royal Paladin","level":457,"joined":"2026-01-01","status":"offline"},{"name":"Lordysz","title":"","rank":"Trooper","vocation":"Elite Knight","level":585,"joined":"2026-02-08","status":"offline"},{"name":"Lost Megs","title":"","rank":"Trooper","vocation":"Royal Paladin","level":562,"joined":"2026-03-14","status":"offline"},{"name":"Lox Hell","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":979,"joined":"2025-11-04","status":"offline"},{"name":"Luffy Luke","title":"","rank":"Trooper","vocation":"Elite Knight","level":666,"joined":"2026-04-30","status":"offline"},{"name":"Maaudioo","title":"","rank":"Trooper","vocation":"Exalted Monk","level":487,"joined":"2025-12-16","status":"offline"},{"name":"Mackad","title":"","rank":"Trooper","vocation":"Royal Paladin","level":363,"joined":"2026-05-07","status":"offline"},{"name":"Mad Cap","title":"","rank":"Trooper","vocation":"Elite Knight","level":705,"joined":"2026-04-03","status":"offline"},{"name":"Madcuzbad","title":"","rank":"Trooper","vocation":"Royal Paladin","level":501,"joined":"2026-04-13","status":"offline"},{"name":"Maistro Constructor","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":229,"joined":"2026-04-15","status":"offline"},{"name":"Malak Air","title":"","rank":"Trooper","vocation":"Royal Paladin","level":510,"joined":"2026-03-21","status":"offline"},{"name":"Matheuziinhu ek","title":"","rank":"Trooper","vocation":"Elite Knight","level":394,"joined":"2025-12-21","status":"offline"},{"name":"Maximus Matt","title":"","rank":"Trooper","vocation":"Elite Knight","level":881,"joined":"2026-05-01","status":"offline"},{"name":"Maximus Smooth","title":"","rank":"Trooper","vocation":"Elite Knight","level":313,"joined":"2026-03-22","status":"offline"},{"name":"Maxymo Apocalipsis","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":778,"joined":"2026-01-15","status":"offline"},{"name":"Mefe Beast","title":"","rank":"Trooper","vocation":"Elder Druid","level":839,"joined":"2026-05-21","status":"offline"},{"name":"Megasilver","title":"Primeramente Dios","rank":"Trooper","vocation":"Royal Paladin","level":446,"joined":"2025-08-31","status":"offline"},{"name":"Meliodas Ekazor","title":"","rank":"Trooper","vocation":"Elite Knight","level":610,"joined":"2026-01-07","status":"offline"},{"name":"Memo El Azteca","title":"","rank":"Trooper","vocation":"Elite Knight","level":608,"joined":"2026-01-25","status":"offline"},{"name":"Metal Boy","title":"","rank":"Trooper","vocation":"Elite Knight","level":473,"joined":"2026-03-27","status":"offline"},{"name":"Metro Araw","title":"","rank":"Trooper","vocation":"Elite Knight","level":342,"joined":"2026-01-09","status":"offline"},{"name":"Midigan","title":"","rank":"Trooper","vocation":"Elite Knight","level":494,"joined":"2026-04-03","status":"offline"},{"name":"Migdalys","title":"","rank":"Trooper","vocation":"Elite Knight","level":968,"joined":"2024-12-13","status":"offline"},{"name":"Miitch Yuki","title":"","rank":"Trooper","vocation":"Royal Paladin","level":611,"joined":"2026-03-13","status":"offline"},{"name":"Milotrd","title":"","rank":"Trooper","vocation":"Royal Paladin","level":602,"joined":"2026-04-26","status":"offline"},{"name":"Mondragon tales","title":"","rank":"Trooper","vocation":"Elder Druid","level":493,"joined":"2026-05-29","status":"offline"},{"name":"Monke cappuccino","title":"","rank":"Trooper","vocation":"Exalted Monk","level":339,"joined":"2026-05-04","status":"offline"},{"name":"Morsemonk","title":"","rank":"Trooper","vocation":"Exalted Monk","level":299,"joined":"2026-03-06","status":"offline"},{"name":"Mosoly","title":"","rank":"Trooper","vocation":"Druid","level":461,"joined":"2025-06-15","status":"offline"},{"name":"Muscle Magic","title":"","rank":"Trooper","vocation":"Elite Knight","level":877,"joined":"2025-01-18","status":"offline"},{"name":"Nartleb","title":"","rank":"Trooper","vocation":"Elite Knight","level":840,"joined":"2025-05-21","status":"offline"},{"name":"Nighmare Sword","title":"","rank":"Trooper","vocation":"Elite Knight","level":441,"joined":"2026-05-24","status":"offline"},{"name":"Ninja Atletic","title":"","rank":"Trooper","vocation":"Royal Paladin","level":873,"joined":"2025-07-05","status":"offline"},{"name":"No Primall","title":"","rank":"Trooper","vocation":"Elite Knight","level":991,"joined":"2026-05-03","status":"online"},{"name":"Nooba","title":"","rank":"Trooper","vocation":"Elite Knight","level":721,"joined":"2025-05-04","status":"offline"},{"name":"Ohh Jacher","title":"","rank":"Trooper","vocation":"Elder Druid","level":1084,"joined":"2026-04-27","status":"offline"},{"name":"Ohtooodles","title":"","rank":"Trooper","vocation":"Exalted Monk","level":847,"joined":"2025-08-15","status":"offline"},{"name":"Okfoi","title":"","rank":"Trooper","vocation":"Elite Knight","level":643,"joined":"2026-02-02","status":"offline"},{"name":"Opeth the Monk","title":"","rank":"Trooper","vocation":"Exalted Monk","level":487,"joined":"2026-03-16","status":"offline"},{"name":"Otyken","title":"","rank":"Trooper","vocation":"Elite Knight","level":580,"joined":"2026-04-04","status":"offline"},{"name":"Outcast Knight","title":"","rank":"Trooper","vocation":"Elite Knight","level":295,"joined":"2026-04-08","status":"offline"},{"name":"Ox kuro","title":"","rank":"Trooper","vocation":"Elite Knight","level":595,"joined":"2026-01-22","status":"offline"},{"name":"Pain Sky","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":585,"joined":"2026-02-11","status":"offline"},{"name":"Palopa Da Hallow","title":"","rank":"Trooper","vocation":"Elite Knight","level":851,"joined":"2026-04-27","status":"offline"},{"name":"Pancho Panterra","title":"","rank":"Trooper","vocation":"Royal Paladin","level":1238,"joined":"2025-09-12","status":"offline"},{"name":"Pegorinoo","title":"","rank":"Trooper","vocation":"Elite Knight","level":472,"joined":"2026-04-15","status":"offline"},{"name":"Peka Mechanics","title":"","rank":"Trooper","vocation":"Elite Knight","level":619,"joined":"2025-11-19","status":"offline"},{"name":"Penellopinha","title":"","rank":"Trooper","vocation":"Royal Paladin","level":745,"joined":"2025-11-22","status":"offline"},{"name":"Perrito Cumbia","title":"","rank":"Trooper","vocation":"Elder Druid","level":444,"joined":"2026-05-08","status":"offline"},{"name":"Pilgore the Toad","title":"","rank":"Trooper","vocation":"Elder Druid","level":572,"joined":"2026-04-24","status":"offline"},{"name":"Piqueno Mestre","title":"","rank":"Trooper","vocation":"Elite Knight","level":1034,"joined":"2025-06-15","status":"online"},{"name":"Pretty Pink","title":"","rank":"Trooper","vocation":"Elite Knight","level":1388,"joined":"2026-04-03","status":"offline"},{"name":"Profesor Rakkun","title":"","rank":"Trooper","vocation":"Elite Knight","level":949,"joined":"2026-01-07","status":"offline"},{"name":"Professional Sniper","title":"","rank":"Trooper","vocation":"Royal Paladin","level":804,"joined":"2025-05-31","status":"offline"},{"name":"Pure Bone","title":"","rank":"Trooper","vocation":"Royal Paladin","level":618,"joined":"2025-06-20","status":"offline"},{"name":"Rafinha Eumemo","title":"","rank":"Trooper","vocation":"Elite Knight","level":674,"joined":"2025-11-13","status":"offline"},{"name":"Raizen Arte","title":"","rank":"Trooper","vocation":"Elder Druid","level":511,"joined":"2025-12-19","status":"online"},{"name":"Rakiti Gleemody","title":"","rank":"Trooper","vocation":"Elder Druid","level":965,"joined":"2025-05-24","status":"offline"},{"name":"Reeal Toxic","title":"","rank":"Trooper","vocation":"Paladin","level":1554,"joined":"2025-07-04","status":"offline"},{"name":"Rhodik Blaze","title":"","rank":"Trooper","vocation":"Elite Knight","level":984,"joined":"2025-03-31","status":"offline"},{"name":"Rimuru Tempesst","title":"","rank":"Trooper","vocation":"Royal Paladin","level":520,"joined":"2025-07-07","status":"offline"},{"name":"Roa The Only","title":"","rank":"Trooper","vocation":"Elite Knight","level":676,"joined":"2026-03-25","status":"offline"},{"name":"Rodgem","title":"","rank":"Trooper","vocation":"Exalted Monk","level":511,"joined":"2025-09-26","status":"offline"},{"name":"Royalsito cherrys","title":"","rank":"Trooper","vocation":"Royal Paladin","level":253,"joined":"2026-05-02","status":"offline"},{"name":"Ryota Soalhat","title":"","rank":"Trooper","vocation":"Elite Knight","level":1038,"joined":"2026-04-26","status":"offline"},{"name":"Sadtimes","title":"","rank":"Trooper","vocation":"Exalted Monk","level":506,"joined":"2025-11-01","status":"offline"},{"name":"Saintus Maba","title":"","rank":"Trooper","vocation":"Elder Druid","level":622,"joined":"2026-01-16","status":"offline"},{"name":"Samco","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":652,"joined":"2025-02-11","status":"offline"},{"name":"Schajris","title":"","rank":"Trooper","vocation":"Elder Druid","level":651,"joined":"2024-10-13","status":"offline"},{"name":"Shelter Birdy","title":"","rank":"Trooper","vocation":"Elite Knight","level":1203,"joined":"2024-11-16","status":"offline"},{"name":"Sir Arctic","title":"","rank":"Trooper","vocation":"Elder Druid","level":789,"joined":"2025-07-27","status":"online"},{"name":"Sir Peryster Hero","title":"","rank":"Trooper","vocation":"Elder Druid","level":760,"joined":"2025-07-06","status":"online"},{"name":"Skabatta","title":"","rank":"Trooper","vocation":"Elite Knight","level":616,"joined":"2026-05-08","status":"offline"},{"name":"Som Go Ku","title":"","rank":"Trooper","vocation":"Elite Knight","level":617,"joined":"2025-08-23","status":"offline"},{"name":"Spastik Ink","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":555,"joined":"2026-05-27","status":"offline"},{"name":"Speedy The Monk","title":"","rank":"Trooper","vocation":"Exalted Monk","level":307,"joined":"2026-05-13","status":"offline"},{"name":"Stellar Autumn","title":"","rank":"Trooper","vocation":"Royal Paladin","level":803,"joined":"2025-12-12","status":"offline"},{"name":"Straguimata","title":"","rank":"Trooper","vocation":"Elder Druid","level":535,"joined":"2025-12-27","status":"offline"},{"name":"Stredimus","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":1078,"joined":"2024-11-21","status":"offline"},{"name":"Strongest","title":"","rank":"Trooper","vocation":"Elite Knight","level":1009,"joined":"2024-12-18","status":"offline"},{"name":"Suiciniv Zarref","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":505,"joined":"2025-11-16","status":"online"},{"name":"Sura Knigh","title":"","rank":"Trooper","vocation":"Elite Knight","level":769,"joined":"2025-03-15","status":"offline"},{"name":"The","title":"","rank":"Trooper","vocation":"Elite Knight","level":1223,"joined":"2024-10-01","status":"offline"},{"name":"The King Aurelio","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":170,"joined":"2026-04-09","status":"offline"},{"name":"The Patetex","title":"","rank":"Trooper","vocation":"Elder Druid","level":679,"joined":"2025-08-08","status":"offline"},{"name":"Three Sword Style","title":"","rank":"Trooper","vocation":"Elite Knight","level":637,"joined":"2025-11-22","status":"offline"},{"name":"Tiro Pa Pa","title":"","rank":"Trooper","vocation":"Royal Paladin","level":740,"joined":"2024-11-29","status":"offline"},{"name":"Toin Carrapato","title":"","rank":"Trooper","vocation":"Elite Knight","level":1020,"joined":"2026-03-06","status":"offline"},{"name":"Tomy Scirez","title":"","rank":"Trooper","vocation":"Elite Knight","level":1130,"joined":"2026-01-22","status":"offline"},{"name":"Toxic Nivis","title":"","rank":"Trooper","vocation":"Royal Paladin","level":856,"joined":"2025-01-10","status":"offline"},{"name":"Triple Pe","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":195,"joined":"2026-05-04","status":"offline"},{"name":"Trit Lother","title":"","rank":"Trooper","vocation":"Royal Paladin","level":1021,"joined":"2025-02-02","status":"offline"},{"name":"Trotuska","title":"","rank":"Trooper","vocation":"Elite Knight","level":1072,"joined":"2025-03-26","status":"offline"},{"name":"Tucano Boy","title":"","rank":"Trooper","vocation":"Elite Knight","level":233,"joined":"2026-04-11","status":"offline"},{"name":"Tuco Rox","title":"","rank":"Trooper","vocation":"Elite Knight","level":743,"joined":"2025-10-17","status":"offline"},{"name":"Uchiha Sky","title":"","rank":"Trooper","vocation":"Knight","level":609,"joined":"2025-07-10","status":"offline"},{"name":"Ultras Eoweia","title":"","rank":"Trooper","vocation":"Royal Paladin","level":751,"joined":"2026-05-03","status":"offline"},{"name":"Ultrasonic","title":"","rank":"Trooper","vocation":"Elite Knight","level":803,"joined":"2026-02-05","status":"online"},{"name":"Undead Andrew","title":"","rank":"Trooper","vocation":"Royal Paladin","level":788,"joined":"2025-06-24","status":"offline"},{"name":"Unholy Therion","title":"","rank":"Trooper","vocation":"Elder Druid","level":381,"joined":"2026-03-23","status":"offline"},{"name":"Urzxel","title":"","rank":"Trooper","vocation":"Druid","level":484,"joined":"2026-03-14","status":"offline"},{"name":"Varkan","title":"","rank":"Trooper","vocation":"Royal Paladin","level":873,"joined":"2026-03-15","status":"offline"},{"name":"Vermilyon","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":1199,"joined":"2025-04-29","status":"offline"},{"name":"Virtuous Archer","title":"","rank":"Trooper","vocation":"Royal Paladin","level":1004,"joined":"2025-08-29","status":"offline"},{"name":"Voidcore","title":"","rank":"Trooper","vocation":"Elder Druid","level":542,"joined":"2026-02-14","status":"offline"},{"name":"Vola Malin","title":"","rank":"Trooper","vocation":"Elder Druid","level":440,"joined":"2026-03-13","status":"offline"},{"name":"Wicked Rauros","title":"","rank":"Trooper","vocation":"Elite Knight","level":889,"joined":"2026-05-22","status":"offline"},{"name":"Willahelm Sanctus Pelagius","title":"","rank":"Trooper","vocation":"Elite Knight","level":359,"joined":"2026-05-03","status":"offline"},{"name":"Wunnsy","title":"","rank":"Trooper","vocation":"Elite Knight","level":357,"joined":"2026-05-19","status":"online"},{"name":"Yakloy","title":"","rank":"Trooper","vocation":"Master Sorcerer","level":665,"joined":"2026-01-19","status":"offline"},{"name":"Yams ek","title":"","rank":"Trooper","vocation":"Elite Knight","level":449,"joined":"2026-04-11","status":"online"},{"name":"Yijin Jing","title":"","rank":"Trooper","vocation":"Exalted Monk","level":377,"joined":"2026-01-20","status":"offline"},{"name":"Zafar Shan","title":"","rank":"Trooper","vocation":"Royal Paladin","level":686,"joined":"2025-11-15","status":"online"},{"name":"Zangly Ozzy","title":"","rank":"Trooper","vocation":"Elite Knight","level":723,"joined":"2025-12-24","status":"offline"},{"name":"Zeratoz","title":"","rank":"Trooper","vocation":"Royal Paladin","level":1177,"joined":"2024-12-13","status":"offline"},{"name":"Zom boi","title":"","rank":"Trooper","vocation":"Elite Knight","level":1067,"joined":"2024-10-02","status":"offline"},{"name":"Aara Wadin","title":"","rank":"Rage","vocation":"Elite Knight","level":233,"joined":"2025-12-17","status":"offline"},{"name":"Abadesa","title":"","rank":"Rage","vocation":"Exalted Monk","level":470,"joined":"2025-06-28","status":"offline"},{"name":"Adasqu","title":"","rank":"Rage","vocation":"Elder Druid","level":826,"joined":"2025-05-26","status":"offline"},{"name":"Aemonj","title":"","rank":"Rage","vocation":"Monk","level":16,"joined":"2026-05-07","status":"online"},{"name":"Ailkita","title":"","rank":"Rage","vocation":"Elite Knight","level":372,"joined":"2026-03-25","status":"offline"},{"name":"Akamaru Skydiver","title":"","rank":"Rage","vocation":"Royal Paladin","level":362,"joined":"2026-02-28","status":"offline"},{"name":"Akaruz of Dracul","title":"","rank":"Rage","vocation":"Elite Knight","level":491,"joined":"2026-04-18","status":"offline"},{"name":"Akg San","title":"","rank":"Rage","vocation":"Exalted Monk","level":52,"joined":"2025-11-07","status":"online"},{"name":"Akus Azra","title":"","rank":"Rage","vocation":"Royal Paladin","level":133,"joined":"2026-04-09","status":"offline"},{"name":"Alex Del Llano","title":"","rank":"Rage","vocation":"Elder Druid","level":399,"joined":"2025-01-25","status":"offline"},{"name":"Alex El Olmeca","title":"","rank":"Rage","vocation":"Master Sorcerer","level":358,"joined":"2025-09-22","status":"offline"},{"name":"Alex Reider","title":"","rank":"Rage","vocation":"Elder Druid","level":517,"joined":"2025-10-17","status":"offline"},{"name":"Algodonero druid","title":"","rank":"Rage","vocation":"Elder Druid","level":50,"joined":"2026-05-03","status":"offline"},{"name":"Aliahs","title":"","rank":"Rage","vocation":"Elite Knight","level":440,"joined":"2025-12-17","status":"offline"},{"name":"Alix Elogan","title":"","rank":"Rage","vocation":"Exalted Monk","level":45,"joined":"2026-05-08","status":"offline"},{"name":"Amatterasu","title":"","rank":"Rage","vocation":"Master Sorcerer","level":145,"joined":"2025-07-04","status":"offline"},{"name":"Andrewsed","title":"","rank":"Rage","vocation":"Elder Druid","level":229,"joined":"2025-09-04","status":"offline"},{"name":"Anfin","title":"","rank":"Rage","vocation":"Elder Druid","level":111,"joined":"2026-05-27","status":"offline"},{"name":"Anor londo","title":"","rank":"Rage","vocation":"Royal Paladin","level":308,"joined":"2026-02-02","status":"offline"},{"name":"Aphrodi Guardian","title":"","rank":"Rage","vocation":"Master Sorcerer","level":222,"joined":"2026-03-13","status":"offline"},{"name":"Aphroditha","title":"","rank":"Rage","vocation":"Elite Knight","level":411,"joined":"2026-03-13","status":"offline"},{"name":"Archer of Eternity","title":"","rank":"Rage","vocation":"Royal Paladin","level":474,"joined":"2025-11-20","status":"offline"},{"name":"Arctic Monge","title":"","rank":"Rage","vocation":"Exalted Monk","level":396,"joined":"2026-05-13","status":"offline"},{"name":"Arcuria Thun","title":"","rank":"Rage","vocation":"Elite Knight","level":252,"joined":"2025-12-17","status":"offline"},{"name":"Arlohandor Skan","title":"","rank":"Rage","vocation":"Master Sorcerer","level":150,"joined":"2026-04-18","status":"offline"},{"name":"Art of Love","title":"","rank":"Rage","vocation":"Royal Paladin","level":99,"joined":"2023-01-29","status":"offline"},{"name":"Atlas Flamez","title":"","rank":"Rage","vocation":"Master Sorcerer","level":367,"joined":"2025-03-01","status":"offline"},{"name":"Aurrin Wave","title":"","rank":"Rage","vocation":"Elder Druid","level":330,"joined":"2025-03-05","status":"offline"},{"name":"Avenger Akaligon","title":"","rank":"Rage","vocation":"Royal Paladin","level":544,"joined":"2025-11-06","status":"offline"},{"name":"Axeel Sky","title":"","rank":"Rage","vocation":"Elder Druid","level":107,"joined":"2026-02-06","status":"offline"},{"name":"Aztekiller","title":"","rank":"Rage","vocation":"Royal Paladin","level":275,"joined":"2026-03-14","status":"offline"},{"name":"Basszk","title":"","rank":"Rage","vocation":"Elite Knight","level":304,"joined":"2024-10-02","status":"offline"},{"name":"Bastet gatita","title":"","rank":"Rage","vocation":"Exalted Monk","level":52,"joined":"2025-11-30","status":"offline"},{"name":"Beauty Dies","title":"","rank":"Rage","vocation":"Exalted Monk","level":58,"joined":"2026-05-03","status":"offline"},{"name":"Benjamin Swin","title":"","rank":"Rage","vocation":"Elite Knight","level":208,"joined":"2026-05-12","status":"offline"},{"name":"Biell Bolado","title":"","rank":"Rage","vocation":"Elite Knight","level":324,"joined":"2025-12-17","status":"offline"},{"name":"Bionic","title":"","rank":"Rage","vocation":"Elder Druid","level":673,"joined":"2024-11-29","status":"offline"},{"name":"Bixtrus","title":"","rank":"Rage","vocation":"Elite Knight","level":244,"joined":"2026-04-15","status":"offline"},{"name":"Blakjcks","title":"","rank":"Rage","vocation":"Elite Knight","level":411,"joined":"2025-11-22","status":"offline"},{"name":"Blood of light","title":"","rank":"Rage","vocation":"Elite Knight","level":102,"joined":"2026-04-19","status":"offline"},{"name":"Bob Benedictus","title":"","rank":"Rage","vocation":"Elder Druid","level":602,"joined":"2025-06-12","status":"offline"},{"name":"Bonhoeffer","title":"","rank":"Rage","vocation":"Elite Knight","level":725,"joined":"2025-11-20","status":"offline"},{"name":"Brabo Detz","title":"","rank":"Rage","vocation":"Exalted Monk","level":459,"joined":"2025-07-09","status":"offline"},{"name":"Brawlic","title":"","rank":"Rage","vocation":"Exalted Monk","level":170,"joined":"2025-08-30","status":"offline"},{"name":"Bronn Firewind","title":"","rank":"Rage","vocation":"Elite Knight","level":771,"joined":"2024-04-25","status":"offline"},{"name":"Brownie de avellana","title":"","rank":"Rage","vocation":"Elite Knight","level":409,"joined":"2026-05-12","status":"offline"},{"name":"Brugal Cola","title":"","rank":"Rage","vocation":"Master Sorcerer","level":314,"joined":"2026-04-09","status":"offline"},{"name":"Brujo Azteka","title":"","rank":"Rage","vocation":"Master Sorcerer","level":95,"joined":"2025-11-12","status":"offline"},{"name":"Burns One","title":"","rank":"Rage","vocation":"Master Sorcerer","level":496,"joined":"2025-05-14","status":"offline"},{"name":"Cachiiporro","title":"","rank":"Rage","vocation":"Royal Paladin","level":415,"joined":"2024-12-20","status":"offline"},{"name":"Cael Ybor","title":"","rank":"Rage","vocation":"Elder Druid","level":346,"joined":"2026-05-17","status":"offline"},{"name":"Cain Kronux","title":"","rank":"Rage","vocation":"Royal Paladin","level":105,"joined":"2026-04-08","status":"offline"},{"name":"Calciferitoo","title":"","rank":"Rage","vocation":"Elder Druid","level":648,"joined":"2025-12-12","status":"offline"},{"name":"Chumagora","title":"","rank":"Rage","vocation":"Elite Knight","level":621,"joined":"2025-06-28","status":"offline"},{"name":"Clelia Macae","title":"","rank":"Rage","vocation":"Elite Knight","level":499,"joined":"2025-07-05","status":"offline"},{"name":"Cler","title":"","rank":"Rage","vocation":"Elite Knight","level":137,"joined":"2026-01-23","status":"offline"},{"name":"Daenerys Mcgraw","title":"","rank":"Rage","vocation":"Master Sorcerer","level":591,"joined":"2026-05-17","status":"offline"},{"name":"Dana Wet","title":"","rank":"Rage","vocation":"Paladin","level":340,"joined":"2026-02-21","status":"offline"},{"name":"Danzera Warchief","title":"","rank":"Rage","vocation":"Master Sorcerer","level":294,"joined":"2025-12-19","status":"offline"},{"name":"Daos Munt","title":"","rank":"Rage","vocation":"Elite Knight","level":272,"joined":"2026-02-08","status":"offline"},{"name":"Dark Ficho","title":"","rank":"Rage","vocation":"Royal Paladin","level":204,"joined":"2026-05-27","status":"offline"},{"name":"Dark Zedrick","title":"","rank":"Rage","vocation":"Royal Paladin","level":292,"joined":"2026-03-13","status":"offline"},{"name":"Deathciente","title":"","rank":"Rage","vocation":"Exalted Monk","level":192,"joined":"2026-03-14","status":"offline"},{"name":"Demaciano Garen","title":"","rank":"Rage","vocation":"Knight","level":40,"joined":"2025-11-05","status":"offline"},{"name":"Deokaa","title":"","rank":"Rage","vocation":"Elite Knight","level":507,"joined":"2026-01-10","status":"offline"},{"name":"Dhamusa Luco","title":"","rank":"Rage","vocation":"Royal Paladin","level":289,"joined":"2025-11-05","status":"offline"},{"name":"Divano Rex","title":"","rank":"Rage","vocation":"Royal Paladin","level":710,"joined":"2025-01-25","status":"offline"},{"name":"Drackus","title":"","rank":"Rage","vocation":"Exalted Monk","level":207,"joined":"2026-03-13","status":"offline"},{"name":"Drakkar","title":"","rank":"Rage","vocation":"Elite Knight","level":550,"joined":"2026-03-24","status":"offline"},{"name":"Dreth Sasus","title":"","rank":"Rage","vocation":"Royal Paladin","level":369,"joined":"2026-03-14","status":"offline"},{"name":"Druid peque","title":"","rank":"Rage","vocation":"Elder Druid","level":156,"joined":"2026-05-15","status":"offline"},{"name":"Druida Del Temple","title":"","rank":"Rage","vocation":"Elder Druid","level":427,"joined":"2025-03-31","status":"offline"},{"name":"Eleanor Fenrir","title":"","rank":"Rage","vocation":"Royal Paladin","level":355,"joined":"2025-06-28","status":"offline"},{"name":"Emperor Styles","title":"","rank":"Rage","vocation":"Royal Paladin","level":448,"joined":"2026-05-12","status":"offline"},{"name":"Endorok","title":"","rank":"Rage","vocation":"Royal Paladin","level":267,"joined":"2025-02-12","status":"offline"},{"name":"Eternal Of Silent","title":"","rank":"Rage","vocation":"Elder Druid","level":1085,"joined":"2026-04-13","status":"offline"},{"name":"Ever King","title":"","rank":"Rage","vocation":"Elder Druid","level":373,"joined":"2026-03-27","status":"offline"},{"name":"Evil Black Heart","title":"","rank":"Rage","vocation":"Master Sorcerer","level":392,"joined":"2026-04-29","status":"offline"},{"name":"Evil Cabeca","title":"","rank":"Rage","vocation":"Royal Paladin","level":509,"joined":"2026-02-11","status":"offline"},{"name":"Evil Fire Avenger","title":"","rank":"Rage","vocation":"Elite Knight","level":185,"joined":"2026-04-06","status":"offline"},{"name":"Fadutti","title":"","rank":"Rage","vocation":"Elder Druid","level":687,"joined":"2025-11-19","status":"offline"},{"name":"Fadyll Merowigo","title":"","rank":"Rage","vocation":"Elite Knight","level":354,"joined":"2025-10-01","status":"offline"},{"name":"Freeway osi","title":"","rank":"Rage","vocation":"Elite Knight","level":378,"joined":"2026-03-12","status":"offline"},{"name":"Fulmetal Alchemist","title":"","rank":"Rage","vocation":"Master Sorcerer","level":125,"joined":"2025-02-02","status":"offline"},{"name":"Furia Ninja","title":"","rank":"Rage","vocation":"Elder Druid","level":148,"joined":"2025-10-08","status":"offline"},{"name":"Fylax","title":"","rank":"Rage","vocation":"Royal Paladin","level":373,"joined":"2025-08-15","status":"offline"},{"name":"Gahnyu","title":"","rank":"Rage","vocation":"Royal Paladin","level":503,"joined":"2025-12-13","status":"offline"},{"name":"Gatix","title":"","rank":"Rage","vocation":"Elder Druid","level":65,"joined":"2025-09-30","status":"offline"},{"name":"Gerver","title":"","rank":"Rage","vocation":"Royal Paladin","level":317,"joined":"2025-07-16","status":"offline"},{"name":"Ghaaal","title":"","rank":"Rage","vocation":"Elite Knight","level":763,"joined":"2026-03-14","status":"offline"},{"name":"Gid Lucione","title":"","rank":"Rage","vocation":"Elite Knight","level":419,"joined":"2025-11-13","status":"offline"},{"name":"Gladi Joana","title":"","rank":"Rage","vocation":"Elder Druid","level":649,"joined":"2025-01-15","status":"offline"},{"name":"Gold Bellona","title":"","rank":"Rage","vocation":"Elite Knight","level":123,"joined":"2025-07-04","status":"offline"},{"name":"Golden Gloves","title":"","rank":"Rage","vocation":"Exalted Monk","level":155,"joined":"2025-05-14","status":"offline"},{"name":"Goldfeder","title":"","rank":"Rage","vocation":"Elite Knight","level":170,"joined":"2026-05-02","status":"offline"},{"name":"Gorbiux","title":"","rank":"Rage","vocation":"Exalted Monk","level":201,"joined":"2025-11-19","status":"offline"},{"name":"Goss diieth","title":"","rank":"Rage","vocation":"Master Sorcerer","level":120,"joined":"2025-09-23","status":"offline"},{"name":"Gran Ley Violenta","title":"","rank":"Rage","vocation":"Elite Knight","level":263,"joined":"2026-01-16","status":"offline"},{"name":"Greater Matt","title":"","rank":"Rage","vocation":"Elder Druid","level":150,"joined":"2025-01-10","status":"offline"},{"name":"Guenhe","title":"","rank":"Rage","vocation":"Elder Druid","level":116,"joined":"2026-04-26","status":"offline"},{"name":"Gurmak","title":"","rank":"Rage","vocation":"Elite Knight","level":444,"joined":"2025-11-20","status":"offline"},{"name":"Habaard","title":"","rank":"Rage","vocation":"Exalted Monk","level":266,"joined":"2026-03-17","status":"offline"},{"name":"Hard Drax","title":"","rank":"Rage","vocation":"Elite Knight","level":213,"joined":"2026-03-17","status":"offline"},{"name":"Heraccless","title":"","rank":"Rage","vocation":"Elite Knight","level":276,"joined":"2025-04-20","status":"offline"},{"name":"Herasios","title":"","rank":"Rage","vocation":"Master Sorcerer","level":480,"joined":"2025-06-09","status":"offline"},{"name":"Hisoka Matarindo","title":"","rank":"Rage","vocation":"Royal Paladin","level":383,"joined":"2026-01-30","status":"offline"},{"name":"Hodanli","title":"","rank":"Rage","vocation":"Royal Paladin","level":360,"joined":"2025-05-29","status":"offline"},{"name":"Hollyhand","title":"","rank":"Rage","vocation":"Royal Paladin","level":642,"joined":"2025-05-24","status":"offline"},{"name":"Husdal","title":"","rank":"Rage","vocation":"Royal Paladin","level":746,"joined":"2026-05-11","status":"offline"},{"name":"Icezito knight","title":"","rank":"Rage","vocation":"Elite Knight","level":589,"joined":"2025-11-16","status":"offline"},{"name":"Ictu","title":"","rank":"Rage","vocation":"Exalted Monk","level":442,"joined":"2025-12-23","status":"offline"},{"name":"Intrunder","title":"","rank":"Rage","vocation":"Master Sorcerer","level":83,"joined":"2025-11-30","status":"offline"},{"name":"Ironclad Zomboyy","title":"","rank":"Rage","vocation":"Master Sorcerer","level":95,"joined":"2026-04-05","status":"offline"},{"name":"Irving El Tolteca","title":"","rank":"Rage","vocation":"Royal Paladin","level":366,"joined":"2025-11-07","status":"offline"},{"name":"Ithean","title":"","rank":"Rage","vocation":"Master Sorcerer","level":506,"joined":"2026-03-28","status":"offline"},{"name":"Itro","title":"","rank":"Rage","vocation":"Elder Druid","level":92,"joined":"2025-11-07","status":"offline"},{"name":"Itz Warrior","title":"","rank":"Rage","vocation":"Elite Knight","level":773,"joined":"2026-04-13","status":"offline"},{"name":"Ivansito","title":"","rank":"Rage","vocation":"Elder Druid","level":474,"joined":"2026-01-16","status":"offline"},{"name":"Ivaro el Gris","title":"","rank":"Rage","vocation":"Sorcerer","level":8,"joined":"2026-02-07","status":"offline"},{"name":"Jay Bilzerian","title":"","rank":"Rage","vocation":"Royal Paladin","level":425,"joined":"2026-03-13","status":"offline"},{"name":"Jhonson Ed","title":"","rank":"Rage","vocation":"Elder Druid","level":101,"joined":"2026-04-24","status":"offline"},{"name":"Jhoonyyy","title":"","rank":"Rage","vocation":"Exalted Monk","level":177,"joined":"2026-02-06","status":"offline"},{"name":"Jon Snowd","title":"","rank":"Rage","vocation":"Elite Knight","level":50,"joined":"2026-01-13","status":"offline"},{"name":"Jona Damz","title":"","rank":"Rage","vocation":"Elite Knight","level":210,"joined":"2026-04-25","status":"offline"},{"name":"Jpedroo","title":"","rank":"Rage","vocation":"Master Sorcerer","level":293,"joined":"2026-01-15","status":"offline"},{"name":"Ju Gert","title":"","rank":"Rage","vocation":"Royal Paladin","level":429,"joined":"2026-05-01","status":"offline"},{"name":"Jujigavi","title":"","rank":"Rage","vocation":"Master Sorcerer","level":186,"joined":"2026-04-13","status":"offline"},{"name":"Julito Tanke","title":"","rank":"Rage","vocation":"Elder Druid","level":289,"joined":"2026-02-11","status":"offline"},{"name":"Kaelis Ripper","title":"","rank":"Rage","vocation":"Elite Knight","level":333,"joined":"2026-05-24","status":"offline"},{"name":"Kamikc","title":"","rank":"Rage","vocation":"Elder Druid","level":263,"joined":"2025-12-11","status":"offline"},{"name":"Kaoru Kamy","title":"","rank":"Rage","vocation":"Elite Knight","level":220,"joined":"2026-03-22","status":"offline"},{"name":"Karnita","title":"","rank":"Rage","vocation":"Royal Paladin","level":378,"joined":"2025-10-01","status":"offline"},{"name":"Khaos Volk","title":"","rank":"Rage","vocation":"Master Sorcerer","level":114,"joined":"2026-04-22","status":"offline"},{"name":"Kingcat Ryuk","title":"","rank":"Rage","vocation":"Exalted Monk","level":24,"joined":"2026-02-07","status":"offline"},{"name":"Knight Cholo","title":"","rank":"Rage","vocation":"Elite Knight","level":524,"joined":"2025-06-28","status":"offline"},{"name":"Knight Ficho","title":"","rank":"Rage","vocation":"Elite Knight","level":50,"joined":"2026-05-27","status":"offline"},{"name":"Konjure","title":"","rank":"Rage","vocation":"Master Sorcerer","level":219,"joined":"2023-11-01","status":"offline"},{"name":"Krampuis","title":"","rank":"Rage","vocation":"Master Sorcerer","level":410,"joined":"2025-02-05","status":"offline"},{"name":"Krypper Venom","title":"","rank":"Rage","vocation":"Royal Paladin","level":168,"joined":"2026-02-28","status":"offline"},{"name":"Laine Scarlett","title":"","rank":"Rage","vocation":"Elite Knight","level":317,"joined":"2025-10-03","status":"offline"},{"name":"Lampkina","title":"","rank":"Rage","vocation":"Elite Knight","level":179,"joined":"2026-05-27","status":"offline"},{"name":"Lance Goldheart","title":"","rank":"Rage","vocation":"Royal Paladin","level":496,"joined":"2025-02-22","status":"offline"},{"name":"Laxus Monk","title":"","rank":"Rage","vocation":"Exalted Monk","level":30,"joined":"2025-06-30","status":"offline"},{"name":"Leniwe Prosie","title":"","rank":"Rage","vocation":"Elder Druid","level":180,"joined":"2026-05-07","status":"offline"},{"name":"Lewliet Nosferatu","title":"","rank":"Rage","vocation":"Elder Druid","level":283,"joined":"2026-03-06","status":"offline"},{"name":"Lexi Burton","title":"","rank":"Rage","vocation":"Royal Paladin","level":422,"joined":"2025-07-02","status":"offline"},{"name":"Lirien Frost","title":"","rank":"Rage","vocation":"Elite Knight","level":476,"joined":"2026-05-09","status":"offline"},{"name":"Little Pressure","title":"","rank":"Rage","vocation":"Royal Paladin","level":120,"joined":"2025-07-02","status":"offline"},{"name":"Lord Pulgoso","title":"","rank":"Rage","vocation":"Elder Druid","level":353,"joined":"2026-01-19","status":"offline"},{"name":"Madry Barclays","title":"","rank":"Rage","vocation":"Master Sorcerer","level":430,"joined":"2025-09-09","status":"offline"},{"name":"Mag Block","title":"","rank":"Rage","vocation":"Elite Knight","level":873,"joined":"2024-10-17","status":"offline"},{"name":"Mage Rauna","title":"","rank":"Rage","vocation":"Master Sorcerer","level":161,"joined":"2025-07-07","status":"offline"},{"name":"Majestic Maje","title":"","rank":"Rage","vocation":"Elder Druid","level":615,"joined":"2026-02-11","status":"offline"},{"name":"Markeza de avellana","title":"","rank":"Rage","vocation":"Master Sorcerer","level":350,"joined":"2026-04-09","status":"offline"},{"name":"Mefe Everywhere","title":"","rank":"Rage","vocation":"Elite Knight","level":541,"joined":"2026-05-21","status":"offline"},{"name":"Megatrunkz","title":"","rank":"Rage","vocation":"Elite Knight","level":263,"joined":"2025-08-31","status":"offline"},{"name":"Megdor","title":"","rank":"Rage","vocation":"Master Sorcerer","level":82,"joined":"2025-09-30","status":"offline"},{"name":"Meryuxs","title":"","rank":"Rage","vocation":"Royal Paladin","level":404,"joined":"2025-10-22","status":"offline"},{"name":"Meta Furia","title":"","rank":"Rage","vocation":"Elite Knight","level":261,"joined":"2025-12-19","status":"offline"},{"name":"Metro The Hunter","title":"","rank":"Rage","vocation":"Royal Paladin","level":159,"joined":"2026-05-02","status":"offline"},{"name":"Mi Nameless","title":"","rank":"Rage","vocation":"Royal Paladin","level":1017,"joined":"2024-10-02","status":"offline"},{"name":"Mi Shameless","title":"","rank":"Rage","vocation":"Exalted Monk","level":161,"joined":"2025-11-04","status":"offline"},{"name":"Missi Fortune","title":"","rank":"Rage","vocation":"Royal Paladin","level":81,"joined":"2026-05-07","status":"offline"},{"name":"Modori Tawiinz","title":"","rank":"Rage","vocation":"Royal Paladin","level":374,"joined":"2026-02-19","status":"offline"},{"name":"Mondriki","title":"","rank":"Rage","vocation":"Elite Knight","level":336,"joined":"2026-05-29","status":"offline"},{"name":"Monk Ninja","title":"","rank":"Rage","vocation":"Exalted Monk","level":157,"joined":"2025-11-10","status":"offline"},{"name":"Monk Palopa","title":"","rank":"Rage","vocation":"Exalted Monk","level":55,"joined":"2026-05-12","status":"offline"},{"name":"Monk The Emperor","title":"","rank":"Rage","vocation":"Exalted Monk","level":450,"joined":"2026-05-17","status":"offline"},{"name":"Montes Lew","title":"","rank":"Rage","vocation":"Master Sorcerer","level":213,"joined":"2026-03-22","status":"offline"},{"name":"Mufin de avellana","title":"","rank":"Rage","vocation":"Royal Paladin","level":306,"joined":"2026-04-23","status":"offline"},{"name":"Munkel","title":"","rank":"Rage","vocation":"Exalted Monk","level":153,"joined":"2026-05-03","status":"offline"},{"name":"Naberiuz","title":"","rank":"Rage","vocation":"Exalted Monk","level":122,"joined":"2026-02-20","status":"offline"},{"name":"Name change ek","title":"","rank":"Rage","vocation":"Elite Knight","level":505,"joined":"2026-05-26","status":"offline"},{"name":"Nasky Vet","title":"","rank":"Rage","vocation":"Master Sorcerer","level":74,"joined":"2026-03-13","status":"offline"},{"name":"Near In Gladera","title":"","rank":"Rage","vocation":"Elite Knight","level":132,"joined":"2025-08-04","status":"offline"},{"name":"Nechys","title":"","rank":"Rage","vocation":"Monk","level":13,"joined":"2026-05-07","status":"offline"},{"name":"Nenitta","title":"","rank":"Rage","vocation":"Exalted Monk","level":392,"joined":"2025-04-30","status":"offline"},{"name":"Nico Monk","title":"","rank":"Rage","vocation":"Exalted Monk","level":230,"joined":"2026-04-04","status":"offline"},{"name":"No Gokui","title":"","rank":"Rage","vocation":"Exalted Monk","level":50,"joined":"2026-03-12","status":"offline"},{"name":"Non sorry","title":"","rank":"Rage","vocation":"Royal Paladin","level":314,"joined":"2026-05-24","status":"offline"},{"name":"Norbertault","title":"","rank":"Rage","vocation":"Elite Knight","level":380,"joined":"2024-10-02","status":"offline"},{"name":"Norbux","title":"","rank":"Rage","vocation":"Exalted Monk","level":40,"joined":"2025-12-22","status":"offline"},{"name":"Nyxthar","title":"","rank":"Rage","vocation":"Exalted Monk","level":152,"joined":"2025-07-06","status":"offline"},{"name":"Oblivian Knight","title":"Big Baller","rank":"Rage","vocation":"Elite Knight","level":472,"joined":"2025-03-25","status":"offline"},{"name":"Observet","title":"","rank":"Rage","vocation":"Monk","level":30,"joined":"2025-12-02","status":"offline"},{"name":"Pituin","title":"","rank":"Rage","vocation":"Royal Paladin","level":299,"joined":"2025-12-19","status":"offline"},{"name":"Plebeejaze","title":"","rank":"Rage","vocation":"Elder Druid","level":428,"joined":"2026-04-19","status":"offline"},{"name":"Plebita Kirara","title":"","rank":"Rage","vocation":"Master Sorcerer","level":98,"joined":"2026-04-19","status":"offline"},{"name":"Powerful skills","title":"","rank":"Rage","vocation":"Royal Paladin","level":188,"joined":"2025-05-19","status":"offline"},{"name":"Praxxis","title":"","rank":"Rage","vocation":"Elite Knight","level":105,"joined":"2026-04-24","status":"offline"},{"name":"Purple Hazze waxx","title":"","rank":"Rage","vocation":"Exalted Monk","level":58,"joined":"2025-06-30","status":"offline"},{"name":"Rafaramza","title":"","rank":"Rage","vocation":"Royal Paladin","level":357,"joined":"2026-01-27","status":"offline"},{"name":"Ralreus","title":"","rank":"Rage","vocation":"Elder Druid","level":434,"joined":"2025-04-11","status":"offline"},{"name":"Rapsiu Snajper","title":"","rank":"Rage","vocation":"Royal Paladin","level":353,"joined":"2025-12-16","status":"offline"},{"name":"Ravaile","title":"","rank":"Rage","vocation":"Royal Paladin","level":250,"joined":"2025-12-17","status":"offline"},{"name":"Ray Again","title":"","rank":"Rage","vocation":"Royal Paladin","level":156,"joined":"2026-04-07","status":"offline"},{"name":"Real Calvon","title":"","rank":"Rage","vocation":"Elite Knight","level":986,"joined":"2026-03-29","status":"online"},{"name":"Reina Yagami","title":"","rank":"Rage","vocation":"Exalted Monk","level":159,"joined":"2026-05-03","status":"offline"},{"name":"Remgod","title":"","rank":"Rage","vocation":"Exalted Monk","level":225,"joined":"2025-11-07","status":"offline"},{"name":"Rexiion","title":"","rank":"Rage","vocation":"Knight","level":100,"joined":"2026-03-14","status":"offline"},{"name":"Rhazth","title":"","rank":"Rage","vocation":"Elite Knight","level":357,"joined":"2026-02-05","status":"offline"},{"name":"Roa Sorcerer","title":"","rank":"Rage","vocation":"Sorcerer","level":74,"joined":"2026-04-05","status":"offline"},{"name":"Rocoh","title":"","rank":"Rage","vocation":"Elder Druid","level":226,"joined":"2026-03-13","status":"offline"},{"name":"Rokkaa","title":"","rank":"Rage","vocation":"Elite Knight","level":708,"joined":"2025-06-28","status":"offline"},{"name":"Rosolyto Yunerin","title":"","rank":"Rage","vocation":"Exalted Monk","level":347,"joined":"2025-09-22","status":"offline"},{"name":"Royal Fenixe","title":"","rank":"Rage","vocation":"Royal Paladin","level":164,"joined":"2025-07-04","status":"offline"},{"name":"Royal Near Stronda","title":"","rank":"Rage","vocation":"Royal Paladin","level":37,"joined":"2025-08-04","status":"offline"},{"name":"Royal Oso","title":"","rank":"Rage","vocation":"Royal Paladin","level":82,"joined":"2026-02-03","status":"offline"},{"name":"Rpei","title":"","rank":"Rage","vocation":"Royal Paladin","level":484,"joined":"2025-10-24","status":"offline"},{"name":"Ryota Mitsuy","title":"","rank":"Rage","vocation":"Knight","level":318,"joined":"2026-04-26","status":"offline"},{"name":"Saibatzu","title":"","rank":"Rage","vocation":"Elite Knight","level":203,"joined":"2026-03-21","status":"offline"},{"name":"Sanporron ponpo","title":"","rank":"Rage","vocation":"Elder Druid","level":161,"joined":"2026-03-03","status":"offline"},{"name":"Sapo Dungeon","title":"","rank":"Rage","vocation":"Elder Druid","level":809,"joined":"2025-09-24","status":"offline"},{"name":"Scaredknight","title":"","rank":"Rage","vocation":"Elite Knight","level":100,"joined":"2026-05-04","status":"offline"},{"name":"Seasma Monk","title":"","rank":"Rage","vocation":"Monk","level":56,"joined":"2026-02-09","status":"offline"},{"name":"Sebuya","title":"","rank":"Rage","vocation":"Elder Druid","level":107,"joined":"2025-11-07","status":"offline"},{"name":"Semyaz","title":"","rank":"Rage","vocation":"Royal Paladin","level":266,"joined":"2026-03-22","status":"offline"},{"name":"Seratito","title":"","rank":"Rage","vocation":"Elder Druid","level":144,"joined":"2026-04-15","status":"offline"},{"name":"Serene Mind","title":"","rank":"Rage","vocation":"Exalted Monk","level":90,"joined":"2026-05-21","status":"offline"},{"name":"Serys","title":"","rank":"Rage","vocation":"Elder Druid","level":424,"joined":"2026-04-29","status":"offline"},{"name":"Shadow Prin","title":"","rank":"Rage","vocation":"Knight","level":18,"joined":"2026-02-07","status":"offline"},{"name":"Sham Surilius","title":"","rank":"Rage","vocation":"Exalted Monk","level":342,"joined":"2025-11-15","status":"offline"},{"name":"Shana Abert","title":"","rank":"Rage","vocation":"Sorcerer","level":45,"joined":"2025-11-05","status":"offline"},{"name":"Shi Ya Ming","title":"","rank":"Rage","vocation":"Exalted Monk","level":115,"joined":"2025-07-04","status":"offline"},{"name":"Shock Skull","title":"","rank":"Rage","vocation":"Elite Knight","level":223,"joined":"2025-12-17","status":"offline"},{"name":"Shokoviskys","title":"","rank":"Rage","vocation":"Elite Knight","level":312,"joined":"2025-11-30","status":"offline"},{"name":"Sir Richard Zeppelin","title":"","rank":"Rage","vocation":"Master Sorcerer","level":130,"joined":"2026-01-07","status":"offline"},{"name":"Sir Van Damee","title":"","rank":"Rage","vocation":"Elite Knight","level":268,"joined":"2025-12-17","status":"offline"},{"name":"Sir Zadock","title":"","rank":"Rage","vocation":"Elite Knight","level":523,"joined":"2026-03-03","status":"offline"},{"name":"Skin Fryn","title":"","rank":"Rage","vocation":"Royal Paladin","level":171,"joined":"2026-03-17","status":"offline"},{"name":"Skirtax","title":"","rank":"Rage","vocation":"Elite Knight","level":484,"joined":"2025-08-04","status":"offline"},{"name":"Skydev","title":"","rank":"Rage","vocation":"Elite Knight","level":195,"joined":"2025-11-30","status":"offline"},{"name":"Skyszek","title":"","rank":"Rage","vocation":"Elite Knight","level":136,"joined":"2025-06-06","status":"offline"},{"name":"Slartt","title":"","rank":"Rage","vocation":"Royal Paladin","level":307,"joined":"2026-05-08","status":"offline"},{"name":"Snipey One","title":"","rank":"Rage","vocation":"Royal Paladin","level":397,"joined":"2025-05-14","status":"offline"},{"name":"Sobaco de axila","title":"","rank":"Rage","vocation":"Elder Druid","level":152,"joined":"2026-04-13","status":"offline"},{"name":"Soodium","title":"","rank":"Rage","vocation":"Master Sorcerer","level":856,"joined":"2026-05-07","status":"online"},{"name":"Soul Purge","title":"","rank":"Rage","vocation":"Elder Druid","level":998,"joined":"2026-02-14","status":"offline"},{"name":"Speedy The Paladin","title":"","rank":"Rage","vocation":"Royal Paladin","level":245,"joined":"2026-05-25","status":"offline"},{"name":"Srita gatita lunar","title":"","rank":"Rage","vocation":"Elite Knight","level":343,"joined":"2025-12-30","status":"online"},{"name":"Star Buterfly","title":"","rank":"Rage","vocation":"Elite Knight","level":355,"joined":"2025-08-15","status":"offline"},{"name":"Stephensondfk","title":"","rank":"Rage","vocation":"Elder Druid","level":218,"joined":"2025-04-20","status":"offline"},{"name":"Stopa Slonia","title":"","rank":"Rage","vocation":"Exalted Monk","level":274,"joined":"2026-04-29","status":"offline"},{"name":"Strauzs","title":"","rank":"Rage","vocation":"Elite Knight","level":370,"joined":"2026-05-10","status":"offline"},{"name":"Stypinski","title":"","rank":"Rage","vocation":"Elite Knight","level":524,"joined":"2026-04-15","status":"offline"},{"name":"Subzeradelaude","title":"","rank":"Rage","vocation":"Royal Paladin","level":161,"joined":"2026-04-22","status":"offline"},{"name":"Suriah Denick","title":"","rank":"Rage","vocation":"Elder Druid","level":485,"joined":"2025-01-03","status":"offline"},{"name":"Sweet Valen","title":"","rank":"Rage","vocation":"Elite Knight","level":241,"joined":"2026-01-26","status":"offline"},{"name":"Sycoox","title":"","rank":"Rage","vocation":"Royal Paladin","level":123,"joined":"2026-05-08","status":"offline"},{"name":"Tacaa","title":"","rank":"Rage","vocation":"Paladin","level":296,"joined":"2026-03-14","status":"offline"},{"name":"Taco de sal","title":"","rank":"Rage","vocation":"Royal Paladin","level":230,"joined":"2026-05-04","status":"offline"},{"name":"Tar Miriell","title":"","rank":"Rage","vocation":"Exalted Monk","level":153,"joined":"2025-12-19","status":"offline"},{"name":"Teko Uri","title":"","rank":"Rage","vocation":"Exalted Monk","level":67,"joined":"2026-04-15","status":"offline"},{"name":"Telemarkiem Na Paralu","title":"","rank":"Rage","vocation":"Elder Druid","level":405,"joined":"2025-11-01","status":"offline"},{"name":"Thimba","title":"","rank":"Rage","vocation":"Elite Knight","level":169,"joined":"2025-09-08","status":"offline"},{"name":"Toga kina","title":"","rank":"Rage","vocation":"Elite Knight","level":833,"joined":"2025-09-17","status":"offline"},{"name":"Tommy Wong","title":"","rank":"Rage","vocation":"Elite Knight","level":116,"joined":"2025-11-07","status":"offline"},{"name":"Tono Thur","title":"","rank":"Rage","vocation":"Elite Knight","level":1017,"joined":"2026-05-30","status":"offline"},{"name":"Tonyexhunter","title":"","rank":"Rage","vocation":"Royal Paladin","level":196,"joined":"2024-12-22","status":"offline"},{"name":"Topheroo The Wizard","title":"","rank":"Rage","vocation":"Master Sorcerer","level":146,"joined":"2026-05-30","status":"online"},{"name":"Trufa de avellana","title":"","rank":"Rage","vocation":"Exalted Monk","level":92,"joined":"2026-04-23","status":"offline"},{"name":"Tumbley","title":"","rank":"Rage","vocation":"Master Sorcerer","level":142,"joined":"2025-06-28","status":"offline"},{"name":"Uzill","title":"","rank":"Rage","vocation":"Royal Paladin","level":267,"joined":"2026-04-15","status":"offline"},{"name":"Valheim","title":"","rank":"Rage","vocation":"Royal Paladin","level":343,"joined":"2025-02-05","status":"online"},{"name":"Vehy","title":"","rank":"Rage","vocation":"Elder Druid","level":530,"joined":"2024-12-29","status":"offline"},{"name":"Venom Spartan","title":"","rank":"Rage","vocation":"Elder Druid","level":109,"joined":"2026-02-28","status":"offline"},{"name":"Vice Mature","title":"","rank":"Rage","vocation":"Elder Druid","level":530,"joined":"2024-10-02","status":"offline"},{"name":"Ward Pink","title":"","rank":"Rage","vocation":"Master Sorcerer","level":261,"joined":"2025-09-10","status":"offline"},{"name":"Weboki","title":"","rank":"Rage","vocation":"Elite Knight","level":319,"joined":"2025-12-11","status":"offline"},{"name":"Willsegi","title":"","rank":"Rage","vocation":"Royal Paladin","level":230,"joined":"2026-02-08","status":"offline"},{"name":"Wywalony Wujas","title":"","rank":"Rage","vocation":"Elite Knight","level":701,"joined":"2025-12-17","status":"offline"},{"name":"Xezyi","title":"","rank":"Rage","vocation":"Exalted Monk","level":182,"joined":"2026-05-03","status":"offline"},{"name":"Xovith Cuthalion","title":"","rank":"Rage","vocation":"Royal Paladin","level":231,"joined":"2026-05-10","status":"offline"},{"name":"Yumeczek","title":"","rank":"Rage","vocation":"Elder Druid","level":618,"joined":"2026-03-17","status":"offline"},{"name":"Zealous Sniper","title":"","rank":"Rage","vocation":"Royal Paladin","level":298,"joined":"2025-11-07","status":"offline"},{"name":"Zeitek","title":"","rank":"Rage","vocation":"Elite Knight","level":702,"joined":"2026-01-02","status":"offline"},{"name":"Zeiyen","title":"","rank":"Rage","vocation":"Master Sorcerer","level":555,"joined":"2025-08-15","status":"offline"},{"name":"Zemeryt","title":"","rank":"Rage","vocation":"Exalted Monk","level":368,"joined":"2026-02-08","status":"offline"},{"name":"Zeyben","title":"","rank":"Rage","vocation":"Elite Knight","level":790,"joined":"2025-05-24","status":"offline"},{"name":"Zikfrida","title":"","rank":"Rage","vocation":"Royal Paladin","level":324,"joined":"2025-04-20","status":"offline"},{"name":"Darak obz","title":"","rank":"Relax","vocation":"Knight","level":375,"joined":"2026-01-29","status":"offline"},{"name":"Delwyn Fall","title":"","rank":"Relax","vocation":"Paladin","level":631,"joined":"2025-10-08","status":"offline"},{"name":"Demonic Ficho","title":"","rank":"Relax","vocation":"Master Sorcerer","level":138,"joined":"2026-05-27","status":"offline"},{"name":"Derixori","title":"","rank":"Relax","vocation":"Elder Druid","level":426,"joined":"2025-07-12","status":"offline"},{"name":"Fica Xonado","title":"","rank":"Relax","vocation":"Elite Knight","level":539,"joined":"2026-01-03","status":"offline"},{"name":"Jakin Otimista","title":"","rank":"Relax","vocation":"Royal Paladin","level":976,"joined":"2025-12-08","status":"offline"},{"name":"Koto Ryukk","title":"","rank":"Relax","vocation":"Royal Paladin","level":185,"joined":"2026-05-01","status":"offline"},{"name":"Laser Beams","title":"","rank":"Relax","vocation":"Master Sorcerer","level":774,"joined":"2025-08-29","status":"offline"},{"name":"Laura Sayon","title":"","rank":"Relax","vocation":"Knight","level":751,"joined":"2024-10-28","status":"offline"},{"name":"Lina Vados","title":"","rank":"Relax","vocation":"Elite Knight","level":528,"joined":"2025-10-03","status":"offline"},{"name":"Linaeth","title":"","rank":"Relax","vocation":"Royal Paladin","level":566,"joined":"2025-10-19","status":"offline"},{"name":"Liv Mortale","title":"","rank":"Relax","vocation":"Elder Druid","level":1048,"joined":"2024-11-03","status":"offline"},{"name":"Lucifer Laahad","title":"","rank":"Relax","vocation":"Knight","level":578,"joined":"2025-07-27","status":"offline"},{"name":"Roddskii","title":"Big Baller","rank":"Relax","vocation":"Elite Knight","level":685,"joined":"2025-03-25","status":"offline"},{"name":"Stark Manny","title":"","rank":"Relax","vocation":"Elder Druid","level":256,"joined":"2026-05-01","status":"offline"},{"name":"Tal Al","title":"","rank":"Relax","vocation":"Royal Paladin","level":814,"joined":"2026-01-06","status":"offline"},{"name":"Tikespit","title":"","rank":"Relax","vocation":"Elite Knight","level":882,"joined":"2025-10-31","status":"offline"},{"name":"Tonygamer","title":"","rank":"Relax","vocation":"Druid","level":458,"joined":"2024-11-19","status":"offline"},{"name":"Vene Kong","title":"","rank":"Relax","vocation":"Knight","level":804,"joined":"2025-05-19","status":"offline"},{"name":"Banco Wrath","title":"","rank":"Currency","vocation":"Knight","level":30,"joined":"2024-11-25","status":"offline"}],"invites":null},"information":{"api":{"version":4,"release":"4.8.0","commit":"e3963de0848d8ce9d368a9cd54b306ccfddd0d15"},"timestamp":"2026-05-30T15:00:43Z","tibia_urls":["https://www.tibia.com/community/?subtopic=guilds\u0026page=view\u0026GuildName=Wrath"],"status":{"http_code":200}}} \ No newline at end of file diff --git a/tibia-bot/src/test/resources/tibiadata/highscores_antica.json b/tibia-bot/src/test/resources/tibiadata/highscores_antica.json new file mode 100644 index 0000000..be1a425 --- /dev/null +++ b/tibia-bot/src/test/resources/tibiadata/highscores_antica.json @@ -0,0 +1 @@ +{"highscores":{"world":"Antica","category":"experience","vocation":"all","highscore_age":20,"highscore_list":[{"rank":1,"name":"Ranny Zolnierz Elder","vocation":"Master Sorcerer","world":"Antica","level":2153,"value":165914888571},{"rank":2,"name":"Zmek","vocation":"Elite Knight","world":"Antica","level":1999,"value":132790725936},{"rank":3,"name":"Orle Gniazdo","vocation":"Elite Knight","world":"Antica","level":1902,"value":114425649637},{"rank":4,"name":"Niquit","vocation":"Master Sorcerer","world":"Antica","level":1878,"value":110182860615},{"rank":5,"name":"Sedik","vocation":"Elder Druid","world":"Antica","level":1863,"value":107487841429},{"rank":6,"name":"Bossprime","vocation":"Elite Knight","world":"Antica","level":1850,"value":105317226353},{"rank":7,"name":"Pekky Khamzai","vocation":"Master Sorcerer","world":"Antica","level":1763,"value":91124103848},{"rank":8,"name":"Nyge","vocation":"Elite Knight","world":"Antica","level":1727,"value":85581287776},{"rank":9,"name":"Witcher Rawer","vocation":"Elder Druid","world":"Antica","level":1720,"value":84522218469},{"rank":10,"name":"Daso II","vocation":"Royal Paladin","world":"Antica","level":1705,"value":82369159598},{"rank":11,"name":"Knochi","vocation":"Elite Knight","world":"Antica","level":1701,"value":81777983831},{"rank":12,"name":"Yankoz Maestro","vocation":"Master Sorcerer","world":"Antica","level":1673,"value":77895834877},{"rank":13,"name":"Riivv","vocation":"Master Sorcerer","world":"Antica","level":1653,"value":75054922333},{"rank":14,"name":"Trusted Mage","vocation":"Master Sorcerer","world":"Antica","level":1652,"value":74965729870},{"rank":15,"name":"Furyprime","vocation":"Royal Paladin","world":"Antica","level":1644,"value":73891885427},{"rank":16,"name":"Onion koksu","vocation":"Royal Paladin","world":"Antica","level":1638,"value":72998703723},{"rank":17,"name":"John Ackerman","vocation":"Royal Paladin","world":"Antica","level":1627,"value":71589540902},{"rank":18,"name":"Piiotrus Pan","vocation":"Elder Druid","world":"Antica","level":1614,"value":69870981468},{"rank":19,"name":"Iceprime","vocation":"Elder Druid","world":"Antica","level":1594,"value":67366281508},{"rank":20,"name":"Filipec","vocation":"Elite Knight","world":"Antica","level":1543,"value":61013097623},{"rank":21,"name":"Lord Maseda","vocation":"Elite Knight","world":"Antica","level":1530,"value":59460946946},{"rank":22,"name":"Jose Arkadio Gipps","vocation":"Royal Paladin","world":"Antica","level":1516,"value":57917732278},{"rank":23,"name":"Bolk","vocation":"Master Sorcerer","world":"Antica","level":1502,"value":56304920536},{"rank":24,"name":"Danibolt","vocation":"Royal Paladin","world":"Antica","level":1470,"value":52822331628},{"rank":25,"name":"Anilde","vocation":"Royal Paladin","world":"Antica","level":1468,"value":52610870466},{"rank":26,"name":"Per Pan Nando","vocation":"Royal Paladin","world":"Antica","level":1461,"value":51790711699},{"rank":27,"name":"Zarothprime","vocation":"Master Sorcerer","world":"Antica","level":1450,"value":50643554732},{"rank":28,"name":"Sacio","vocation":"Elder Druid","world":"Antica","level":1448,"value":50443227535},{"rank":29,"name":"Eagle Rebel","vocation":"Royal Paladin","world":"Antica","level":1446,"value":50245141293},{"rank":30,"name":"Bahlor","vocation":"Elder Druid","world":"Antica","level":1444,"value":50058754285},{"rank":31,"name":"Cygi los panczos","vocation":"Master Sorcerer","world":"Antica","level":1437,"value":49327937566},{"rank":32,"name":"Sponsored by Potatos","vocation":"Royal Paladin","world":"Antica","level":1417,"value":47249396472},{"rank":33,"name":"The Ends","vocation":"Royal Paladin","world":"Antica","level":1406,"value":46139837006},{"rank":34,"name":"Pupuliukas","vocation":"Royal Paladin","world":"Antica","level":1395,"value":45097475220},{"rank":35,"name":"Zeyad","vocation":"Elder Druid","world":"Antica","level":1395,"value":45067100954},{"rank":36,"name":"Lemaozerah Amou Demais","vocation":"Elite Knight","world":"Antica","level":1386,"value":44277004819},{"rank":37,"name":"Herlik Copperfield","vocation":"Elder Druid","world":"Antica","level":1383,"value":43912740642},{"rank":38,"name":"Pawly","vocation":"Elder Druid","world":"Antica","level":1382,"value":43831320530},{"rank":39,"name":"Mythrion","vocation":"Royal Paladin","world":"Antica","level":1373,"value":42973756725},{"rank":40,"name":"Claho Ther","vocation":"Elder Druid","world":"Antica","level":1371,"value":42763498454},{"rank":41,"name":"Mano Tibeco","vocation":"Elite Knight","world":"Antica","level":1367,"value":42415356832},{"rank":42,"name":"Middel","vocation":"Master Sorcerer","world":"Antica","level":1360,"value":41769699237},{"rank":43,"name":"Xogoode","vocation":"Master Sorcerer","world":"Antica","level":1357,"value":41515554332},{"rank":44,"name":"Magicallse","vocation":"Master Sorcerer","world":"Antica","level":1354,"value":41252915561},{"rank":45,"name":"Piekielny Wojtus","vocation":"Elder Druid","world":"Antica","level":1354,"value":41250558011},{"rank":46,"name":"General Zooz","vocation":"Master Sorcerer","world":"Antica","level":1350,"value":40893292582},{"rank":47,"name":"The Joyboy","vocation":"Royal Paladin","world":"Antica","level":1350,"value":40844239333},{"rank":48,"name":"Jed Bartlet","vocation":"Royal Paladin","world":"Antica","level":1349,"value":40741701760},{"rank":49,"name":"Zygh","vocation":"Royal Paladin","world":"Antica","level":1344,"value":40339598089},{"rank":50,"name":"Vincent'Dra","vocation":"Master Sorcerer","world":"Antica","level":1342,"value":40102076612}],"highscore_page":{"current_page":1,"total_pages":20,"total_records":1000}},"information":{"api":{"version":4,"release":"4.8.0","commit":"e3963de0848d8ce9d368a9cd54b306ccfddd0d15"},"timestamp":"2026-05-30T15:00:06Z","tibia_urls":["https://www.tibia.com/community/?subtopic=highscores\u0026world=Antica\u0026category=6\u0026profession=0\u0026currentpage=1"],"status":{"http_code":200}}} \ No newline at end of file diff --git a/tibia-bot/src/test/resources/tibiadata/world_antica.json b/tibia-bot/src/test/resources/tibiadata/world_antica.json new file mode 100644 index 0000000..2fb801a --- /dev/null +++ b/tibia-bot/src/test/resources/tibiadata/world_antica.json @@ -0,0 +1 @@ +{"world":{"name":"Antica","status":"online","players_online":541,"record_players":1152,"record_date":"2026-05-01T14:19:31Z","creation_date":"1997-01","location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","world_quest_titles":["Rise of Devovorga","The Lightbearer","Bewitched","A Piece of Cake","Orcsoberfest","Winterlight Solstice","The Colours of Magic"],"battleye_protected":true,"battleye_date":"2017-08-29","game_world_type":"regular","tournament_world_type":"","online_players":[{"name":"Abu Shusha","level":131,"vocation":"Exalted Monk"},{"name":"Ace Magdy","level":1036,"vocation":"Royal Paladin"},{"name":"Acery","level":456,"vocation":"Elder Druid"},{"name":"Acriati","level":186,"vocation":"Knight"},{"name":"Acua Turvia","level":246,"vocation":"Elite Knight"},{"name":"Adam","level":119,"vocation":"Master Sorcerer"},{"name":"Adolal","level":649,"vocation":"Master Sorcerer"},{"name":"Ahh Leleklek","level":160,"vocation":"Knight"},{"name":"Akom Shana","level":3,"vocation":"Sorcerer"},{"name":"Akrhu Ranyd","level":20,"vocation":"Sorcerer"},{"name":"Alar Valyrian","level":44,"vocation":"Elite Knight"},{"name":"Aldith","level":548,"vocation":"Royal Paladin"},{"name":"Alerrinho","level":835,"vocation":"Royal Paladin"},{"name":"Alexander Nevsk","level":9,"vocation":"Knight"},{"name":"Alfagger","level":204,"vocation":"Paladin"},{"name":"Alfred Judokus Chomp","level":86,"vocation":"None"},{"name":"Alkodzejkob","level":900,"vocation":"Royal Paladin"},{"name":"Alternative Rock","level":201,"vocation":"Elite Knight"},{"name":"Alu Car","level":861,"vocation":"Royal Paladin"},{"name":"Alucarr","level":985,"vocation":"Royal Paladin"},{"name":"Alvatari Paralyse","level":925,"vocation":"Elder Druid"},{"name":"Anas the fourious","level":320,"vocation":"Elite Knight"},{"name":"Android","level":281,"vocation":"Exalted Monk"},{"name":"Andy Stark","level":26,"vocation":"Knight"},{"name":"Anemorial","level":11,"vocation":"Sorcerer"},{"name":"Angel Vitality","level":737,"vocation":"Royal Paladin"},{"name":"Angelina the Outlaw","level":449,"vocation":"Royal Paladin"},{"name":"Ani Slowa Prawdy","level":57,"vocation":"Elder Druid"},{"name":"Ant Tubis","level":264,"vocation":"Elite Knight"},{"name":"Antczyny Ek","level":38,"vocation":"Knight"},{"name":"Apayo","level":495,"vocation":"Elite Knight"},{"name":"Apex Eight","level":52,"vocation":"Elite Knight"},{"name":"Aphex Twinn","level":87,"vocation":"Paladin"},{"name":"Arceasy Eft","level":528,"vocation":"Royal Paladin"},{"name":"Archend","level":728,"vocation":"Royal Paladin"},{"name":"Arseuzt","level":386,"vocation":"Elite Knight"},{"name":"Arzas thesram","level":195,"vocation":"Royal Paladin"},{"name":"Ashaer Furmind","level":1089,"vocation":"Elder Druid"},{"name":"Assassin Brutal","level":26,"vocation":"Master Sorcerer"},{"name":"Astral Nightmare","level":81,"vocation":"Master Sorcerer"},{"name":"Aszur Askara","level":843,"vocation":"Royal Paladin"},{"name":"Ave War","level":239,"vocation":"Elder Druid"},{"name":"Avoneum","level":614,"vocation":"Royal Paladin"},{"name":"Awerro Shalke","level":113,"vocation":"Exalted Monk"},{"name":"Axeaxeaxe Tentacion","level":12,"vocation":"Knight"},{"name":"Axellak","level":103,"vocation":"Sorcerer"},{"name":"Ayreon the Seer","level":20,"vocation":"Druid"},{"name":"Azare Ethia","level":679,"vocation":"Royal Paladin"},{"name":"Baalthazar Loucious","level":244,"vocation":"Elite Knight"},{"name":"Babanin Oglu Kemal","level":46,"vocation":"Paladin"},{"name":"Babu Yaga","level":672,"vocation":"Master Sorcerer"},{"name":"Baby Stepss","level":18,"vocation":"Monk"},{"name":"Badboy Niebiesky","level":669,"vocation":"Royal Paladin"},{"name":"Baitoloboy","level":300,"vocation":"Elite Knight"},{"name":"Bandziooorek","level":434,"vocation":"Exalted Monk"},{"name":"Barikson","level":474,"vocation":"Royal Paladin"},{"name":"Basiac","level":308,"vocation":"Royal Paladin"},{"name":"Baumboll","level":421,"vocation":"Royal Paladin"},{"name":"Beekeeper Coconut","level":675,"vocation":"Elder Druid"},{"name":"Bella Eon","level":833,"vocation":"Royal Paladin"},{"name":"Beyaxtos Osson","level":8,"vocation":"Knight"},{"name":"Bezerck Iron Side","level":842,"vocation":"Elite Knight"},{"name":"Biazer","level":711,"vocation":"Royal Paladin"},{"name":"Billy Weng","level":82,"vocation":"Royal Paladin"},{"name":"Bingodturka","level":71,"vocation":"Royal Paladin"},{"name":"Black Hadik","level":290,"vocation":"Elder Druid"},{"name":"Blade Zaha","level":404,"vocation":"Elite Knight"},{"name":"Bloodbone","level":237,"vocation":"Master Sorcerer"},{"name":"Bogoomil","level":216,"vocation":"Elite Knight"},{"name":"Bombick","level":156,"vocation":"Royal Paladin"},{"name":"Bondziu","level":72,"vocation":"Elder Druid"},{"name":"Boss Copi","level":372,"vocation":"Master Sorcerer"},{"name":"Boss Skowron","level":845,"vocation":"Elite Knight"},{"name":"Boydrao","level":356,"vocation":"Elder Druid"},{"name":"Brus Sirathel","level":215,"vocation":"Master Sorcerer"},{"name":"Brutus Anticus","level":51,"vocation":"Elite Knight"},{"name":"Bubas Mare","level":110,"vocation":"Royal Paladin"},{"name":"Buch Majstyr","level":169,"vocation":"Master Sorcerer"},{"name":"Bumba Niszczyciel","level":63,"vocation":"Royal Paladin"},{"name":"Byron McAllister","level":632,"vocation":"Elite Knight"},{"name":"Candy Vendetta","level":576,"vocation":"Elder Druid"},{"name":"Capsilka","level":324,"vocation":"Elite Knight"},{"name":"Capten Ash","level":214,"vocation":"Master Sorcerer"},{"name":"Cassis Kokos","level":154,"vocation":"Elite Knight"},{"name":"Charles Stiffler","level":8,"vocation":"Knight"},{"name":"Charlover Doodkinzoo","level":303,"vocation":"Elder Druid"},{"name":"Chito el pekiador","level":542,"vocation":"Royal Paladin"},{"name":"Christopher Robin","level":112,"vocation":"Paladin"},{"name":"Ciao Organiczne","level":395,"vocation":"Elite Knight"},{"name":"Cie Obchodzii","level":891,"vocation":"Elder Druid"},{"name":"Cieply Romano","level":500,"vocation":"Master Sorcerer"},{"name":"Comprador Compulsivo Il","level":8,"vocation":"Sorcerer"},{"name":"Concrendo","level":169,"vocation":"Royal Paladin"},{"name":"Coriin Ferrero","level":73,"vocation":"Exalted Monk"},{"name":"Crank Wand","level":148,"vocation":"Master Sorcerer"},{"name":"Cursing Mati","level":162,"vocation":"Royal Paladin"},{"name":"Cute Headshooter","level":305,"vocation":"Elite Knight"},{"name":"Cybrira Lizak","level":231,"vocation":"Royal Paladin"},{"name":"Daineese","level":133,"vocation":"Royal Paladin"},{"name":"Dajciefaje","level":293,"vocation":"Elder Druid"},{"name":"Dalla Napier","level":167,"vocation":"Elite Knight"},{"name":"Dalor Eyen","level":707,"vocation":"Master Sorcerer"},{"name":"Dannataque Gythbles","level":18,"vocation":"Knight"},{"name":"Darem Leywin","level":424,"vocation":"Master Sorcerer"},{"name":"Darth mantecus","level":44,"vocation":"Elite Knight"},{"name":"Dasol","level":906,"vocation":"Elder Druid"},{"name":"Davisz Bonecruscher","level":403,"vocation":"Royal Paladin"},{"name":"Dawaj Mnie","level":170,"vocation":"Royal Paladin"},{"name":"Debowy","level":224,"vocation":"Elite Knight"},{"name":"Dedzor","level":935,"vocation":"Master Sorcerer"},{"name":"Deh Hush","level":356,"vocation":"Elder Druid"},{"name":"Delgis","level":139,"vocation":"Elder Druid"},{"name":"Demiah Velothi","level":420,"vocation":"Royal Paladin"},{"name":"Derkmo","level":392,"vocation":"Master Sorcerer"},{"name":"Desiree des Morts","level":658,"vocation":"Royal Paladin"},{"name":"Desspero","level":197,"vocation":"Royal Paladin"},{"name":"Destructor Nightmare","level":158,"vocation":"Elite Knight"},{"name":"Dezoweg Sinsemilla","level":16,"vocation":"Knight"},{"name":"Distancehunter","level":665,"vocation":"Royal Paladin"},{"name":"Dominujacy Przemek","level":220,"vocation":"Master Sorcerer"},{"name":"Don Fraagoso","level":608,"vocation":"Elite Knight"},{"name":"Don Groove","level":1169,"vocation":"Elite Knight"},{"name":"Donmats Sniper","level":532,"vocation":"Royal Paladin"},{"name":"Doomruler","level":914,"vocation":"Elite Knight"},{"name":"Dracko Ellebsko","level":787,"vocation":"Elite Knight"},{"name":"Drattwa","level":317,"vocation":"Elite Knight"},{"name":"Drax Xoth","level":508,"vocation":"Royal Paladin"},{"name":"Dream Gourmet","level":897,"vocation":"Elder Druid"},{"name":"Duksum","level":759,"vocation":"Royal Paladin"},{"name":"Dutsuor","level":666,"vocation":"Royal Paladin"},{"name":"Dzejcob","level":249,"vocation":"Paladin"},{"name":"Dzelen","level":1252,"vocation":"Elite Knight"},{"name":"Eili Edel","level":505,"vocation":"Master Sorcerer"},{"name":"El Alamia","level":793,"vocation":"Royal Paladin"},{"name":"El Chinosaurio","level":8,"vocation":"Monk"},{"name":"Elbulkoo","level":953,"vocation":"Elite Knight"},{"name":"Elder Monks","level":72,"vocation":"Exalted Monk"},{"name":"Eldhor Sauana","level":111,"vocation":"Paladin"},{"name":"Eletrodo Revestido","level":14,"vocation":"Sorcerer"},{"name":"Elite Bronson","level":615,"vocation":"Elite Knight"},{"name":"Elite Leqt","level":255,"vocation":"Elite Knight"},{"name":"Elius Theos","level":140,"vocation":"Elite Knight"},{"name":"Elovio","level":472,"vocation":"Elder Druid"},{"name":"Eluma Elish","level":889,"vocation":"Elder Druid"},{"name":"Enzo Rossini","level":455,"vocation":"Elder Druid"},{"name":"Eri Halszki","level":642,"vocation":"Master Sorcerer"},{"name":"Espinacas Arena","level":574,"vocation":"Elite Knight"},{"name":"Ethel Dord","level":504,"vocation":"Royal Paladin"},{"name":"Euskefeurat","level":160,"vocation":"Druid"},{"name":"Evil Dikkos","level":116,"vocation":"Elite Knight"},{"name":"Evocation Shinigami Charlie","level":562,"vocation":"Royal Paladin"},{"name":"Evrette","level":875,"vocation":"Elite Knight"},{"name":"Festerjor Behaind","level":206,"vocation":"Exalted Monk"},{"name":"Fihesute","level":845,"vocation":"Elite Knight"},{"name":"Filemoon la Shadow","level":810,"vocation":"Master Sorcerer"},{"name":"Fiord","level":29,"vocation":"Elite Knight"},{"name":"Fistofthefury","level":475,"vocation":"Exalted Monk"},{"name":"Fjunkster","level":1044,"vocation":"Exalted Monk"},{"name":"Fokopies","level":48,"vocation":"Elite Knight"},{"name":"Folksagan","level":374,"vocation":"Elite Knight"},{"name":"Fortune Arterial","level":594,"vocation":"Elite Knight"},{"name":"Foxsake","level":585,"vocation":"Elite Knight"},{"name":"Franciis Drake","level":864,"vocation":"Royal Paladin"},{"name":"Fredshanks","level":97,"vocation":"Elite Knight"},{"name":"Frogos","level":42,"vocation":"Royal Paladin"},{"name":"Frontalny Rycerz Gasnicowy","level":8,"vocation":"Knight"},{"name":"Fuwex","level":430,"vocation":"Royal Paladin"},{"name":"Fystyki","level":36,"vocation":"Royal Paladin"},{"name":"Gatko nela","level":82,"vocation":"Elder Druid"},{"name":"Gelly","level":275,"vocation":"Elder Druid"},{"name":"Gemenskap","level":1138,"vocation":"Royal Paladin"},{"name":"General Nosee","level":721,"vocation":"Elite Knight"},{"name":"Gieldowy Krach","level":348,"vocation":"Elder Druid"},{"name":"Gigachad Sigma Monk","level":753,"vocation":"Exalted Monk"},{"name":"Gladiator Max","level":75,"vocation":"Elite Knight"},{"name":"Glessar","level":727,"vocation":"Elite Knight"},{"name":"Globe","level":753,"vocation":"Royal Paladin"},{"name":"Godofspeed","level":132,"vocation":"Elder Druid"},{"name":"Goferrek","level":376,"vocation":"Elite Knight"},{"name":"Goku Ultra Instynkt","level":147,"vocation":"Elder Druid"},{"name":"Gotte Hipton","level":8,"vocation":"Druid"},{"name":"Gradesiu","level":635,"vocation":"Exalted Monk"},{"name":"Greats Druid","level":604,"vocation":"Elder Druid"},{"name":"Green Darter","level":35,"vocation":"Paladin"},{"name":"Grina Alis","level":8,"vocation":"Paladin"},{"name":"Grypsser","level":253,"vocation":"Elite Knight"},{"name":"Guaxiniobeso","level":19,"vocation":"Paladin"},{"name":"Gwastis","level":49,"vocation":"Knight"},{"name":"Hamachan","level":432,"vocation":"Exalted Monk"},{"name":"Hamilton Metalurgico","level":724,"vocation":"Master Sorcerer"},{"name":"Happonen","level":503,"vocation":"Royal Paladin"},{"name":"Haytamz Oligarch","level":1010,"vocation":"Elder Druid"},{"name":"Hazaakk","level":695,"vocation":"Elite Knight"},{"name":"Heaven Ek","level":480,"vocation":"Elite Knight"},{"name":"Hellplazer","level":267,"vocation":"Royal Paladin"},{"name":"Helmut from Kopiec","level":273,"vocation":"Elite Knight"},{"name":"Hercodo","level":223,"vocation":"Elite Knight"},{"name":"Herostri Thik","level":62,"vocation":"Knight"},{"name":"Hexif","level":780,"vocation":"Elite Knight"},{"name":"Hexxxan","level":173,"vocation":"Royal Paladin"},{"name":"Hilin Riza","level":14,"vocation":"Knight"},{"name":"Hiragi Guren","level":92,"vocation":"Elite Knight"},{"name":"Hky","level":538,"vocation":"Royal Paladin"},{"name":"Hoo Xoo","level":308,"vocation":"Elder Druid"},{"name":"Hottog","level":635,"vocation":"Elder Druid"},{"name":"Hubbabuba Owocowa","level":661,"vocation":"Royal Paladin"},{"name":"Huryy","level":710,"vocation":"Elite Knight"},{"name":"Ichyoo","level":260,"vocation":"Elite Knight"},{"name":"Idk Heal You","level":649,"vocation":"Elder Druid"},{"name":"Igneus Vox","level":117,"vocation":"Master Sorcerer"},{"name":"Ilid","level":564,"vocation":"Master Sorcerer"},{"name":"Im Leico","level":513,"vocation":"Elder Druid"},{"name":"Imperador da Betoneira","level":9,"vocation":"Sorcerer"},{"name":"Incredible Noisy Piglet","level":47,"vocation":"Paladin"},{"name":"Indestructible Luki","level":467,"vocation":"Elder Druid"},{"name":"Infrata Disone","level":27,"vocation":"Knight"},{"name":"Insonia Nofake","level":1138,"vocation":"Elder Druid"},{"name":"Integra Fairbrook Wingate","level":251,"vocation":"Master Sorcerer"},{"name":"Invadur","level":756,"vocation":"Elder Druid"},{"name":"Ironman Gamy","level":88,"vocation":"Master Sorcerer"},{"name":"Iskold","level":264,"vocation":"Elite Knight"},{"name":"Itadori Yuuji","level":27,"vocation":"Exalted Monk"},{"name":"Ivvann","level":421,"vocation":"Elite Knight"},{"name":"Jabali del Bosque","level":76,"vocation":"Royal Paladin"},{"name":"Jao em antica","level":8,"vocation":"Knight"},{"name":"Jaredi","level":502,"vocation":"Elite Knight"},{"name":"Jed Bartlet","level":1349,"vocation":"Royal Paladin"},{"name":"Jem dopalacze","level":80,"vocation":"Elder Druid"},{"name":"Jimmie Bond","level":672,"vocation":"Royal Paladin"},{"name":"John Pepinho","level":238,"vocation":"Elite Knight"},{"name":"Johnny Bombastick","level":99,"vocation":"Master Sorcerer"},{"name":"Johnny Diesel","level":50,"vocation":"Knight"},{"name":"Johnny Ponny","level":111,"vocation":"Druid"},{"name":"Jose Arkadio Gipps","level":1516,"vocation":"Royal Paladin"},{"name":"Jossie","level":585,"vocation":"Royal Paladin"},{"name":"Junim Wahaknon","level":3,"vocation":"Sorcerer"},{"name":"Kadzienny Sorcerer","level":744,"vocation":"Master Sorcerer"},{"name":"Kagon Aiyamus","level":12,"vocation":"Sorcerer"},{"name":"Kahal Vake","level":608,"vocation":"Royal Paladin"},{"name":"Kaien Eder","level":67,"vocation":"Exalted Monk"},{"name":"Kaj szczelosz","level":39,"vocation":"Paladin"},{"name":"Kajczerek","level":468,"vocation":"Elite Knight"},{"name":"Kamikaze Algramo","level":157,"vocation":"Exalted Monk"},{"name":"Kaszankolot","level":304,"vocation":"Elder Druid"},{"name":"Kat Infernae","level":90,"vocation":"Master Sorcerer"},{"name":"Keki Kudasai","level":425,"vocation":"Paladin"},{"name":"Kenta Magiker","level":8,"vocation":"Sorcerer"},{"name":"Kici Koko","level":624,"vocation":"Elite Knight"},{"name":"Kiedis","level":639,"vocation":"Royal Paladin"},{"name":"Kierowca Mopa","level":451,"vocation":"Royal Paladin"},{"name":"Kijocek","level":490,"vocation":"Elder Druid"},{"name":"Killducks","level":28,"vocation":"Monk"},{"name":"King Hicktor","level":892,"vocation":"Royal Paladin"},{"name":"King stars","level":426,"vocation":"Elder Druid"},{"name":"King Teiko","level":1112,"vocation":"Master Sorcerer"},{"name":"Knight Letai","level":862,"vocation":"Elite Knight"},{"name":"Kolsyratvatten","level":127,"vocation":"Elite Knight"},{"name":"Kondzionator","level":182,"vocation":"Elite Knight"},{"name":"Koomi","level":81,"vocation":"Royal Paladin"},{"name":"Kraehe","level":1016,"vocation":"Elite Knight"},{"name":"Krieger","level":800,"vocation":"Royal Paladin"},{"name":"Krislley","level":681,"vocation":"Elite Knight"},{"name":"Krucza Ada","level":86,"vocation":"Royal Paladin"},{"name":"Kruzios","level":461,"vocation":"Elite Knight"},{"name":"Krystal Mev","level":190,"vocation":"Master Sorcerer"},{"name":"Ksenocyd","level":316,"vocation":"Master Sorcerer"},{"name":"Kwit Do Cywila","level":375,"vocation":"Royal Paladin"},{"name":"Kyabo Stonehaze","level":313,"vocation":"Elite Knight"},{"name":"Lady Andaria","level":550,"vocation":"Elite Knight"},{"name":"Lady Sonek","level":429,"vocation":"Elite Knight"},{"name":"Lady van Mave","level":954,"vocation":"Elder Druid"},{"name":"Lalukofeja Saniko Farszevijot","level":1017,"vocation":"Elder Druid"},{"name":"Laranja King","level":231,"vocation":"Elder Druid"},{"name":"Latira Flechas","level":1003,"vocation":"Royal Paladin"},{"name":"Leavethis Koboj","level":656,"vocation":"Royal Paladin"},{"name":"Lee Haze","level":666,"vocation":"Elite Knight"},{"name":"Legacyofrain","level":1076,"vocation":"Elite Knight"},{"name":"Lemon Blueberry Haze","level":37,"vocation":"Sorcerer"},{"name":"Leth Jazina","level":8,"vocation":"Monk"},{"name":"Lifefail","level":166,"vocation":"Elite Knight"},{"name":"Lito Maldito","level":1091,"vocation":"Elite Knight"},{"name":"Lockyl","level":355,"vocation":"Elder Druid"},{"name":"Lodziarz Leon","level":518,"vocation":"Royal Paladin"},{"name":"Lord Morcego","level":696,"vocation":"Elite Knight"},{"name":"Lubom","level":39,"vocation":"Royal Paladin"},{"name":"Lucius Tazin","level":84,"vocation":"Exalted Monk"},{"name":"Luckyai","level":8,"vocation":"Knight"},{"name":"Lybal Ston","level":331,"vocation":"Master Sorcerer"},{"name":"Maan Ieek","level":181,"vocation":"Elite Knight"},{"name":"Maandarynka","level":892,"vocation":"Royal Paladin"},{"name":"Macio Bez Orzecha","level":123,"vocation":"Royal Paladin"},{"name":"Magiczny Duch","level":1257,"vocation":"Master Sorcerer"},{"name":"Makaroni Pastelani","level":146,"vocation":"Paladin"},{"name":"Makbye","level":17,"vocation":"Sorcerer"},{"name":"Malkan Royal","level":98,"vocation":"Royal Paladin"},{"name":"Maly Chlop","level":49,"vocation":"Sorcerer"},{"name":"Mamiwho","level":418,"vocation":"Royal Paladin"},{"name":"Manieeek","level":317,"vocation":"Royal Paladin"},{"name":"Marcin Poka Elektryka","level":95,"vocation":"Master Sorcerer"},{"name":"Mardin","level":655,"vocation":"Elite Knight"},{"name":"Marek Perepeczko","level":8,"vocation":"Sorcerer"},{"name":"Maria Chuanita","level":436,"vocation":"Elder Druid"},{"name":"Marian krool","level":124,"vocation":"Elder Druid"},{"name":"Maskin Bror","level":9,"vocation":"Sorcerer"},{"name":"Mavs Cloutmma","level":492,"vocation":"Elite Knight"},{"name":"Mayleea Blackheart","level":23,"vocation":"Elder Druid"},{"name":"Melonova","level":708,"vocation":"Elite Knight"},{"name":"Menczolek","level":990,"vocation":"Elite Knight"},{"name":"Menina Zikaw","level":316,"vocation":"Royal Paladin"},{"name":"Merfi Knight","level":614,"vocation":"Elite Knight"},{"name":"Merudrian","level":779,"vocation":"Exalted Monk"},{"name":"Metamind","level":257,"vocation":"Master Sorcerer"},{"name":"Mfarid","level":346,"vocation":"Master Sorcerer"},{"name":"Michal Wyvern Maly","level":636,"vocation":"Royal Paladin"},{"name":"Michuuuu","level":211,"vocation":"Elder Druid"},{"name":"Miegoeltstron","level":153,"vocation":"Elite Knight"},{"name":"Miguel oprimido","level":8,"vocation":"Knight"},{"name":"Mini Antica","level":8,"vocation":"Paladin"},{"name":"Miniooo","level":169,"vocation":"Master Sorcerer"},{"name":"Mirahu Jane","level":256,"vocation":"Royal Paladin"},{"name":"Mirlo An","level":8,"vocation":"Knight"},{"name":"Mistekone","level":48,"vocation":"Elite Knight"},{"name":"Mister Bad Luck","level":150,"vocation":"Elite Knight"},{"name":"Mistrawa Ek","level":91,"vocation":"Elite Knight"},{"name":"Mistyczny Eksporter","level":335,"vocation":"Master Sorcerer"},{"name":"Mocarny Czarownik","level":16,"vocation":"Sorcerer"},{"name":"Moedasmax","level":22,"vocation":"Knight"},{"name":"Mommoth","level":693,"vocation":"Elite Knight"},{"name":"Monkowiec","level":275,"vocation":"Exalted Monk"},{"name":"Morgil Morix","level":104,"vocation":"Master Sorcerer"},{"name":"Morvaine","level":18,"vocation":"Paladin"},{"name":"Munch","level":392,"vocation":"Royal Paladin"},{"name":"Muu Uunk","level":440,"vocation":"Exalted Monk"},{"name":"Myrenthil","level":706,"vocation":"Elder Druid"},{"name":"Mystic Monkman","level":12,"vocation":"Monk"},{"name":"Nabesna Glacier","level":49,"vocation":"Exalted Monk"},{"name":"Naczelnik Fuso","level":1065,"vocation":"Elder Druid"},{"name":"Napalmneon","level":467,"vocation":"Master Sorcerer"},{"name":"Napfek","level":1013,"vocation":"Royal Paladin"},{"name":"Napfkova","level":960,"vocation":"Elder Druid"},{"name":"Neige Noire","level":572,"vocation":"Elite Knight"},{"name":"Nenexeta","level":1069,"vocation":"Royal Paladin"},{"name":"Nhya ko","level":552,"vocation":"Elder Druid"},{"name":"Niesmialy Hazardzista","level":142,"vocation":"Royal Paladin"},{"name":"Nitro Bendek","level":60,"vocation":"Master Sorcerer"},{"name":"Nod one","level":10,"vocation":"Paladin"},{"name":"Notorious Wiso","level":891,"vocation":"Elite Knight"},{"name":"Nydandyn","level":86,"vocation":"Royal Paladin"},{"name":"Oakstrider","level":533,"vocation":"Royal Paladin"},{"name":"Obsrane Stringi","level":28,"vocation":"Sorcerer"},{"name":"Ohley","level":933,"vocation":"Elder Druid"},{"name":"Old School Bodybuilding","level":763,"vocation":"Elite Knight"},{"name":"Oliec","level":890,"vocation":"Master Sorcerer"},{"name":"Olycklig","level":343,"vocation":"Elite Knight"},{"name":"On Warriors","level":120,"vocation":"Exalted Monk"},{"name":"Oobie","level":327,"vocation":"Royal Paladin"},{"name":"Orchata Disfruton","level":774,"vocation":"Elite Knight"},{"name":"Oros Belo","level":325,"vocation":"Master Sorcerer"},{"name":"Ortaaz","level":443,"vocation":"Royal Paladin"},{"name":"Osamcza","level":368,"vocation":"Royal Paladin"},{"name":"Oskarenko The Noss","level":143,"vocation":"Exalted Monk"},{"name":"Ozzy Anticaa","level":8,"vocation":"Knight"},{"name":"Paitah On","level":719,"vocation":"Exalted Monk"},{"name":"Pakiet Amg","level":554,"vocation":"Elite Knight"},{"name":"Pal Goral","level":229,"vocation":"Royal Paladin"},{"name":"Pali Ragu","level":103,"vocation":"Royal Paladin"},{"name":"Pan Admiiraltutaj","level":337,"vocation":"Elite Knight"},{"name":"Pan Sebiks","level":1005,"vocation":"Royal Paladin"},{"name":"Papichuliitoo","level":1098,"vocation":"Master Sorcerer"},{"name":"Patruya Espiritual","level":633,"vocation":"Royal Paladin"},{"name":"Patte Quick","level":839,"vocation":"Elite Knight"},{"name":"Peck Alax","level":669,"vocation":"Elder Druid"},{"name":"Peky Targaryen","level":252,"vocation":"Master Sorcerer"},{"name":"Penelope Serimoor","level":477,"vocation":"Royal Paladin"},{"name":"Perez de Vargas","level":9,"vocation":"Knight"},{"name":"Perna Exorcista","level":857,"vocation":"Elite Knight"},{"name":"Pet Five","level":63,"vocation":"Sorcerer"},{"name":"Piratov","level":1160,"vocation":"Elite Knight"},{"name":"Ponky Pliky","level":14,"vocation":"Knight"},{"name":"Potrzebuje Wody","level":109,"vocation":"Elite Knight"},{"name":"Pravi Levi","level":15,"vocation":"Paladin"},{"name":"Prokasa","level":1037,"vocation":"Elder Druid"},{"name":"Pruszku","level":563,"vocation":"Royal Paladin"},{"name":"Przeszkadzator","level":51,"vocation":"Master Sorcerer"},{"name":"Pug","level":532,"vocation":"Master Sorcerer"},{"name":"Pulhow","level":861,"vocation":"Elite Knight"},{"name":"Pyrfeusz","level":804,"vocation":"Elite Knight"},{"name":"Qlinse Mage","level":807,"vocation":"Elder Druid"},{"name":"Qrisuu","level":400,"vocation":"Royal Paladin"},{"name":"Quara Lecter","level":1000,"vocation":"Elder Druid"},{"name":"Queen Jezebel","level":49,"vocation":"Master Sorcerer"},{"name":"Rackherael Terim","level":35,"vocation":"Sorcerer"},{"name":"Radantus","level":27,"vocation":"Knight"},{"name":"Raged fist","level":627,"vocation":"Exalted Monk"},{"name":"Rasto Prime","level":177,"vocation":"Elite Knight"},{"name":"Ratajkoo","level":157,"vocation":"Master Sorcerer"},{"name":"Rei Chileno","level":651,"vocation":"Royal Paladin"},{"name":"Reivux","level":176,"vocation":"Knight"},{"name":"Renarin Junorgem","level":1319,"vocation":"Master Sorcerer"},{"name":"Resko","level":508,"vocation":"Elite Knight"},{"name":"Rewaleh","level":638,"vocation":"Elite Knight"},{"name":"Rewkero","level":198,"vocation":"Master Sorcerer"},{"name":"Ricky Diamond","level":801,"vocation":"Elite Knight"},{"name":"Robert Bruxo","level":544,"vocation":"Master Sorcerer"},{"name":"Roffa Sensei","level":1141,"vocation":"Royal Paladin"},{"name":"Rolanupo","level":173,"vocation":"Elite Knight"},{"name":"Roma Nunos","level":108,"vocation":"Royal Paladin"},{"name":"Rookiie Mistakes","level":538,"vocation":"Elder Druid"},{"name":"Rozumesz","level":356,"vocation":"Royal Paladin"},{"name":"Rzadkie itemy","level":8,"vocation":"Paladin"},{"name":"Sam Swift","level":191,"vocation":"Royal Paladin"},{"name":"Sameuly","level":854,"vocation":"Elite Knight"},{"name":"Santa of Xerena","level":751,"vocation":"Elite Knight"},{"name":"Saragah","level":151,"vocation":"Master Sorcerer"},{"name":"Saredo","level":157,"vocation":"Master Sorcerer"},{"name":"Schattenjaeger","level":903,"vocation":"Royal Paladin"},{"name":"Sebek Wielki","level":90,"vocation":"Master Sorcerer"},{"name":"Sem Echeta","level":316,"vocation":"Elite Knight"},{"name":"Shakszuka","level":365,"vocation":"Royal Paladin"},{"name":"Shpryca","level":491,"vocation":"Elite Knight"},{"name":"Shteekaarn","level":1164,"vocation":"Royal Paladin"},{"name":"Silentshell","level":1328,"vocation":"Royal Paladin"},{"name":"Silver Shadowblade","level":1150,"vocation":"Elite Knight"},{"name":"Sir Malaje","level":352,"vocation":"Royal Paladin"},{"name":"Sir Okonomitaki","level":730,"vocation":"Elite Knight"},{"name":"Sith Sair","level":14,"vocation":"Paladin"},{"name":"Skun Zabujca","level":24,"vocation":"Paladin"},{"name":"Smoozi","level":1066,"vocation":"Elder Druid"},{"name":"Smutsen","level":594,"vocation":"Master Sorcerer"},{"name":"Snajper Lukas","level":342,"vocation":"Royal Paladin"},{"name":"Sogod","level":16,"vocation":"Paladin"},{"name":"Sok Pozeczkowy","level":100,"vocation":"Elder Druid"},{"name":"Solidny Pioterek","level":30,"vocation":"Elite Knight"},{"name":"Solo Moon","level":23,"vocation":"Knight"},{"name":"Spandzio","level":915,"vocation":"Royal Paladin"},{"name":"Spocona Antylopa","level":886,"vocation":"Royal Paladin"},{"name":"Stan Khoras","level":399,"vocation":"Master Sorcerer"},{"name":"Sterno","level":147,"vocation":"Elder Druid"},{"name":"Stissig","level":103,"vocation":"Master Sorcerer"},{"name":"Stivin","level":340,"vocation":"Royal Paladin"},{"name":"Storm Holy","level":254,"vocation":"Royal Paladin"},{"name":"Stormr","level":136,"vocation":"Exalted Monk"},{"name":"Stritwoczach","level":53,"vocation":"Royal Paladin"},{"name":"Sufqweqweggf","level":1112,"vocation":"Elder Druid"},{"name":"Suney","level":53,"vocation":"Royal Paladin"},{"name":"Suppastriqr","level":41,"vocation":"Royal Paladin"},{"name":"Suspendie","level":238,"vocation":"Royal Paladin"},{"name":"Synoptic","level":326,"vocation":"Elite Knight"},{"name":"Szafa Leczy","level":953,"vocation":"Elder Druid"},{"name":"Szamootulak","level":87,"vocation":"Sorcerer"},{"name":"Szhowxz","level":626,"vocation":"Elder Druid"},{"name":"Szkolnik","level":37,"vocation":"Elder Druid"},{"name":"Szorstkii Zajac","level":706,"vocation":"Elite Knight"},{"name":"Szybka Szpachla","level":63,"vocation":"Elite Knight"},{"name":"Szympkovsky","level":1207,"vocation":"Elite Knight"},{"name":"Tabitha Galavan","level":537,"vocation":"Elite Knight"},{"name":"Tabulek","level":60,"vocation":"Master Sorcerer"},{"name":"Tallo Lotha","level":259,"vocation":"Royal Paladin"},{"name":"Tamal Tankando","level":156,"vocation":"Elite Knight"},{"name":"Taxian","level":14,"vocation":"Sorcerer"},{"name":"Teh Reaper","level":8,"vocation":"Druid"},{"name":"Teraz Mnie","level":21,"vocation":"Sorcerer"},{"name":"The Bowman","level":1010,"vocation":"Royal Paladin"},{"name":"The Ends","level":1406,"vocation":"Royal Paladin"},{"name":"The Shadowbringer","level":492,"vocation":"Royal Paladin"},{"name":"Thug Pablito","level":87,"vocation":"Elite Knight"},{"name":"Thusear Darax","level":302,"vocation":"Elder Druid"},{"name":"Tiases Blackheart","level":22,"vocation":"Elite Knight"},{"name":"Tinig Gharakir","level":33,"vocation":"Master Sorcerer"},{"name":"Tiotmap","level":449,"vocation":"Elder Druid"},{"name":"Tirelivix Alon","level":408,"vocation":"Elite Knight"},{"name":"Tiro de cena","level":823,"vocation":"Royal Paladin"},{"name":"Tomris Khan","level":355,"vocation":"Elite Knight"},{"name":"Tomson Rook","level":37,"vocation":"Paladin"},{"name":"Tonguithaa","level":865,"vocation":"Elder Druid"},{"name":"Tosieczek Wariacik","level":412,"vocation":"Royal Paladin"},{"name":"Toxic Jiddra","level":548,"vocation":"Royal Paladin"},{"name":"Toxic Sebixxx","level":547,"vocation":"Master Sorcerer"},{"name":"Tresrayas","level":50,"vocation":"Elite Knight"},{"name":"Tricke","level":330,"vocation":"Master Sorcerer"},{"name":"Trisham","level":234,"vocation":"Elite Knight"},{"name":"Truck Kunn","level":677,"vocation":"Master Sorcerer"},{"name":"True Zwyrol","level":464,"vocation":"Master Sorcerer"},{"name":"Trumpster Trev Trev","level":532,"vocation":"Elite Knight"},{"name":"Truskosek","level":252,"vocation":"Elite Knight"},{"name":"Tsukiha Matsunari","level":263,"vocation":"Druid"},{"name":"Turbinoskorbin","level":232,"vocation":"Royal Paladin"},{"name":"Turecki Kefir","level":212,"vocation":"Elite Knight"},{"name":"Twoopac","level":918,"vocation":"Elder Druid"},{"name":"Tygrysz","level":551,"vocation":"Elite Knight"},{"name":"Umasol","level":117,"vocation":"Elder Druid"},{"name":"Unffer","level":82,"vocation":"Elite Knight"},{"name":"Urizhen Abyzz","level":506,"vocation":"Royal Paladin"},{"name":"Valingerino","level":8,"vocation":"Knight"},{"name":"Vampire Night","level":90,"vocation":"Elite Knight"},{"name":"Vem Soultainter","level":8,"vocation":"Knight"},{"name":"Verczi","level":732,"vocation":"Master Sorcerer"},{"name":"Verosa","level":377,"vocation":"Elite Knight"},{"name":"Verybig Troll","level":8,"vocation":"Knight"},{"name":"Vladokz","level":578,"vocation":"Royal Paladin"},{"name":"Voljin","level":556,"vocation":"Royal Paladin"},{"name":"Von Setremix","level":327,"vocation":"Elite Knight"},{"name":"Waaks","level":603,"vocation":"Elite Knight"},{"name":"Waleado","level":908,"vocation":"Elite Knight"},{"name":"Waloddi den Vise","level":332,"vocation":"Exalted Monk"},{"name":"War Deviill","level":74,"vocation":"Master Sorcerer"},{"name":"Warrior Antica","level":63,"vocation":"Elite Knight"},{"name":"Wevototex","level":8,"vocation":"Sorcerer"},{"name":"Wielbladem Na Svargrond","level":231,"vocation":"Exalted Monk"},{"name":"Wild Patryk","level":29,"vocation":"Paladin"},{"name":"Winter Priest","level":15,"vocation":"Druid"},{"name":"Wizzyte","level":1271,"vocation":"Royal Paladin"},{"name":"Wojownik Jake","level":24,"vocation":"Druid"},{"name":"Wojtulek","level":720,"vocation":"Master Sorcerer"},{"name":"Wookiewad","level":1121,"vocation":"Elite Knight"},{"name":"Wsiowy Zulik","level":189,"vocation":"Elder Druid"},{"name":"Wyslannik Onyxa","level":8,"vocation":"Monk"},{"name":"Xeluturuil Trin","level":415,"vocation":"Elite Knight"},{"name":"Xeraph","level":181,"vocation":"Master Sorcerer"},{"name":"Xeronat","level":255,"vocation":"Royal Paladin"},{"name":"Xikle","level":802,"vocation":"Elite Knight"},{"name":"Xooneya","level":530,"vocation":"Elder Druid"},{"name":"Yardmon Rharya","level":93,"vocation":"Royal Paladin"},{"name":"Yeti Wielka Stopa","level":341,"vocation":"Exalted Monk"},{"name":"Yo hji","level":8,"vocation":"Sorcerer"},{"name":"Yumikhal","level":191,"vocation":"Elder Druid"},{"name":"Zaginiony Chemik","level":243,"vocation":"Elder Druid"},{"name":"Zaklinacz Wezy","level":476,"vocation":"Elder Druid"},{"name":"Zalemoon","level":46,"vocation":"Elder Druid"},{"name":"Zamino","level":435,"vocation":"Royal Paladin"},{"name":"Zarrash","level":93,"vocation":"Exalted Monk"},{"name":"Zathrov","level":583,"vocation":"Master Sorcerer"},{"name":"Zax Sweet Warrior","level":560,"vocation":"Elite Knight"},{"name":"Zedivers Atiop","level":7,"vocation":"Paladin"},{"name":"Zekkal","level":751,"vocation":"Master Sorcerer"},{"name":"Zesar Thundershield","level":479,"vocation":"Elite Knight"},{"name":"Zginiesz sdkiem","level":227,"vocation":"Master Sorcerer"},{"name":"Zjadacz Frytek","level":42,"vocation":"Royal Paladin"},{"name":"Zmorge","level":853,"vocation":"Royal Paladin"},{"name":"Zumito Stargazer","level":31,"vocation":"Elite Knight"},{"name":"Zuol","level":153,"vocation":"Elite Knight"}]},"information":{"api":{"version":4,"release":"4.8.0","commit":"e3963de0848d8ce9d368a9cd54b306ccfddd0d15"},"timestamp":"2026-05-30T14:57:46Z","tibia_urls":["https://www.tibia.com/community/?subtopic=worlds\u0026world=Antica"],"status":{"http_code":200}}} \ No newline at end of file diff --git a/tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala new file mode 100644 index 0000000..1269537 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala @@ -0,0 +1,73 @@ +package com.tibiabot + +import com.tibiabot.presentation.{BoostedEmbeds, DeathEmbeds} +import com.tibiabot.tibiadata.JsonSupport +import com.tibiabot.tibiadata.response._ +import com.tibiabot.tracking.{LevelRecord, LevelTracker} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import spray.json._ + +import java.time.ZonedDateTime + +/** + * Threads REAL decoded TibiaData fixtures through the bot's production logic + * (the seams actually wired into the live path): level-up detection + * (LevelTracker, wired in the stream), the death embed and the boosted embed. + * This goes a step past the decoder tests — it checks the bot does the right + * thing with real API data, not just that the JSON parses. Still hermetic. + */ +class RealDataBehaviorSpec extends AnyFunSuite with Matchers with JsonSupport { + + private def fixture(name: String): String = { + val is = getClass.getResourceAsStream(s"/tibiadata/$name") + require(is != null, s"missing fixture /tibiadata/$name") + try scala.io.Source.fromInputStream(is, "UTF-8").mkString finally is.close() + } + + private def character(): Character = + fixture("character.json").parseJson.convertTo[CharacterResponse].character.character + + test("a real character drives the production level-up decision (LevelTracker)") { + val sheet = fixture("character.json").parseJson.convertTo[CharacterResponse].character + val name = sheet.character.name + val level = sheet.character.level.toInt + val lastLogin = ZonedDateTime.parse(sheet.character.last_login.getOrElse(fail("character has no last_login"))) + + val tracker = new LevelTracker + // never seen this (name, level) -> the bot would post and cache the level-up + tracker.shouldRecord(name, level, lastLogin) shouldBe true + + // already recorded this login session -> suppressed (no double post) + tracker.record(LevelRecord(name, level, sheet.character.vocation, lastLogin, lastLogin)) + tracker.shouldRecord(name, level, lastLogin) shouldBe false + + // a later login at the same level (relog, then re-level) -> posts again + tracker.shouldRecord(name, level, lastLogin.plusHours(1)) shouldBe true + } + + test("a real character death renders through the production death embed") { + val sheet = fixture("character.json").parseJson.convertTo[CharacterResponse].character + val ch = sheet.character + val death = sheet.deaths.getOrElse(Nil).headOption.getOrElse(fail("fixture character has no deaths")) + + val embed = DeathEmbeds.build(ch.name, ch.vocation, death.reason, "https://x/t.gif", 3092790).build() + embed.getTitle should include(ch.name) // production title = vocation emoji + real name + embed.getDescription shouldBe death.reason // real TibiaData death reason flows through + embed.getDescription should not be empty + } + + test("the real boosted boss renders through the production boosted embed") { + val boss = fixture("boostablebosses.json").parseJson.convertTo[BoostedResponse].boostable_bosses.boosted.name + val text = s"The boosted boss today is: **$boss**" + val embed = BoostedEmbeds.create(boss, ":crossed_swords:", "https://wiki", "https://x/t.gif", text) + embed.getDescription should include(boss) + } + + test("sanity: the character fixture exposes the fields the bot reads") { + val ch = character() + ch.name should not be empty + ch.vocation should not be empty + ch.level should be > 0.0 + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/app/StreamSupervisorSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/app/StreamSupervisorSpec.scala new file mode 100644 index 0000000..272ec09 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/app/StreamSupervisorSpec.scala @@ -0,0 +1,59 @@ +package com.tibiabot.app + +import com.tibiabot.domain.Discords +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class StreamSupervisorSpec extends AnyFunSuite with Matchers { + + private class FakeCancellable extends akka.actor.Cancellable { + var cancelled = false + def cancel(): Boolean = { cancelled = true; true } + def isCancelled: Boolean = cancelled + } + + private def discord(id: String) = Discords(id, "0", "0", "0") + + test("put then get/contains tracks a world's stream") { + val sup = new StreamSupervisor + val s = new FakeCancellable + sup.put("Antica", s, List(discord("g1"))) + sup.contains("Antica") shouldBe true + sup.get("Antica").map(_.usedBy) shouldBe Some(List(discord("g1"))) + sup.activeWorlds shouldBe Set("Antica") + } + + test("removeGuild keeps streams still in use and cancels+drops unused ones") { + val sup = new StreamSupervisor + val sA = new FakeCancellable + val sB = new FakeCancellable + sup.put("Antica", sA, List(discord("g1"), discord("g2"))) + sup.put("Bona", sB, List(discord("g1"))) + + sup.removeGuild("g1") + + sup.get("Antica").map(_.usedBy) shouldBe Some(List(discord("g2"))) // g1 dropped, kept + sA.cancelled shouldBe false + sup.contains("Bona") shouldBe false // last user gone -> removed + sB.cancelled shouldBe true + } + + test("removeGuildFromWorld cancels only when the world becomes unused") { + val sup = new StreamSupervisor + val sA = new FakeCancellable + sup.put("Antica", sA, List(discord("g1"), discord("g2"))) + + sup.removeGuildFromWorld("Antica", "g1") + sup.get("Antica").map(_.usedBy) shouldBe Some(List(discord("g2"))) + sA.cancelled shouldBe false + + sup.removeGuildFromWorld("Antica", "g2") + sup.contains("Antica") shouldBe false + sA.cancelled shouldBe true + } + + test("removeGuildFromWorld is a no-op for an unknown world") { + val sup = new StreamSupervisor + noException should be thrownBy sup.removeGuildFromWorld("Nowhere", "g1") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/commands/CommandRouterSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/commands/CommandRouterSpec.scala new file mode 100644 index 0000000..868ce9b --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/commands/CommandRouterSpec.scala @@ -0,0 +1,33 @@ +package com.tibiabot.commands + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.collection.mutable.ListBuffer + +class CommandRouterSpec extends AnyFunSuite with Matchers { + + test("routes a known command to its handler and reports true") { + val seen = ListBuffer.empty[String] + val router = new CommandRouter[String](Map( + "setup" -> (e => seen += s"setup:$e"), + "online" -> (e => seen += s"online:$e") + )) + + router.route("online", "guild1") shouldBe true + seen.toList shouldBe List("online:guild1") + } + + test("an unknown command runs no handler and reports false") { + val seen = ListBuffer.empty[String] + val router = new CommandRouter[String](Map("setup" -> (e => seen += e))) + + router.route("nope", "x") shouldBe false + seen shouldBe empty + } + + test("exposes the set of registered command names") { + val router = new CommandRouter[String](Map("a" -> (_ => ()), "b" -> (_ => ()))) + router.commandNames shouldBe Set("a", "b") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/commands/CommandSchemasSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/commands/CommandSchemasSpec.scala new file mode 100644 index 0000000..7d22d35 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/commands/CommandSchemasSpec.scala @@ -0,0 +1,37 @@ +package com.tibiabot.commands + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.jdk.CollectionConverters._ + +class CommandSchemasSpec extends AnyFunSuite with Matchers { + + test("registered commands have the expected names") { + CommandSchemas.commands.map(_.getName) should contain theSameElementsAs List( + "setup", "remove", "hunted", "allies", "neutral", "fullbless", + "filter", "exiva", "help", "repair", "online", "boosted", "galthen") + } + + test("admin command list adds /admin to the normal set") { + CommandSchemas.adminCommands.map(_.getName) shouldBe + CommandSchemas.commands.map(_.getName) :+ "admin" + } + + test("setup requires a single 'world' string option") { + val opts = CommandSchemas.setupCommand.getOptions.asScala + opts.map(_.getName) shouldBe List("world") + opts.head.isRequired shouldBe true + } + + test("hunted exposes the expected subcommands") { + CommandSchemas.huntedCommand.getSubcommands.asScala.map(_.getName) should contain allOf + ("guild", "player", "list", "clear", "info", "autodetect", "levels", "deaths") + } + + test("leaderboards is defined but intentionally not registered") { + CommandSchemas.leaderboardsCommand.getName shouldBe "leaderboards" + CommandSchemas.commands.map(_.getName) should not contain "leaderboards" + CommandSchemas.adminCommands.map(_.getName) should not contain "leaderboards" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/commands/PermissionsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/commands/PermissionsSpec.scala new file mode 100644 index 0000000..36d7452 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/commands/PermissionsSpec.scala @@ -0,0 +1,17 @@ +package com.tibiabot.commands + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class PermissionsSpec extends AnyFunSuite with Matchers { + + test("isBotCreator only matches the exact application-owner id") { + Permissions.isBotCreator("123", "123") shouldBe true + Permissions.isBotCreator("123", "456") shouldBe false + } + + test("isBotCreator denies everyone when the owner id is unknown (empty)") { + Permissions.isBotCreator("123", "") shouldBe false + Permissions.isBotCreator("", "") shouldBe false + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/commands/handlers/CommandOptionsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/commands/handlers/CommandOptionsSpec.scala new file mode 100644 index 0000000..7ff44fa --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/commands/handlers/CommandOptionsSpec.scala @@ -0,0 +1,17 @@ +package com.tibiabot.commands.handlers + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class CommandOptionsSpec extends AnyFunSuite with Matchers { + + test("fullbless level defaults to 250 and parses an explicit value") { + FullblessCommands.parseLevel(Map.empty) shouldBe 250 + FullblessCommands.parseLevel(Map("level" -> "300")) shouldBe 300 + } + + test("filter level defaults to 8 and parses an explicit value") { + FilterCommands.parseLevel(Map.empty) shouldBe 8 + FilterCommands.parseLevel(Map("level" -> "50")) shouldBe 50 + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/commands/handlers/NeutralCommandsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/commands/handlers/NeutralCommandsSpec.scala new file mode 100644 index 0000000..72f3c43 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/commands/handlers/NeutralCommandsSpec.scala @@ -0,0 +1,20 @@ +package com.tibiabot.commands.handlers + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class NeutralCommandsSpec extends AnyFunSuite with Matchers { + + test("isValidEmoji accepts standard emojis and rejects custom/text/empty") { + NeutralCommands.isValidEmoji("😀") shouldBe true // grinning face + NeutralCommands.isValidEmoji("⚔️") shouldBe true // crossed swords + NeutralCommands.isValidEmoji("<:custom:123>") shouldBe false // custom emoji + NeutralCommands.isValidEmoji("hello") shouldBe false + NeutralCommands.isValidEmoji("") shouldBe false + } + + test("sanitizeLabel keeps letters, digits and spaces, strips the rest and trims") { + NeutralCommands.sanitizeLabel(" Hello, World! 123 ") shouldBe "Hello World 123" + NeutralCommands.sanitizeLabel("a@#b") shouldBe "ab" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/discord/DiscordGatewaySpec.scala b/tibia-bot/src/test/scala/com/tibiabot/discord/DiscordGatewaySpec.scala new file mode 100644 index 0000000..c46faf3 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/discord/DiscordGatewaySpec.scala @@ -0,0 +1,31 @@ +package com.tibiabot.discord + +import net.dv8tion.jda.api.entities.{Guild, User} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class DiscordGatewaySpec extends AnyFunSuite with Matchers { + + /** Proves the port is implementable without JDA and honours the nullable + * guildById contract that call sites (e.g. `if (guild != null)`) rely on. */ + private class FakeGateway(known: Set[String]) extends DiscordGateway { + def guildById(id: String): Guild = null + def guilds: List[Guild] = Nil + def retrieveUser(id: String): User = null + def selfUserId: String = "self-1" + def selfUserName: String = "ViolentBot" + def applicationOwnerId: String = "owner-9" + def setWatchingActivity(text: String): Unit = () + } + + test("guildById returns null for an unknown guild (mirrors JDA)") { + new FakeGateway(Set.empty).guildById("nope") shouldBe null + } + + test("identity accessors are plain strings usable without JDA") { + val gw = new FakeGateway(Set.empty) + gw.selfUserId shouldBe "self-1" + gw.selfUserName shouldBe "ViolentBot" + gw.applicationOwnerId shouldBe "owner-9" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/discord/RateLimitedSenderSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/discord/RateLimitedSenderSpec.scala new file mode 100644 index 0000000..83ff576 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/discord/RateLimitedSenderSpec.scala @@ -0,0 +1,65 @@ +package com.tibiabot.discord + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.collection.mutable.ListBuffer + +class RateLimitedSenderSpec extends AnyFunSuite with Matchers { + + /** A ticker that captures the drain action so the test can fire it by hand. */ + private class ManualTicker { + var drain: Option[() => Unit] = None + var starts = 0 + val start: (() => Unit) => (() => Unit) = d => { + starts += 1 + drain = Some(d) + () => { drain = None } + } + def tick(): Unit = drain.foreach(_()) + } + + test("drains queued messages in FIFO order, one per tick") { + val ticker = new ManualTicker + val sender = new RateLimitedSender(ticker.start) + val sent = ListBuffer.empty[String] + + List("a", "b", "c").foreach(s => sender.enqueue(() => sent += s)) + + sent shouldBe empty // nothing sent until a tick fires + ticker.tick(); sent.toList shouldBe List("a") + ticker.tick(); sent.toList shouldBe List("a", "b") + ticker.tick(); sent.toList shouldBe List("a", "b", "c") + ticker.tick(); sent.toList shouldBe List("a", "b", "c") // empty tick is a no-op + } + + test("starts the ticker only once across many enqueues") { + val ticker = new ManualTicker + val sender = new RateLimitedSender(ticker.start) + (1 to 5).foreach(_ => sender.enqueue(() => ())) + ticker.starts shouldBe 1 + } + + test("a failing dispatch is swallowed and the next still sends") { + val ticker = new ManualTicker + val sender = new RateLimitedSender(ticker.start) + val sent = ListBuffer.empty[String] + + sender.enqueue(() => throw new RuntimeException("boom")) + sender.enqueue(() => sent += "after") + + noException should be thrownBy ticker.tick() + ticker.tick() + sent.toList shouldBe List("after") + } + + test("a finite capacity drops overflow instead of growing unbounded") { + val ticker = new ManualTicker + val sender = new RateLimitedSender(ticker.start, capacity = 2) + val sent = ListBuffer.empty[String] + + List("a", "b", "c").foreach(s => sender.enqueue(() => sent += s)) // "c" dropped (tail drop) + ticker.tick(); ticker.tick(); ticker.tick() + sent.toList shouldBe List("a", "b") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/time/ClockSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/time/ClockSpec.scala new file mode 100644 index 0000000..211afe8 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/time/ClockSpec.scala @@ -0,0 +1,28 @@ +package com.tibiabot.domain.time + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.{Instant, ZonedDateTime} + +/** A deterministic Clock for tests — the whole point of the port. */ +final class FixedClock(fixed: Instant) extends Clock { + def instant: Instant = fixed + def now: ZonedDateTime = ZonedDateTime.ofInstant(fixed, Clock.Berlin) +} + +class ClockSpec extends AnyFunSuite with Matchers { + + test("FixedClock returns the same instant every call") { + val t = Instant.ofEpochSecond(1779868800L) + val clock = new FixedClock(t) + clock.instant shouldBe t + clock.instant shouldBe t + } + + test("now renders the fixed instant in the Europe/Berlin zone") { + val clock = new FixedClock(Instant.ofEpochSecond(1779868800L)) + clock.now.getZone shouldBe Clock.Berlin + clock.now.toInstant shouldBe Instant.ofEpochSecond(1779868800L) + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala new file mode 100644 index 0000000..5465761 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala @@ -0,0 +1,33 @@ +package com.tibiabot.domain.time + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class DreamScarCycleSpec extends AnyFunSuite with Matchers { + + test("each world's boss advances to the next in the cycle") { + DreamScarCycle.shiftAllBossesUp(Map("Antica" -> "Plagueroot")) shouldBe + Map("Antica" -> "Malofur Mangrinder") + } + + test("the last boss wraps around to the first") { + DreamScarCycle.shiftAllBossesUp(Map("Antica" -> "Izcandar the Banished")) shouldBe + Map("Antica" -> "Plagueroot") + } + + test("an unknown boss is left unchanged") { + DreamScarCycle.shiftAllBossesUp(Map("Antica" -> "World not found")) shouldBe + Map("Antica" -> "World not found") + } + + test("shifts every world independently") { + val before = Map("Antica" -> "Maxxenius", "Bona" -> "Alptramun") + DreamScarCycle.shiftAllBossesUp(before) shouldBe + Map("Antica" -> "Alptramun", "Bona" -> "Izcandar the Banished") + } + + test("indexOfBoss maps each boss to its position") { + DreamScarCycle.indexOfBoss("Plagueroot") shouldBe 0 + DreamScarCycle.indexOfBoss("Izcandar the Banished") shouldBe 4 + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/time/DromeCycleSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/time/DromeCycleSpec.scala new file mode 100644 index 0000000..fc07e6c --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/time/DromeCycleSpec.scala @@ -0,0 +1,34 @@ +package com.tibiabot.domain.time + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.Instant + +class DromeCycleSpec extends AnyFunSuite with Matchers { + + private val initial = DromeCycle.initial // 27 May 2026 (CEST), well clear of DST changes + private val twoWeeks = 14L * 24 * 3600 // exactly 14 days while Berlin stays on CEST + + test("initial anchor is the 27 May 2026 server save") { + initial shouldBe Instant.ofEpochSecond(1779868800L) + } + + test("a target at or before the current anchor leaves it unchanged") { + DromeCycle.advanceFrom(initial, initial) shouldBe initial + DromeCycle.advanceFrom(initial, initial.minusSeconds(100)) shouldBe initial + } + + test("a target just past the anchor advances exactly one 2-week step") { + DromeCycle.advanceFrom(initial, initial.plusSeconds(1)) shouldBe initial.plusSeconds(twoWeeks) + } + + test("a target on a step boundary lands on that boundary, not the next") { + DromeCycle.advanceFrom(initial, initial.plusSeconds(twoWeeks)) shouldBe initial.plusSeconds(twoWeeks) + } + + test("advances over multiple cycles until no longer before the target") { + DromeCycle.advanceFrom(initial, initial.plusSeconds(twoWeeks * 2 + 5)) shouldBe + initial.plusSeconds(twoWeeks * 3) + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/ActivityRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/ActivityRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..baf24a3 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/ActivityRepositoryIntegrationSpec.scala @@ -0,0 +1,44 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcActivityRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime + +/** Round-trips ActivityRepository against a real Postgres (cancels without PGHOST). */ +class ActivityRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val guildId = "888000888000888000" // numeric-only fake guild id + private val t = ZonedDateTime.parse("2026-05-30T10:00:00Z") + + test("tracked_activity round-trip: add, rename via update, remove") { + val provider = pgOrCancel() + ensureGuildDatabase(provider, guildId) + val repo = new JdbcActivityRepository(provider) + + repo.getActivity(guildId) // creates the table on first use + repo.removeByName(guildId, "ActChar") // clean slate + repo.removeByName(guildId, "ActCharRenamed") + + repo.add(guildId, "ActChar", List("Old A"), "Guild X", t) + val rows = repo.getActivity(guildId) + rows.map(_.name) should contain("ActChar") + rows.find(_.name == "ActChar").map(_.guild) shouldBe Some("Guild X") + + repo.update(guildId, "ActChar", List("Old A"), "Guild X", t, "ActCharRenamed") + val renamed = repo.getActivity(guildId) + renamed.map(_.name) should (contain("ActCharRenamed") and not contain "ActChar") + + repo.removeByName(guildId, "ActCharRenamed") + repo.getActivity(guildId).map(_.name) should not contain "ActCharRenamed" + } + + private def ensureGuildDatabase(provider: JdbcConnectionProvider, guildId: String): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + if (!rs.next()) conn.createStatement().executeUpdate(s"CREATE DATABASE _$guildId") + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..a19fcc0 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala @@ -0,0 +1,36 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcBoostedRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Round-trips BoostedRepository against a real Postgres (cancels without PGHOST). */ +class BoostedRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val user = "itest_boosted_user" + + test("subscribe / forUser / all / unsubscribe / unsubscribeAll round-trip") { + val provider = pgOrCancel() + val repo = new JdbcBoostedRepository(provider) + + repo.unsubscribeAll(user) // clean slate (also creates the table) + + repo.subscribe(user, "Rotworm", "creature") + repo.subscribe(user, "Ferumbras", "boss") + + val mine = repo.forUser(user) + mine.map(_.boostedName) should contain allOf ("Rotworm", "Ferumbras") + mine.find(_.boostedName == "Rotworm").map(_.boostedType) shouldBe Some("creature") + repo.all().map(_.user) should contain(user) + + // ON CONFLICT (userid, name) DO NOTHING — duplicate subscribe is a no-op + repo.subscribe(user, "Rotworm", "creature") + repo.forUser(user).count(_.boostedName == "Rotworm") shouldBe 1 + + repo.unsubscribe(user, "rotworm") // case-insensitive + repo.forUser(user).map(_.boostedName) should (contain("Ferumbras") and not contain "Rotworm") + + repo.unsubscribeAll(user) + repo.forUser(user) shouldBe empty + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..e4d25de --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala @@ -0,0 +1,115 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcCacheRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime + +/** Round-trips the deaths/levels caches against a real Postgres (cancels without PGHOST). */ +class CacheRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val world = "ITestCacheWorld" + + test("deaths cache: add, get and expiry") { + val provider = pgOrCancel() + ensureCacheDatabase(provider) + ensureTables(provider) + val repo = new JdbcCacheRepository(provider) + + repo.addDeath(world, "Char A", "2026-05-30T10:00:00Z") + repo.getDeaths(world).map(_.name) should contain("Char A") + + // now is well past the 30-minute window -> the row is purged + repo.removeExpiredDeaths(ZonedDateTime.parse("2026-05-31T00:00:00Z")) + repo.getDeaths(world).map(_.name) should not contain "Char A" + } + + test("levels cache: add, get and expiry") { + val provider = pgOrCancel() + ensureCacheDatabase(provider) + ensureTables(provider) + val repo = new JdbcCacheRepository(provider) + + repo.addLevel(world, "Char B", "100", "Knight", "2026-05-30T09:00:00Z", "2026-05-30T10:00:00Z") + repo.getLevels(world).map(_.name) should contain("Char B") + + // now is well past the 25-hour window -> the row is purged + repo.removeExpiredLevels(ZonedDateTime.parse("2026-06-01T00:00:00Z")) + repo.getLevels(world).map(_.name) should not contain "Char B" + } + + test("list cache: add (upsert), get and expiry") { + val provider = pgOrCancel() + ensureCacheDatabase(provider) + val repo = new JdbcCacheRepository(provider) + val listWorld = "Itestlistx" + + repo.getList(listWorld) // creates the `list` table on first use + repo.addToList("ListChar", List("OldName"), listWorld, List("OldWorld"), + "SomeGuild", "200", "Knight", "2026-05-30T09:00:00Z", ZonedDateTime.parse("2026-05-30T10:00:00Z")) + + val rows = repo.getList(listWorld) + rows.map(_.name) should contain("ListChar") + rows.find(_.name == "ListChar").map(_.guild) shouldBe Some("SomeGuild") + + // now is well past the 7-day window -> the row is purged + repo.removeExpiredList(ZonedDateTime.parse("2026-06-30T00:00:00Z")) + repo.getList(listWorld).map(_.name) should not contain "ListChar" + } + + test("boosted_info: default row created, then updated and read back") { + val provider = pgOrCancel() + ensureCacheDatabase(provider) + val repo = new JdbcCacheRepository(provider) + + val initial = repo.getBoosted() // creates table + default row on first use + initial should not be empty + + repo.updateBoosted("Some Boss", "Some Creature", "111", "222") + val updated = repo.getBoosted() + updated.head.boss shouldBe "Some Boss" + updated.head.creature shouldBe "Some Creature" + updated.head.bossChanged shouldBe "111" + updated.head.creatureChanged shouldBe "222" + + // empty-string args leave fields unchanged + repo.updateBoosted("", "Another Creature", "", "") + val partial = repo.getBoosted() + partial.head.boss shouldBe "Some Boss" + partial.head.creature shouldBe "Another Creature" + } + + private def ensureCacheDatabase(provider: JdbcConnectionProvider): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery("SELECT datname FROM pg_database WHERE datname = 'bot_cache'") + if (!rs.next()) conn.createStatement().executeUpdate("CREATE DATABASE bot_cache") + } finally conn.close() + } + + private def ensureTables(provider: JdbcConnectionProvider): Unit = { + val conn = provider.cache() + try { + val st = conn.createStatement() + st.execute( + """CREATE TABLE IF NOT EXISTS deaths ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |)""".stripMargin) + st.execute( + """CREATE TABLE IF NOT EXISTS levels ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |level VARCHAR(255) NOT NULL, + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |)""".stripMargin) + st.close() + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/CustomSortRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/CustomSortRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..024bad5 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/CustomSortRepositoryIntegrationSpec.scala @@ -0,0 +1,41 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcCustomSortRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Round-trips CustomSortRepository against a real Postgres (cancels without PGHOST). */ +class CustomSortRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val guildId = "666000666000666000" // numeric-only fake guild id + + test("custom sort round-trip: add, read, remove by name/entity and by label") { + val provider = pgOrCancel() + ensureGuildDatabase(provider, guildId) + val repo = new JdbcCustomSortRepository(provider) + + repo.getAll(guildId) // creates the table on first use + repo.removeByNameEntity(guildId, "guild", "TestGuild") // clean slate + repo.removeByLabel(guildId, "LabelX") + + repo.add(guildId, "guild", "TestGuild", "MyLabel", ":emoji:") + val rows = repo.getAll(guildId) + rows.map(_.name) should contain("TestGuild") + rows.find(_.name == "TestGuild").map(_.label) shouldBe Some("MyLabel") + + repo.removeByNameEntity(guildId, "guild", "TestGuild") + repo.getAll(guildId).map(_.name) should not contain "TestGuild" + + repo.add(guildId, "player", "TestPlayer", "LabelX", ":e:") + repo.removeByLabel(guildId, "LabelX") + repo.getAll(guildId).map(_.name) should not contain "TestPlayer" + } + + private def ensureGuildDatabase(provider: JdbcConnectionProvider, guildId: String): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + if (!rs.next()) conn.createStatement().executeUpdate(s"CREATE DATABASE _$guildId") + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/DeathScreenshotRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/DeathScreenshotRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..85d5f40 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/DeathScreenshotRepositoryIntegrationSpec.scala @@ -0,0 +1,43 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcDeathScreenshotRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Round-trips DeathScreenshotRepository against a real Postgres (cancels without PGHOST). */ +class DeathScreenshotRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val guildId = "999000999000999000" // numeric-only fake guild id + private val world = "Antica" + private val char = "Test Char" + private val dt = 1234L + private val url = "https://example.com/shot1.png" + + test("store / get / deleteIfPermitted round-trip on death_screenshots") { + val provider = pgOrCancel() + ensureGuildDatabase(provider, guildId) + val repo = new JdbcDeathScreenshotRepository(provider) + + repo.store(guildId, world, char, dt, url, "user1", "User One", "msg1") + + val stored = repo.get(guildId, world, char, dt) + stored.map(_.screenshotUrl) should contain(url) + stored.find(_.screenshotUrl == url).map(_.addedBy) shouldBe Some("user1") + + // permission predicate denies -> nothing deleted + repo.deleteIfPermitted(guildId, char, dt, url)(_ => false) shouldBe false + repo.get(guildId, world, char, dt) should not be empty + + // permission predicate allows -> deleted + repo.deleteIfPermitted(guildId, char, dt, url)(_ => true) shouldBe true + repo.get(guildId, world, char, dt) shouldBe empty + } + + private def ensureGuildDatabase(provider: JdbcConnectionProvider, guildId: String): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + if (!rs.next()) conn.createStatement().executeUpdate(s"CREATE DATABASE _$guildId") + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/DiscordConfigRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/DiscordConfigRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..3bf01d9 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/DiscordConfigRepositoryIntegrationSpec.scala @@ -0,0 +1,69 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcDiscordConfigRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime + +/** Round-trips DiscordConfigRepository against a real Postgres (cancels without PGHOST). */ +class DiscordConfigRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val guildId = "444000444000444000" // numeric-only fake guild id + private val created = ZonedDateTime.parse("2026-05-30T10:00:00Z") + + test("discord_info round-trip: create, retrieve and conditional update") { + val provider = pgOrCancel() + ensureGuildDatabase(provider, guildId) + ensureDiscordInfoTable(provider) + val repo = new JdbcDiscordConfigRepository(provider) + + repo.getConfig(guildId) // migrates last_world column + clearDiscordInfo(provider) // idempotency: a prior run leaves last_world='Antica' + + repo.create(guildId, "MyGuild", "owner1", "cat1", "achan", "bchan", "msg1", created) + val cfg = repo.getConfig(guildId) + cfg("guild_name") shouldBe "MyGuild" + cfg("admin_channel") shouldBe "achan" + cfg("last_world") shouldBe "0" // default + + // only the non-empty fields are updated + repo.update(guildId, adminCategory = "", adminChannel = "newadmin", boostedChannel = "", + boostedMessage = "", lastWorld = "Antica") + val updated = repo.getConfig(guildId) + updated("admin_channel") shouldBe "newadmin" + updated("last_world") shouldBe "Antica" + updated("admin_category") shouldBe "cat1" // unchanged (empty arg) + } + + private def clearDiscordInfo(provider: JdbcConnectionProvider): Unit = { + val conn = provider.guild(guildId) + try conn.createStatement().executeUpdate("DELETE FROM discord_info") finally conn.close() + } + + private def ensureGuildDatabase(provider: JdbcConnectionProvider, guildId: String): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + if (!rs.next()) conn.createStatement().executeUpdate(s"CREATE DATABASE _$guildId") + } finally conn.close() + } + + private def ensureDiscordInfoTable(provider: JdbcConnectionProvider): Unit = { + val conn = provider.guild(guildId) + try { + conn.createStatement().execute( + """CREATE TABLE IF NOT EXISTS discord_info ( + |guild_name VARCHAR(255) NOT NULL, + |guild_owner VARCHAR(255) NOT NULL, + |admin_category VARCHAR(255) NOT NULL, + |admin_channel VARCHAR(255) NOT NULL, + |boosted_channel VARCHAR(255) NOT NULL, + |boosted_messageid VARCHAR(255) NOT NULL, + |flags VARCHAR(255) NOT NULL, + |created TIMESTAMP NOT NULL, + |PRIMARY KEY (guild_name) + |)""".stripMargin) + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/GalthenRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/GalthenRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..d21ae5b --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/GalthenRepositoryIntegrationSpec.scala @@ -0,0 +1,44 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcGalthenRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime + +/** Round-trips GalthenRepository against a real Postgres (cancels without PGHOST). */ +class GalthenRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val user = "itest_galthen_user" + private val when = ZonedDateTime.parse("2026-05-30T10:00:00Z") + + test("add / get / del / delAll round-trip on the satchel table") { + val provider = pgOrCancel() + ensureCacheDatabase(provider) + val repo = new JdbcGalthenRepository(provider) + + repo.getStamps(user) // creates the table on first use + repo.delAll(user) // start from a clean slate + repo.getStamps(user).getOrElse(Nil) shouldBe empty + + repo.add(user, when, "boots") + repo.add(user, when, "ring") + val tags = repo.getStamps(user).getOrElse(Nil).map(_.tag) + tags should contain allOf ("boots", "ring") + + repo.del(user, "boots") + repo.getStamps(user).getOrElse(Nil).map(_.tag) should (contain("ring") and not contain "boots") + + repo.delAll(user) + repo.getStamps(user).getOrElse(Nil) shouldBe empty + } + + /** Create the bot_cache database the repository connects to, if absent. */ + private def ensureCacheDatabase(provider: JdbcConnectionProvider): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery("SELECT datname FROM pg_database WHERE datname = 'bot_cache'") + if (!rs.next()) conn.createStatement().executeUpdate("CREATE DATABASE bot_cache") + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/HuntedAlliedRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/HuntedAlliedRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..a523134 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/HuntedAlliedRepositoryIntegrationSpec.scala @@ -0,0 +1,68 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcHuntedAlliedRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Round-trips HuntedAlliedRepository against a real Postgres (cancels without PGHOST). */ +class HuntedAlliedRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val guildId = "777000777000777000" // numeric-only fake guild id + + test("hunted players: add, read, rename and remove") { + val provider = pgOrCancel() + ensureGuildDatabase(provider, guildId) + ensureTable(provider, "hunted_players") + val repo = new JdbcHuntedAlliedRepository(provider) + + repo.removeHunted(guildId, "player", "Enemy") // clean slate + repo.removeHunted(guildId, "player", "EnemyTwo") + + repo.addHunted(guildId, "player", "Enemy", "manual", "added by test", "tester") + val players = repo.getPlayers(guildId, "hunted_players") + players.map(_.name) should contain("Enemy") + players.find(_.name == "Enemy").map(_.reason) shouldBe Some("manual") + + repo.rename(guildId, "hunted", "Enemy", "EnemyTwo") + repo.getPlayers(guildId, "hunted_players").map(_.name) should + (contain("EnemyTwo") and not contain "Enemy") + + repo.removeHunted(guildId, "player", "EnemyTwo") + repo.getPlayers(guildId, "hunted_players").map(_.name) should not contain "EnemyTwo" + } + + test("hunted guilds: add, read and remove") { + val provider = pgOrCancel() + ensureGuildDatabase(provider, guildId) + ensureTable(provider, "hunted_guilds") + val repo = new JdbcHuntedAlliedRepository(provider) + + repo.removeHunted(guildId, "guild", "Some Guild") + repo.addHunted(guildId, "guild", "Some Guild", "manual", "added by test", "tester") + repo.getGuilds(guildId, "hunted_guilds").map(_.name) should contain("Some Guild") + repo.removeHunted(guildId, "guild", "Some Guild") + repo.getGuilds(guildId, "hunted_guilds").map(_.name) should not contain "Some Guild" + } + + private def ensureGuildDatabase(provider: JdbcConnectionProvider, guildId: String): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + if (!rs.next()) conn.createStatement().executeUpdate(s"CREATE DATABASE _$guildId") + } finally conn.close() + } + + private def ensureTable(provider: JdbcConnectionProvider, table: String): Unit = { + val conn = provider.guild(guildId) + try { + conn.createStatement().execute( + s"""CREATE TABLE IF NOT EXISTS $table ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |)""".stripMargin) + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/JdbcUrlsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/JdbcUrlsSpec.scala new file mode 100644 index 0000000..d32a2de --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/JdbcUrlsSpec.scala @@ -0,0 +1,20 @@ +package com.tibiabot.persistence + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JdbcUrlsSpec extends AnyFunSuite with Matchers { + + private val host = "sqlhost" + + test("guild url targets the per-guild _ database") { + JdbcUrls.guild(host, "912739993015947324") shouldBe + "jdbc:postgresql://sqlhost:5432/_912739993015947324" + } + + test("cache, admin and premium urls match the originals") { + JdbcUrls.cache(host) shouldBe "jdbc:postgresql://sqlhost:5432/bot_cache" + JdbcUrls.admin(host) shouldBe "jdbc:postgresql://sqlhost:5432/postgres" + JdbcUrls.premium(host) shouldBe "jdbc:postgresql://sqlhost:5432/premium" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresConnectivityIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresConnectivityIntegrationSpec.scala new file mode 100644 index 0000000..66fb7f8 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresConnectivityIntegrationSpec.scala @@ -0,0 +1,20 @@ +package com.tibiabot.persistence + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Smoke test proving the integration-test pipeline works end to end: + * the JdbcConnectionProvider opens a real connection and runs a query. + * Cancels (does not fail) when no PGHOST is configured. */ +class PostgresConnectivityIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + test("admin connection runs a trivial query against a real Postgres") { + val provider = pgOrCancel() + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery("SELECT 1") + rs.next() shouldBe true + rs.getInt(1) shouldBe 1 + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresSupport.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresSupport.scala new file mode 100644 index 0000000..e3bbac1 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/PostgresSupport.scala @@ -0,0 +1,21 @@ +package com.tibiabot.persistence + +import org.scalatest.funsuite.AnyFunSuite + +/** Mix-in for Postgres integration tests. + * + * Integration specs need a real database. When `PGHOST` is not set (e.g. a + * plain local `sbt test` with no DB), `pgOrCancel()` cancels the test instead + * of failing it — so the default test run stays green everywhere. CI sets + * `PGHOST`/`PGPASSWORD` (via a postgres service) so the same tests run for real. + */ +trait PostgresSupport { self: AnyFunSuite => + + protected def pgConfigured: Boolean = sys.env.get("PGHOST").exists(_.nonEmpty) + + protected def pgOrCancel(): JdbcConnectionProvider = { + val host = sys.env.getOrElse("PGHOST", "") + if (host.isEmpty) cancel("PGHOST not set; skipping Postgres integration test") + new JdbcConnectionProvider(host, sys.env.getOrElse("PGPASSWORD", "postgres")) + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala new file mode 100644 index 0000000..a737b20 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala @@ -0,0 +1,41 @@ +package com.tibiabot.persistence + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.sql.Connection + +/** Verifies SchemaInitializer creates the expected databases/tables against a + * real Postgres (cancels without PGHOST). */ +class SchemaInitializerIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + test("initCache ensures bot_cache with deaths/levels/list/satchel tables") { + val provider = pgOrCancel() + new SchemaInitializer(provider).initCache() + val conn = provider.cache() + try { + Seq("deaths", "levels", "list", "satchel").foreach(t => hasTable(conn, t) shouldBe true) + } finally conn.close() + } + + test("initGuild creates a guild database with all config tables") { + val provider = pgOrCancel() + val init = new SchemaInitializer(provider) + val guildId = "333000333000333000" + + init.initGuild(guildId, "TestGuild") + init.guildDatabaseExists(guildId) shouldBe true + + val conn = provider.guild(guildId) + try { + Seq("discord_info", "hunted_players", "hunted_guilds", "allied_players", "allied_guilds", "worlds") + .foreach(t => hasTable(conn, t) shouldBe true) + } finally conn.close() + } + + private def hasTable(conn: Connection, name: String): Boolean = { + val rs = conn.createStatement().executeQuery( + s"SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = '$name'") + rs.next() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/WorldConfigRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/WorldConfigRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..492e952 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/WorldConfigRepositoryIntegrationSpec.scala @@ -0,0 +1,84 @@ +package com.tibiabot.persistence + +import com.tibiabot.persistence.jdbc.JdbcWorldConfigRepository +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Round-trips WorldConfigRepository against a real Postgres (cancels without PGHOST). */ +class WorldConfigRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { + + private val guildId = "555000555000555000" // numeric-only fake guild id + private val world = "Itestworld" + + test("worlds round-trip: create, retrieve, list and remove") { + val provider = pgOrCancel() + ensureGuildDatabase(provider, guildId) + ensureWorldsTable(provider) + val repo = new JdbcWorldConfigRepository(provider, mergedWorlds = List.empty) + + repo.removeWorld(guildId, world) // clean slate + repo.createWorld(guildId, world, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "99") + + val cfg = repo.retrieveWorld(guildId, world) + cfg("name") shouldBe "Itestworld" + cfg("allies_channel") shouldBe "1" + cfg("activity_channel") shouldBe "99" + cfg("fullbless_level") shouldBe "250" // default applied by createWorld + + repo.listWorlds(guildId).map(_.name) should contain("Itestworld") + + // string + int field updates + repo.updateWorldString(guildId, "Itestworld", "detect_hunteds", "off") + repo.updateWorldInt(guildId, "Itestworld", "fullbless_level", 400) + val updated = repo.retrieveWorld(guildId, world) + updated("detect_hunteds") shouldBe "off" + updated("fullbless_level") shouldBe "400" + + repo.removeWorld(guildId, world) + repo.listWorlds(guildId).map(_.name) should not contain "Itestworld" + } + + private def ensureGuildDatabase(provider: JdbcConnectionProvider, guildId: String): Unit = { + val conn = provider.admin() + try { + val rs = conn.createStatement().executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") + if (!rs.next()) conn.createStatement().executeUpdate(s"CREATE DATABASE _$guildId") + } finally conn.close() + } + + private def ensureWorldsTable(provider: JdbcConnectionProvider): Unit = { + val conn = provider.guild(guildId) + try { + conn.createStatement().execute( + """CREATE TABLE IF NOT EXISTS worlds ( + |name VARCHAR(255) NOT NULL, + |allies_channel VARCHAR(255) NOT NULL, + |enemies_channel VARCHAR(255) NOT NULL, + |neutrals_channel VARCHAR(255) NOT NULL, + |levels_channel VARCHAR(255) NOT NULL, + |deaths_channel VARCHAR(255) NOT NULL, + |category VARCHAR(255) NOT NULL, + |fullbless_role VARCHAR(255) NOT NULL, + |nemesis_role VARCHAR(255) NOT NULL, + |allypk_role VARCHAR(255) NOT NULL, + |masslog_role VARCHAR(255) NOT NULL, + |fullbless_channel VARCHAR(255) NOT NULL, + |nemesis_channel VARCHAR(255) NOT NULL, + |fullbless_level INT NOT NULL, + |show_neutral_levels VARCHAR(255) NOT NULL, + |show_neutral_deaths VARCHAR(255) NOT NULL, + |show_allies_levels VARCHAR(255) NOT NULL, + |show_allies_deaths VARCHAR(255) NOT NULL, + |show_enemies_levels VARCHAR(255) NOT NULL, + |show_enemies_deaths VARCHAR(255) NOT NULL, + |detect_hunteds VARCHAR(255) NOT NULL, + |levels_min INT NOT NULL, + |deaths_min INT NOT NULL, + |exiva_list VARCHAR(255) NOT NULL, + |activity_channel VARCHAR(255) NOT NULL, + |online_combined VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |)""".stripMargin) + } finally conn.close() + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala new file mode 100644 index 0000000..4ea7b0b --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala @@ -0,0 +1,17 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class BoostedEmbedsSpec extends AnyFunSuite with Matchers { + + test("builds the boosted embed with thumbnail, fixed colour and description; no title") { + val e = BoostedEmbeds.create( + "Boosted Boss", ":boss:", "https://www.tibia.com/library", + "https://x/thumb.gif", "The boosted boss today is X") + e.getDescription shouldBe "The boosted boss today is X" + e.getThumbnail.getUrl shouldBe "https://x/thumb.gif" + (e.getColor.getRGB & 0xFFFFFF) shouldBe 3092790 + e.getTitle shouldBe null // title is intentionally commented out upstream + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/CreatureUrlsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/CreatureUrlsSpec.scala new file mode 100644 index 0000000..3c024fb --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/CreatureUrlsSpec.scala @@ -0,0 +1,35 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Characterization of the creature name-parsing shared by the image/wiki URL + * builders. Uses an explicit mappings map so the parsing fallback is exercised. */ +class CreatureUrlsSpec extends AnyFunSuite with Matchers { + + private val noMappings = Map.empty[String, String] + + test("simple name is capitalised") { + Urls.creatureFileName("dragon", noMappings) shouldBe "Dragon" + } + + test("capitalises the letter after punctuation and spaces") { + Urls.creatureFileName("mooh'tah warrior", noMappings) shouldBe "Mooh'Tah_Warrior" + Urls.creatureFileName("two-headed turtle", noMappings) shouldBe "Two-Headed_Turtle" + } + + test("lowercases interior articles/prepositions but capitalises the first word") { + Urls.creatureFileName("the voice of ruin", noMappings) shouldBe "The_Voice_of_Ruin" + } + + test("an explicit mapping short-circuits the parsing") { + Urls.creatureFileName("foo", Map("foo" -> "Custom_Name")) shouldBe "Custom_Name" + } + + test("image and wiki URLs wrap the resolved file name") { + Urls.creatureImageUrl("dragon", noMappings) shouldBe + "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Dragon.gif" + Urls.creatureWikiUrl("dragon", noMappings) shouldBe + "https://www.tibiawiki.com.br/wiki/Dragon" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala new file mode 100644 index 0000000..059760b --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala @@ -0,0 +1,21 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class DeathEmbedsSpec extends AnyFunSuite with Matchers { + + test("assembles title, description, thumbnail and colour") { + val e = DeathEmbeds.build("Bobeek", "Elite Knight", "Died at level 100", "https://x/shot.gif", 3092790).build() + e.getTitle shouldBe ":shield: Bobeek :shield:" + e.getDescription shouldBe "Died at level 100" + e.getThumbnail.getUrl shouldBe "https://x/shot.gif" + (e.getColor.getRGB & 0xFFFFFF) shouldBe 3092790 + } + + test("title links to the character page and uses the monk emoji where applicable") { + val e = DeathEmbeds.build("Some Monk", "Exalted Monk", "desc", "https://x/t.gif", 1).build() + e.getUrl shouldBe "https://www.tibia.com/community/?name=Some+Monk" + e.getTitle should startWith(":fist::skin-tone-3:") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala new file mode 100644 index 0000000..d6cbb15 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala @@ -0,0 +1,29 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Characterization of the vocation->emoji mapping, including the deliberate + * divergence between the two call sites. */ +class EmojisSpec extends AnyFunSuite with Matchers { + + test("vocEmoji matches the promoted vocation by its last word") { + Emojis.vocEmoji("Elite Knight") shouldBe ":shield:" + Emojis.vocEmoji("Elder Druid") shouldBe ":snowflake:" + Emojis.vocEmoji("Master Sorcerer") shouldBe ":fire:" + Emojis.vocEmoji("Royal Paladin") shouldBe ":bow_and_arrow:" + Emojis.vocEmoji("Exalted Monk") shouldBe ":fist::skin-tone-3:" + Emojis.vocEmoji("None") shouldBe ":hatching_chick:" + Emojis.vocEmoji("") shouldBe "" + } + + test("vocEmojiWithoutMonk matches BotApp's original behaviour") { + Emojis.vocEmojiWithoutMonk("Elite Knight") shouldBe ":shield:" + Emojis.vocEmojiWithoutMonk("None") shouldBe ":hatching_chick:" + } + + test("the divergence is preserved: monk maps to an emoji in one path, '' in the other") { + Emojis.vocEmoji("Monk") shouldBe ":fist::skin-tone-3:" + Emojis.vocEmojiWithoutMonk("Monk") shouldBe "" // BotApp predates monks + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/GalthenEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/GalthenEmbedsSpec.scala new file mode 100644 index 0000000..1016379 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/GalthenEmbedsSpec.scala @@ -0,0 +1,33 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GalthenEmbedsSpec extends AnyFunSuite with Matchers { + + test("short content is joined with newlines and not truncated") { + GalthenEmbeds.truncate(Seq("ab", "cd"), limit = 100) shouldBe "ab\ncd" + } + + test("empty and single-line inputs") { + GalthenEmbeds.truncate(Seq.empty) shouldBe "" + GalthenEmbeds.truncate(Seq("only line"), limit = 100) shouldBe "only line" + } + + test("over the limit, truncation cuts back to the last whole line") { + // "aaa\nbbb\nccc" is 11 chars; first 5 = "aaa\nb"; last newline at index 3 -> "aaa" + GalthenEmbeds.truncate(Seq("aaa", "bbb", "ccc"), limit = 5) shouldBe "aaa" + } + + test("over the limit with no newline in range keeps the hard cut") { + // "aaaaaa" has no newline within the first 3 chars + GalthenEmbeds.truncate(Seq("aaaaaa"), limit = 3) shouldBe "aaa" + } + + test("default limit keeps output within 4050 characters") { + val many = (1 to 1000).map(i => s"line number $i") + val out = GalthenEmbeds.truncate(many) + out.length should be <= 4050 + out should not include "\n\n" // no empty fragments introduced + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala new file mode 100644 index 0000000..f435b0a --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala @@ -0,0 +1,20 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class OnlineListEmbedsSpec extends AnyFunSuite with Matchers { + + test("durationString formats seconds as backticked minutes under an hour") { + OnlineListEmbeds.durationString(0) shouldBe "`0min`" + OnlineListEmbeds.durationString(59) shouldBe "`0min`" + OnlineListEmbeds.durationString(60) shouldBe "`1min`" + OnlineListEmbeds.durationString(3540) shouldBe "`59min`" + } + + test("durationString switches to hours+minutes at 60 minutes") { + OnlineListEmbeds.durationString(3600) shouldBe "`1hr 0min`" + OnlineListEmbeds.durationString(3660) shouldBe "`1hr 1min`" + OnlineListEmbeds.durationString(7320) shouldBe "`2hr 2min`" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/UrlsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/UrlsSpec.scala new file mode 100644 index 0000000..23d47f8 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/UrlsSpec.scala @@ -0,0 +1,22 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Golden-string characterization of the extracted URL builders. */ +class UrlsSpec extends AnyFunSuite with Matchers { + + test("charUrl builds a community lookup URL") { + Urls.charUrl("Bobeek") shouldBe "https://www.tibia.com/community/?name=Bobeek" + } + + test("charUrl URL-encodes spaces and special characters") { + Urls.charUrl("Violent Beams") shouldBe "https://www.tibia.com/community/?name=Violent+Beams" + Urls.charUrl("Mooh'Tah") shouldBe "https://www.tibia.com/community/?name=Mooh%27Tah" + } + + test("guildUrl builds a guild view URL") { + Urls.guildUrl("Red Rose") shouldBe + "https://www.tibia.com/community/?subtopic=guilds&page=view&GuildName=Red+Rose" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala new file mode 100644 index 0000000..87689e9 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala @@ -0,0 +1,31 @@ +package com.tibiabot.scheduler + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.{DayOfWeek, Duration, Instant, LocalTime} + +class ServerSaveScheduleSpec extends AnyFunSuite with Matchers { + + test("isServerSaveWindow is the open interval 10:00 to 10:45") { + ServerSaveSchedule.isServerSaveWindow(LocalTime.of(9, 59)) shouldBe false + ServerSaveSchedule.isServerSaveWindow(LocalTime.of(10, 0)) shouldBe false + ServerSaveSchedule.isServerSaveWindow(LocalTime.of(10, 1)) shouldBe true + ServerSaveSchedule.isServerSaveWindow(LocalTime.of(10, 44)) shouldBe true + ServerSaveSchedule.isServerSaveWindow(LocalTime.of(10, 45)) shouldBe false + } + + test("rashidLocation maps each weekday to its city") { + ServerSaveSchedule.rashidLocation(DayOfWeek.MONDAY) shouldBe "Svargrond" + ServerSaveSchedule.rashidLocation(DayOfWeek.THURSDAY) shouldBe "Ankrahmun" + ServerSaveSchedule.rashidLocation(DayOfWeek.SUNDAY) shouldBe "Carlin" + } + + test("shouldShowDrome only when drome is in the future and within 3 days") { + val now = Instant.parse("2026-01-01T00:00:00Z") + ServerSaveSchedule.shouldShowDrome(now, now.minusSeconds(10)) shouldBe false + ServerSaveSchedule.shouldShowDrome(now, now.plusSeconds(3600)) shouldBe true + ServerSaveSchedule.shouldShowDrome(now, now.plus(Duration.ofDays(3))) shouldBe true + ServerSaveSchedule.shouldShowDrome(now, now.plus(Duration.ofDays(4))) shouldBe false + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala b/tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala new file mode 100644 index 0000000..19f91e5 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala @@ -0,0 +1,80 @@ +package com.tibiabot.state + +import com.tibiabot.domain.{PlayerCache, Players} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime +import java.util.concurrent.{CountDownLatch, Executors, TimeUnit} + +/** + * Simulates many world-streams + command threads mutating the shared state at + * once. These assertions only hold because every read-modify-write goes through + * the synchronized modify* methods; against a plain `var Map` they fail with lost + * updates (which is the bug the hardening fixed). + */ +class StreamStateConcurrencySpec extends AnyFunSuite with Matchers { + + private val when = ZonedDateTime.parse("2026-01-01T00:00:00Z") + private def cache(name: String) = PlayerCache(name, Nil, "guild", when) + private def player(name: String) = Players(name, "false", "test", "0") + + /** Run `body(threadIndex)` on `threads` threads at once, started together. */ + private def race(threads: Int)(body: Int => Unit): Unit = { + val pool = Executors.newFixedThreadPool(threads) + val start = new CountDownLatch(1) + val done = new CountDownLatch(threads) + (0 until threads).foreach { t => + pool.submit(new Runnable { + def run(): Unit = { start.await(); try body(t) finally done.countDown() } + }) + } + start.countDown() // release all threads simultaneously for maximum contention + done.await(30, TimeUnit.SECONDS) shouldBe true + pool.shutdown() + } + + test("concurrent inserts for distinct guilds never lose an entry") { + val state = new StreamState + val threads = 16 + val perThread = 250 // 16 * 250 = 4000 distinct guild keys + race(threads) { t => + (0 until perThread).foreach { k => + state.modifyActivityData(_ + (s"guild-$t-$k" -> List(cache(s"c$t$k")))) + } + } + state.activityData.size shouldBe threads * perThread + } + + test("concurrent appends to the SAME guild keep every entry (atomic read-modify-write)") { + val state = new StreamState + val threads = 16 + val perThread = 250 + val guildId = "shared-guild" + race(threads) { t => + (0 until perThread).foreach { i => + state.modifyHuntedPlayersData { m => + m.updated(guildId, player(s"$t-$i") :: m.getOrElse(guildId, Nil)) + } + } + } + state.huntedPlayersData(guildId).size shouldBe threads * perThread + state.huntedPlayersData(guildId).map(_.name).toSet.size shouldBe threads * perThread // no duplicates/drops + } + + test("interleaved writes across all three maps stay consistent") { + val state = new StreamState + val threads = 12 + val perThread = 200 + race(threads) { t => + (0 until perThread).foreach { i => + state.modifyActivityData(_ + (s"a-$t-$i" -> List(cache("x")))) + state.modifyHuntedPlayersData(_ + (s"h-$t-$i" -> List(player("x")))) + state.modifyAlliedPlayersData(_ + (s"y-$t-$i" -> List(player("x")))) + } + } + state.activityData.size shouldBe threads * perThread + state.huntedPlayersData.size shouldBe threads * perThread + state.alliedPlayersData.size shouldBe threads * perThread + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala new file mode 100644 index 0000000..99c2ed6 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala @@ -0,0 +1,84 @@ +package com.tibiabot.tibiadata + +import com.tibiabot.tibiadata.response._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import spray.json._ + +/** + * Decodes real TibiaData v4 payloads (captured under test/resources/tibiadata) + * with the production [[JsonSupport]] formats. Hermetic: no network at test time; + * the fixtures are frozen snapshots that lock the live API contract the bot relies on. + */ +class TibiaDataDecodersSpec extends AnyFunSuite with Matchers with JsonSupport { + + private def fixture(name: String): String = { + val is = getClass.getResourceAsStream(s"/tibiadata/$name") + require(is != null, s"missing fixture /tibiadata/$name") + try scala.io.Source.fromInputStream(is, "UTF-8").mkString + finally is.close() + } + + test("boostablebosses: boosted boss is named and appears in the full list") { + val r = fixture("boostablebosses.json").parseJson.convertTo[BoostedResponse] + r.boostable_bosses.boosted.name should not be empty + r.boostable_bosses.boostable_boss_list.size should be > 10 + r.boostable_bosses.boostable_boss_list.map(_.name) should contain(r.boostable_bosses.boosted.name) + } + + test("creatures: boosted creature is named and the full list decodes") { + val r = fixture("creatures.json").parseJson.convertTo[CreaturesResponse] + r.creatures.boosted.name should not be empty + r.creatures.boosted.race should not be empty + r.creatures.creature_list.size should be > 50 + // every list entry fully decodes (name + image url present) + all(r.creatures.creature_list.map(_.name)) should not be empty + all(r.creatures.creature_list.map(_.image_url)) should startWith("https://") + } + + test("world: online players list decodes with positive levels") { + val r = fixture("world_antica.json").parseJson.convertTo[WorldResponse] + r.world.name shouldBe "Antica" + r.world.status shouldBe "online" + val online = r.world.online_players.getOrElse(Nil) + online should not be empty + all(online.map(_.level)) should be > 0.0 + } + + test("character: empty guild object decodes to None and deaths/killers parse") { + val r = fixture("character.json").parseJson.convertTo[CharacterResponse] + val sheet = r.character + sheet.character.name should not be empty + sheet.character.level should be > 0.0 + sheet.character.guild shouldBe None // TibiaData returns {} for guildless chars -> optGuildFormat -> None + val deaths = sheet.deaths.getOrElse(Nil) + deaths should not be empty + val first = deaths.head + first.time should not be empty + first.level should be > 0.0 + (first.killers.nonEmpty || first.reason.nonEmpty) shouldBe true + } + + test("guild: members list decodes with ranks, vocations and levels") { + val r = fixture("guild.json").parseJson.convertTo[GuildResponse] + r.guild.name should not be empty + r.guild.world should not be empty + val members = r.guild.members.getOrElse(Nil) + members should not be empty + all(members.map(_.name)) should not be empty + all(members.map(_.vocation)) should not be empty + all(members.map(_.level)) should be > 0.0 + } + + test("highscores: experience page decodes with a ranked list and paging") { + val r = fixture("highscores_antica.json").parseJson.convertTo[HighscoresResponse] + r.highscores.world shouldBe "Antica" + r.highscores.category shouldBe "experience" + val list = r.highscores.highscore_list.getOrElse(Nil) + list should not be empty + list.head.rank shouldBe 1.0 + all(list.map(_.name)) should not be empty + all(list.map(_.value)) should be > 0.0 + r.highscores.highscore_page.total_pages should be > 0.0 + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tracking/BoundedMessageQueueSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tracking/BoundedMessageQueueSpec.scala new file mode 100644 index 0000000..bd99e38 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tracking/BoundedMessageQueueSpec.scala @@ -0,0 +1,43 @@ +package com.tibiabot.tracking + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class BoundedMessageQueueSpec extends AnyFunSuite with Matchers { + + test("unbounded queue preserves FIFO order and retains everything (current behaviour)") { + val q = new BoundedMessageQueue[Int]() // Int.MaxValue == today's unbounded queue + (1 to 5).foreach(q.enqueue) + q.size shouldBe 5 + q.dropped shouldBe 0 + List.fill(5)(q.dequeueOption()).flatten shouldBe List(1, 2, 3, 4, 5) + q.dequeueOption() shouldBe None + } + + test("under capacity nothing is dropped") { + val q = new BoundedMessageQueue[Int](capacity = 3) + q.enqueue(1) shouldBe true + q.enqueue(2) shouldBe true + q.size shouldBe 2 + q.dropped shouldBe 0 + } + + test("tail-drop: when full, incoming items are rejected and the backlog is kept") { + val q = new BoundedMessageQueue[Int](capacity = 3, dropNewest = true) + (1 to 3).foreach(q.enqueue) + q.enqueue(4) shouldBe false // rejected + q.enqueue(5) shouldBe false + q.size shouldBe 3 + q.dropped shouldBe 2 + List.fill(3)(q.dequeueOption()).flatten shouldBe List(1, 2, 3) + } + + test("drop-oldest: when full, oldest is evicted to make room for the newest") { + val q = new BoundedMessageQueue[Int](capacity = 3, dropNewest = false) + (1 to 3).foreach(q.enqueue) + q.enqueue(4) shouldBe true + q.size shouldBe 3 + q.dropped shouldBe 1 + List.fill(3)(q.dequeueOption()).flatten shouldBe List(2, 3, 4) + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tracking/LevelTrackerSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tracking/LevelTrackerSpec.scala new file mode 100644 index 0000000..3e5b134 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tracking/LevelTrackerSpec.scala @@ -0,0 +1,74 @@ +package com.tibiabot.tracking + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime + +/** Characterization tests for the level-up dedup gate (TibiaBot 625-659). + * These pin the exact "post this level-up or not?" decision and must stay + * green after recentLevels is optimized to a keyed Map. */ +class LevelTrackerSpec extends AnyFunSuite with Matchers { + + private val login1 = ZonedDateTime.parse("2026-05-30T08:00:00Z") + private val login2 = ZonedDateTime.parse("2026-05-30T20:00:00Z") // a later session + private val now = ZonedDateTime.parse("2026-05-30T21:00:00Z") + + private def rec(name: String, level: Int, lastLogin: ZonedDateTime, time: ZonedDateTime = now) = + LevelRecord(name, level, "Knight", lastLogin, time) + + test("first time reaching a level: should record") { + val lt = new LevelTracker + lt.shouldRecord("Hero", 100, login1) shouldBe true + } + + test("same level, same login session, already recorded: suppressed (dedup)") { + val lt = new LevelTracker + lt.record(rec("Hero", 100, login1)) + lt.shouldRecord("Hero", 100, login1) shouldBe false + } + + test("same level reached in a LATER login session: should record again") { + val lt = new LevelTracker + lt.record(rec("Hero", 100, login1)) + lt.shouldRecord("Hero", 100, login2) shouldBe true + } + + test("a recorded later session suppresses an earlier sheet login") { + val lt = new LevelTracker + lt.record(rec("Hero", 100, login2)) + lt.shouldRecord("Hero", 100, login1) shouldBe false + } + + test("different level is independent") { + val lt = new LevelTracker + lt.record(rec("Hero", 100, login1)) + lt.shouldRecord("Hero", 101, login1) shouldBe true + } + + test("different name is independent") { + val lt = new LevelTracker + lt.record(rec("Hero", 100, login1)) + lt.shouldRecord("Other", 100, login1) shouldBe true + } + + test("multiple records for the same (name, level): newest lastLogin wins the decision") { + val lt = new LevelTracker + lt.record(rec("Hero", 100, login1)) + lt.record(rec("Hero", 100, login2)) // baseline Set keeps both + // there exists a record with lastLogin >= login1, so suppressed + lt.shouldRecord("Hero", 100, login1) shouldBe false + // nothing newer than login2, so a strictly-later session would record + lt.shouldRecord("Hero", 100, login2.plusHours(1)) shouldBe true + } + + test("prune drops records older than the expiry window by recorded time") { + val lt = new LevelTracker + lt.record(rec("Old", 10, login1, time = now.minusHours(30))) + lt.record(rec("Fresh", 20, login1, time = now.minusMinutes(5))) + lt.size shouldBe 2 + + lt.prune(now, 25 * 60 * 60) // 25h, matching recentLevelExpiry + lt.snapshot.map(_.name) shouldBe Set("Fresh") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tracking/MasslogDetectorSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tracking/MasslogDetectorSpec.scala new file mode 100644 index 0000000..f5c3a78 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tracking/MasslogDetectorSpec.scala @@ -0,0 +1,37 @@ +package com.tibiabot.tracking + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class MasslogDetectorSpec extends AnyFunSuite with Matchers { + + test("base percentage thresholds by enemy count") { + MasslogDetector.basePercentage(5) shouldBe 0.60 + MasslogDetector.basePercentage(6) shouldBe 0.55 + MasslogDetector.basePercentage(10) shouldBe 0.55 + MasslogDetector.basePercentage(11) shouldBe 0.40 + MasslogDetector.basePercentage(20) shouldBe 0.40 + MasslogDetector.basePercentage(21) shouldBe 0.32 + } + + test("sensitivity 0 (today's fixed value) is the strict 1.20 multiplier") { + MasslogDetector.sensitivityModifier(0) shouldBe 1.20 + } + + test("required zap count never drops below the floor of 3") { + MasslogDetector.requiredZapCount(0) shouldBe 3 + MasslogDetector.requiredZapCount(1) shouldBe 3 // ceil(1*0.60*1.20)=1 -> floored to 3 + MasslogDetector.requiredZapCount(5) shouldBe 4 // ceil(5*0.60*1.20)=ceil(3.6)=4 + } + + test("required zap count scales with enemy count") { + MasslogDetector.requiredZapCount(10) shouldBe 7 // ceil(10*0.55*1.20)=ceil(6.6)=7 + MasslogDetector.requiredZapCount(20) shouldBe 10 // ceil(20*0.40*1.20)=ceil(9.6)=10 + MasslogDetector.requiredZapCount(21) shouldBe 9 // ceil(21*0.32*1.20)=ceil(8.064)=9 + } + + test("isMasslog compares zap count against the requirement") { + MasslogDetector.isMasslog(zapCount = 4, enemyCount = 5) shouldBe true + MasslogDetector.isMasslog(zapCount = 3, enemyCount = 5) shouldBe false + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tracking/OnlineTrackerSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tracking/OnlineTrackerSpec.scala new file mode 100644 index 0000000..570577f --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tracking/OnlineTrackerSpec.scala @@ -0,0 +1,95 @@ +package com.tibiabot.tracking + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime + +/** Characterization tests: pin the CURRENT behaviour of the online-presence + * logic (TibiaBot.currentOnline). These must stay green after the Set->Map + * optimization. */ +class OnlineTrackerSpec extends AnyFunSuite with Matchers { + + private val t0 = ZonedDateTime.parse("2026-05-30T10:00:00Z") + private def at(sec: Int) = t0.plusSeconds(sec.toLong) + + test("new players start with empty guild, zero duration, empty flag") { + val tr = new OnlineTracker + tr.updateFromOnline(Seq(("Knight", 100, "Elite Knight")), t0) + + val p = tr.find("Knight").value + p.level shouldBe 100 + p.vocation shouldBe "Elite Knight" + p.guildName shouldBe "" + p.duration shouldBe 0L + p.flag shouldBe "" + p.time shouldBe t0 + } + + test("duration accumulates across cycles for a player who stays online") { + val tr = new OnlineTracker + tr.updateFromOnline(Seq(("Mage", 50, "Master Sorcerer")), at(0)) + tr.updateFromOnline(Seq(("Mage", 51, "Master Sorcerer")), at(60)) + tr.updateFromOnline(Seq(("Mage", 52, "Master Sorcerer")), at(150)) + + val p = tr.find("Mage").value + p.duration shouldBe 150L // (60-0) + (150-60) + p.level shouldBe 52 // level/vocation always taken from the fresh list + p.time shouldBe at(150) + } + + test("guild and flag are carried over across cycles") { + val tr = new OnlineTracker + tr.updateFromOnline(Seq(("Paladin", 200, "Royal Paladin")), at(0)) + tr.setGuild("Paladin", "Red Rose") + tr.setFlag("Paladin", ":arrow_up:") + + tr.updateFromOnline(Seq(("Paladin", 201, "Royal Paladin")), at(60)) + val p = tr.find("Paladin").value + p.guildName shouldBe "Red Rose" + p.flag shouldBe ":arrow_up:" + p.level shouldBe 201 + } + + test("players who log off are dropped on the next update") { + val tr = new OnlineTracker + tr.updateFromOnline(Seq(("A", 1, "None"), ("B", 2, "None")), at(0)) + tr.size shouldBe 2 + + tr.updateFromOnline(Seq(("A", 1, "None")), at(60)) // B logged off + tr.find("B") shouldBe None + tr.find("A") shouldBe defined + tr.size shouldBe 1 + } + + test("setGuild only rewrites when the guild actually changed") { + val tr = new OnlineTracker + tr.updateFromOnline(Seq(("X", 10, "None")), at(0)) + tr.setGuild("X", "Alpha") + tr.setGuild("X", "Alpha") // no-op + tr.find("X").value.guildName shouldBe "Alpha" + + tr.setGuild("X", "Beta") + tr.find("X").value.guildName shouldBe "Beta" + } + + test("find is exact and case-sensitive (matches the original .find(_.name == x))") { + val tr = new OnlineTracker + tr.updateFromOnline(Seq(("Charname", 5, "None")), at(0)) + tr.find("Charname") shouldBe defined + tr.find("charname") shouldBe None + } + + test("operations on unknown names are silent no-ops") { + val tr = new OnlineTracker + tr.updateFromOnline(Seq(("Known", 5, "None")), at(0)) + noException should be thrownBy tr.setGuild("Ghost", "G") + noException should be thrownBy tr.setFlag("Ghost", "f") + tr.size shouldBe 1 + } + + // tiny .value helper for Option without importing OptionValues + private implicit class OptOps[A](o: Option[A]) { + def value: A = o.getOrElse(fail("expected Some but was None")) + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tracking/TrackingBenchmarkSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tracking/TrackingBenchmarkSpec.scala new file mode 100644 index 0000000..3e48b76 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tracking/TrackingBenchmarkSpec.scala @@ -0,0 +1,70 @@ +package com.tibiabot.tracking + +import org.scalatest.funsuite.AnyFunSuite + +import java.time.ZonedDateTime + +/** Lightweight benchmarks (not JMH) to record BASELINE cost of the hot paths + * before optimization, and to demonstrate the speedup after. Timings are + * printed, not asserted (wall-clock assertions are flaky); the asserts only + * guard that the work actually happened. Run with: sbt "testOnly *TrackingBenchmarkSpec" + */ +class TrackingBenchmarkSpec extends AnyFunSuite { + + private val t0 = ZonedDateTime.parse("2026-05-30T10:00:00Z") + + private def time[A](label: String)(body: => A): A = { + val start = System.nanoTime() + val r = body + val ms = (System.nanoTime() - start) / 1e6 + println(f"[bench] $label%-52s ${ms}%9.2f ms") + r + } + + test("OnlineTracker: full-cycle merge over a large online list") { + val N = 2000 // ~ a busy world's online count + val cycles = 20 // ~20 minutes of 60s cycles + val players = (1 to N).map(i => (s"Player$i", 100 + (i % 500), "Knight")).toVector + + val tr = new OnlineTracker + time(s"merge N=$N x $cycles cycles (current: O(n^2)/cycle)") { + var c = 0 + while (c < cycles) { + tr.updateFromOnline(players, t0.plusSeconds(c.toLong * 60)) + c += 1 + } + } + assert(tr.size == N) + } + + test("OnlineTracker: per-character find storm (one find per online char)") { + val N = 2000 + val players = (1 to N).map(i => (s"Player$i", 100, "Knight")).toVector + val tr = new OnlineTracker + tr.updateFromOnline(players, t0) + + time(s"$N finds over N=$N state (current: O(n) each)") { + var hits = 0 + players.foreach { case (name, _, _) => if (tr.find(name).isDefined) hits += 1 } + assert(hits == N) + } + } + + test("LevelTracker: shouldRecord scan over a 25h backlog") { + val K = 20000 // level-ups retained over 25h on a busy world + val M = 5000 // shouldRecord calls in a cycle + val login = ZonedDateTime.parse("2026-05-30T08:00:00Z") + val lt = new LevelTracker + lt.load((1 to K).map(i => LevelRecord(s"P${i % 4000}", 100 + (i % 800), "Knight", login, t0))) + + time(s"$M shouldRecord over K=$K backlog (current: O(k) each)") { + var trues = 0 + var i = 0 + while (i < M) { + if (lt.shouldRecord(s"P${i % 4000}", 2000 + i, login)) trues += 1 + i += 1 + } + assert(trues == M) // all are brand-new (name,level) pairs + } + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/wiki/FandomWikiParserSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/wiki/FandomWikiParserSpec.scala new file mode 100644 index 0000000..2e5bd74 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/wiki/FandomWikiParserSpec.scala @@ -0,0 +1,40 @@ +package com.tibiabot.wiki + +import com.tibiabot.domain.BossEntry +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Pure parse tests for the Fandom wiki HTML, using fixture strings (no network). */ +class FandomWikiParserSpec extends AnyFunSuite with Matchers { + + test("parseDreamScarBosses reads (world, boss) from the wikitable, skipping the header") { + val html = + """ + | + | + | + |
WorldBoss
AnticaPlagueroot
BonaMaxxenius
""".stripMargin + + FandomWikiParser.parseDreamScarBosses(html) shouldBe List( + BossEntry("Antica", "Plagueroot"), + BossEntry("Bona", "Maxxenius")) + } + + test("parseDreamScarBosses returns Nil when there is no wikitable") { + FandomWikiParser.parseDreamScarBosses("

no table here

") shouldBe Nil + } + + test("parseCreatureNames keeps /wiki/ creature links, dedups, drops namespaced/list links") { + val html = + """""".stripMargin + + FandomWikiParser.parseCreatureNames(html) shouldBe List("Dragon", "Demon") + } +} From 5a65d1131c2b3e803a9cdf935dfcbd240a03b2d9 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Sun, 31 May 2026 19:20:03 +1000 Subject: [PATCH 02/21] finalised refactor with help from Claude --- .claude/scheduled_tasks.lock | 1 + README.md | 2 +- .../src/main/scala/com/tibiabot/BotApp.scala | 1751 +++++------------ .../main/scala/com/tibiabot/BotListener.scala | 42 +- .../main/scala/com/tibiabot/CachedList.scala | 38 + .../src/main/scala/com/tibiabot/Config.scala | 8 +- .../scala/com/tibiabot/CreatureManager.scala | 55 - .../main/scala/com/tibiabot/TibiaBot.scala | 615 ++---- .../scala/com/tibiabot/WorldManager.scala | 56 +- .../com/tibiabot/admin/AdminService.scala | 89 +- .../com/tibiabot/boosted/BoostedService.scala | 261 +-- .../com/tibiabot/commands/Permissions.scala | 7 + .../com/tibiabot/commands/SlashRouting.scala | 32 + .../commands/handlers/AlliesCommands.scala | 24 +- .../commands/handlers/BoostedCommands.scala | 2 +- .../commands/handlers/GalthenCommands.scala | 5 +- .../commands/handlers/HuntedCommands.scala | 24 +- .../commands/handlers/NeutralCommands.scala | 9 +- .../com/tibiabot/domain/BoostedName.scala | 10 + .../com/tibiabot/domain/BossAliases.scala | 103 + .../scala/com/tibiabot/domain/Killers.scala | 46 + .../scala/com/tibiabot/domain/Vocations.scala | 11 + .../scala/com/tibiabot/domain/WorldName.scala | 9 + .../tibiabot/domain/time/DreamScarCycle.scala | 5 + .../domain/time/SatchelCooldown.scala | 16 + .../com/tibiabot/galthen/GalthenService.scala | 13 +- .../tibiabot/interactions/ButtonHandler.scala | 257 +-- .../tibiabot/interactions/ModalHandler.scala | 111 +- .../persistence/ConnectionProvider.scala | 4 +- .../com/tibiabot/persistence/JdbcUrls.scala | 3 +- .../persistence/SchemaInitializer.scala | 442 +++-- .../jdbc/JdbcActivityRepository.scala | 21 +- .../jdbc/JdbcBoostedRepository.scala | 124 +- .../jdbc/JdbcCacheRepository.scala | 624 +++--- .../jdbc/JdbcCustomSortRepository.scala | 139 +- .../jdbc/JdbcDiscordConfigRepository.scala | 203 +- .../jdbc/JdbcGalthenRepository.scala | 191 +- .../jdbc/JdbcHuntedAlliedRepository.scala | 30 +- .../persistence/jdbc/JdbcSupport.scala | 19 + .../jdbc/JdbcWorldConfigRepository.scala | 38 +- .../tibiabot/presentation/BoostedEmbeds.scala | 10 +- .../com/tibiabot/presentation/BossEmoji.scala | 47 + .../tibiabot/presentation/DeathEffect.scala | 46 + .../tibiabot/presentation/DeathEmbeds.scala | 17 + .../com/tibiabot/presentation/EmbedText.scala | 23 + .../com/tibiabot/presentation/Embeds.scala | 17 + .../com/tibiabot/presentation/Emojis.scala | 30 +- .../tibiabot/presentation/GuildActivity.scala | 16 + .../tibiabot/presentation/GuildIcons.scala | 134 ++ .../presentation/LevelVisibility.scala | 24 + .../tibiabot/presentation/ListEmbeds.scala | 46 + .../com/tibiabot/presentation/Names.scala | 12 + .../presentation/OnlineListEmbeds.scala | 56 + .../presentation/OnlineListGrouping.scala | 70 + .../tibiabot/presentation/RecentLogin.scala | 22 + .../com/tibiabot/presentation/WorldList.scala | 44 + .../com/tibiabot/setup/ChannelService.scala | 59 + .../com/tibiabot/state/StreamState.scala | 35 +- .../com/tibiabot/tibiadata/JsonSupport.scala | 16 - .../com/tibiabot/tibiadata/TibiaApi.scala | 5 +- .../tibiabot/tibiadata/TibiaDataClient.scala | 370 +--- .../response/CreaturesResponse.scala | 22 - .../tibiadata/response/NewsResponse.scala | 36 - .../tibiadata/response/RaceResponse.scala | 32 - .../src/test/resources/tibiadata/worlds.json | 1 + .../scala/com/tibiabot/CachedListSpec.scala | 69 + .../com/tibiabot/RealDataBehaviorSpec.scala | 123 +- .../tibiabot/commands/SlashRoutingSpec.scala | 29 + .../com/tibiabot/domain/BoostedNameSpec.scala | 26 + .../com/tibiabot/domain/BossAliasesSpec.scala | 34 + .../com/tibiabot/domain/KillersSpec.scala | 54 + .../com/tibiabot/domain/VocationsSpec.scala | 19 + .../com/tibiabot/domain/WorldNameSpec.scala | 22 + .../domain/time/DreamScarCycleSpec.scala | 12 + .../domain/time/SatchelCooldownSpec.scala | 23 + .../presentation/BoostedEmbedsSpec.scala | 4 +- .../tibiabot/presentation/BossEmojiSpec.scala | 38 + .../presentation/DeathEffectSpec.scala | 58 + .../presentation/DeathEmbedsSpec.scala | 23 + .../tibiabot/presentation/EmbedTextSpec.scala | 35 + .../tibiabot/presentation/EmbedsSpec.scala | 19 + .../tibiabot/presentation/EmojisSpec.scala | 19 +- .../presentation/GuildActivitySpec.scala | 27 + .../presentation/GuildIconsSpec.scala | 76 + .../presentation/LevelVisibilitySpec.scala | 46 + .../presentation/ListEmbedsSpec.scala | 47 + .../com/tibiabot/presentation/NamesSpec.scala | 22 + .../presentation/OnlineListEmbedsSpec.scala | 59 + .../presentation/OnlineListGroupingSpec.scala | 115 ++ .../presentation/RecentLoginSpec.scala | 40 + .../tibiabot/presentation/WorldListSpec.scala | 74 + .../scheduler/ServerSaveScheduleSpec.scala | 9 +- .../state/StreamStateConcurrencySpec.scala | 108 +- .../tibiabot/tibiadata/EntityDrainSpec.scala | 47 + .../tibiadata/TibiaDataDecodersSpec.scala | 17 +- 95 files changed, 4102 insertions(+), 3822 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 tibia-bot/src/main/scala/com/tibiabot/CachedList.scala delete mode 100644 tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/commands/SlashRouting.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/BoostedName.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/BossAliases.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/Vocations.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/WorldName.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/domain/time/SatchelCooldown.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcSupport.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/BossEmoji.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/EmbedText.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/Embeds.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/GuildActivity.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/GuildIcons.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/LevelVisibility.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/ListEmbeds.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/Names.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListGrouping.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/RecentLogin.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/presentation/WorldList.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/setup/ChannelService.scala delete mode 100644 tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/CreaturesResponse.scala delete mode 100644 tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/NewsResponse.scala delete mode 100644 tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/RaceResponse.scala create mode 100644 tibia-bot/src/test/resources/tibiadata/worlds.json create mode 100644 tibia-bot/src/test/scala/com/tibiabot/CachedListSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/commands/SlashRoutingSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/BoostedNameSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/BossAliasesSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/KillersSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/VocationsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/WorldNameSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/domain/time/SatchelCooldownSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/BossEmojiSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEffectSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedTextSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/GuildActivitySpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/GuildIconsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/LevelVisibilitySpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/ListEmbedsSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/NamesSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListGroupingSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/RecentLoginSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/presentation/WorldListSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tibiadata/EntityDrainSpec.scala diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..49f90d7 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"ebc4796c-a9b2-4ddf-8fb2-21b78f4c13bf","pid":1063296,"procStart":"3488585","acquiredAt":1780162977068} \ No newline at end of file diff --git a/README.md b/README.md index 9513c2c..d0a21cc 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Supporting packages: | `commands/` | Slash-command schemas, `CommandRouter`, `Permissions`; `commands/handlers/` has one object per command. | | `interactions/` | Button, modal and message (screenshot-upload) interaction handlers. | | `discord/` | `DiscordGateway` (the JDA read seam) and `RateLimitedSender` (outbound message queue). | -| `persistence/` | Repository ports + `ConnectionProvider`/`SchemaInitializer`; JDBC/Postgres impls in `persistence/jdbc/`. | +| `persistence/` | Repository ports + `ConnectionProvider`/`SchemaInitializer`; JDBC/Postgres impls in `persistence/jdbc/`. All JDBC access goes through `JdbcSupport.withConnection`, which releases the connection even when a statement throws, so errors can't leak connections under concurrent load. | | `presentation/` | Pure embed/message builders (deaths, online list, boosted, galthen). | | `scheduler/` | Server-save schedule decisions (window, Rashid location, Drome countdown). | | `tracking/` | Death/level/online dedup state and masslog detection. | diff --git a/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala b/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala index 6cad0d4..d1cc087 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala @@ -3,50 +3,34 @@ package com.tibiabot import akka.actor.ActorSystem import akka.stream.scaladsl.{Keep, Sink, Source} import com.tibiabot.tibiadata.TibiaDataClient -import com.tibiabot.tibiadata.response.{CharacterResponse, GuildResponse, BoostedResponse, CreatureResponse, RaceResponse, Members, HighscoresResponse} +import com.tibiabot.tibiadata.response.{CharacterResponse, GuildResponse, BoostedResponse, CreatureResponse, Members, HighscoresResponse} import com.tibiabot.scheduler.ServerSaveSchedule import com.typesafe.scalalogging.StrictLogging import net.dv8tion.jda.api.entities.channel.concrete.TextChannel import net.dv8tion.jda.api.entities.{Guild, MessageEmbed} -import net.dv8tion.jda.api.events.guild.GuildLeaveEvent -import net.dv8tion.jda.api.events.guild.GuildJoinEvent import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent -import net.dv8tion.jda.api.interactions.commands.Command.Choice -import net.dv8tion.jda.api.interactions.commands.build.{Commands, OptionData, SlashCommandData, SubcommandData, SubcommandGroupData} -import net.dv8tion.jda.api.interactions.commands.{DefaultMemberPermissions, OptionType} import net.dv8tion.jda.api.interactions.components.buttons._ -import net.dv8tion.jda.api.requests.GatewayIntent import net.dv8tion.jda.api.{EmbedBuilder, Permission} -import org.postgresql.util.PSQLException import net.dv8tion.jda.api.entities.User +import net.dv8tion.jda.api.entities.Role +import net.dv8tion.jda.api.entities.channel.attribute.IPermissionContainer import net.dv8tion.jda.api.entities.emoji.Emoji import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.utils.TimeFormat import java.awt.Color -import java.sql.{Connection, Timestamp} -import java.time.{Instant, ZoneOffset, ZonedDateTime, DayOfWeek} +import java.time.{Instant, ZonedDateTime} import scala.collection.immutable.ListMap import scala.collection.mutable.ListBuffer import scala.concurrent.duration._ import scala.concurrent.{ExecutionContextExecutor, Future} import scala.jdk.CollectionConverters._ -import java.time.format._ import scala.util.{Failure, Success} -import java.time.{LocalTime, ZoneId, LocalDateTime, LocalDate} +import java.time.ZoneId import java.time.temporal.ChronoUnit -import scala.util.{Try, Success, Failure} -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import scala.util.Random import scala.concurrent.Await -import scala.concurrent.duration._ - -import sttp.client3._ -import org.jsoup.Jsoup -import scala.jdk.CollectionConverters._ -import io.circe.parser._ -import io.circe.HCursor +import com.tibiabot.presentation.Embeds.BrandColor object BotApp extends App with StrictLogging { @@ -115,7 +99,12 @@ object BotApp extends App with StrictLogging { // Per-user boosted boss/creature notification subscriptions val boostedService = new boosted.BoostedService(connectionProvider, boostedRepository, () => boostedBossesList) - // Bot-creator-only /admin operations + // get bot userID (used to stamp automated enemy detection messages) + val botUser = discordGateway.selfUserId + // the application owner = the bot creator (used to gate /admin) + val botOwner: String = discordGateway.applicationOwnerId + + // Bot-creator-only /admin operations (needs botUser, defined above) val adminService = new admin.AdminService( discordGateway, botUser, @@ -123,19 +112,8 @@ object BotApp extends App with StrictLogging { () => { dreamScar = fetchDreamScarBosses().map(e => e.world -> e.boss).toMap } ) - // get bot userID (used to stamp automated enemy detection messages) - val botUser = discordGateway.selfUserId - private val botName = discordGateway.selfUserName - // the application owner = the bot creator (used to gate /admin) - val botOwner: String = discordGateway.applicationOwnerId - // Core hunted/allied/world state, read every cycle by the per-world streams and // written by command threads — @volatile gives cross-thread visibility. - @volatile var customSortData: Map[String, List[CustomSort]] = Map.empty - @volatile var huntedGuildsData: Map[String, List[Guilds]] = Map.empty - @volatile var alliedGuildsData: Map[String, List[Guilds]] = Map.empty - @volatile var activityCommandBlocker: Map[String, Boolean] = Map.empty - @volatile var characterCache: Map[String, ZonedDateTime] = Map.empty // The maps written by both streams and command threads live in StreamState, which // serialises every read-modify-write. BotApp delegates so existing call sites @@ -144,23 +122,62 @@ object BotApp extends App with StrictLogging { def activityData: Map[String, List[PlayerCache]] = streamState.activityData def huntedPlayersData: Map[String, List[Players]] = streamState.huntedPlayersData def alliedPlayersData: Map[String, List[Players]] = streamState.alliedPlayersData + def huntedGuildsData: Map[String, List[Guilds]] = streamState.huntedGuildsData + def alliedGuildsData: Map[String, List[Guilds]] = streamState.alliedGuildsData + def customSortData: Map[String, List[CustomSort]] = streamState.customSortData + def discordsData: Map[String, List[Discords]] = streamState.discordsData + def worldsData: Map[String, List[Worlds]] = streamState.worldsData + def activityCommandBlocker: Map[String, Boolean] = streamState.activityCommandBlocker + def characterCache: Map[String, ZonedDateTime] = streamState.characterCache def modifyActivityData(f: Map[String, List[PlayerCache]] => Map[String, List[PlayerCache]]): Unit = streamState.modifyActivityData(f) def modifyHuntedPlayersData(f: Map[String, List[Players]] => Map[String, List[Players]]): Unit = streamState.modifyHuntedPlayersData(f) def modifyAlliedPlayersData(f: Map[String, List[Players]] => Map[String, List[Players]]): Unit = streamState.modifyAlliedPlayersData(f) + def modifyHuntedGuildsData(f: Map[String, List[Guilds]] => Map[String, List[Guilds]]): Unit = + streamState.modifyHuntedGuildsData(f) + def modifyAlliedGuildsData(f: Map[String, List[Guilds]] => Map[String, List[Guilds]]): Unit = + streamState.modifyAlliedGuildsData(f) + def modifyCustomSortData(f: Map[String, List[CustomSort]] => Map[String, List[CustomSort]]): Unit = + streamState.modifyCustomSortData(f) + def modifyDiscordsData(f: Map[String, List[Discords]] => Map[String, List[Discords]]): Unit = + streamState.modifyDiscordsData(f) + def modifyWorldsData(f: Map[String, List[Worlds]] => Map[String, List[Worlds]]): Unit = + streamState.modifyWorldsData(f) + def modifyActivityCommandBlocker(f: Map[String, Boolean] => Map[String, Boolean]): Unit = + streamState.modifyActivityCommandBlocker(f) + def modifyCharacterCache(f: Map[String, ZonedDateTime] => Map[String, ZonedDateTime]): Unit = + streamState.modifyCharacterCache(f) + val worlds: List[String] = Config.worldList + + // Per-guild channel/role setup lifecycle (extraction of the channel ops from + // BotApp is in progress; currently the guild-join/leave handlers). State + // mutation stays in BotApp via the forgetGuild callback. + val channelService = new setup.ChannelService( + streamSupervisor, + schemaInitializer, + forgetGuild = guildId => { + if (worldsData.contains(guildId)) modifyWorldsData(_ - guildId) + val updatedDiscordsData = discordsData.map { case (world, discordsList) => + if (discordsList.exists(_.id == guildId)) world -> discordsList.filterNot(_.id == guildId) + else world -> discordsList + } + if (updatedDiscordsData != discordsData) modifyDiscordsData(_ => updatedDiscordsData) + }, + sharedConfigGuilds = Set("912739993015947324", "1176279097001918516", "1224670957466161234") + ) - @volatile var worldsData: Map[String, List[Worlds]] = Map.empty - @volatile var discordsData: Map[String, List[Discords]] = Map.empty - var worlds: List[String] = Config.worldList - - // Dream Courts boss rotation extracted to domain.time.DreamScarCycle + // Dream Courts boss rotation extracted to domain.time.DreamScarCycle. + // dreamScar/dromeTime are written by the scheduler thread (and dreamScar also by + // the /admin resync thread) but read every cycle by the per-world streams — so + // they need @volatile for the same cross-thread visibility reason as the state + // below; without it a stream can keep reading a stale boss/cycle after a shift. val bossCycle = domain.time.DreamScarCycle.bossCycle val indexOfBoss: Map[String, Int] = domain.time.DreamScarCycle.indexOfBoss - var dreamScar: Map[String, String] = fetchDreamScarBosses().map(e => e.world -> e.boss).toMap - var dreamScarLastCheck: String = System.currentTimeMillis().toString - var dromeTime = domain.time.DromeCycle.initial // 27 May 2026 server save - increment 2 weeks from here + @volatile var dreamScar: Map[String, String] = fetchDreamScarBosses().map(e => e.world -> e.boss).toMap + @volatile var dreamScarLastCheck: String = System.currentTimeMillis().toString + @volatile var dromeTime = domain.time.DromeCycle.initial // 27 May 2026 server save - increment 2 weeks from here // Boosted Boss val boostedBosses: Future[Either[String, BoostedResponse]] = tibiaDataClient.getBoostedBoss() @@ -200,7 +217,10 @@ object BotApp extends App with StrictLogging { } // Start all world streams - var startUpComplete = false + // Written once on the startup thread (after all world streams are launched) and + // read on JDA event threads in BotListener — @volatile so a command thread can't + // cache the initial false and reject every slash command as "still starting up". + @volatile var startUpComplete = false val startTime = Instant.now() // update Drome Timer to the latest cycle if (dromeTime.isBefore(startTime)) { @@ -208,8 +228,10 @@ object BotApp extends App with StrictLogging { } startBot(None, None) // guild: Option[Guild], world: Option[String] - // run the scheduler to clean cache and update dashboard every hour - actorSystem.scheduler.schedule(60.seconds, 30.seconds) { + // run the scheduler to clean cache and update dashboard every hour. + // scheduleWithFixedDelay (not the deprecated schedule) so a slow cycle — this + // body makes blocking API calls at server save — can't pile up behind itself. + actorSystem.scheduler.scheduleWithFixedDelay(60.seconds, 30.seconds)(() => { // set activity status // only do this every second cycle if (updateOnOdd >= 10) { @@ -267,7 +289,7 @@ object BotApp extends App with StrictLogging { boostedMonsterUpdate(boostedBoss, "", "1", "") } ( - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), s"The boosted boss today is:\n### ${Config.indentEmoji}${Config.archfoeEmoji} **[$boostedBoss](${creatureWikiUrl(boostedBoss)})**"), + presentation.BoostedEmbeds.create(creatureImageUrl(boostedBoss),s"The boosted boss today is:\n### ${Config.indentEmoji}${Config.archfoeEmoji} **[$boostedBoss](${creatureWikiUrl(boostedBoss)})**"), boostedBoss.toLowerCase != currentBoss.toLowerCase && currentBoss.toLowerCase != "none", boostedBoss ) @@ -285,7 +307,7 @@ object BotApp extends App with StrictLogging { boostedMonsterUpdate("", boostedCreature, "", "1") } ( - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), s"The boosted creature today is:\n### ${Config.indentEmoji}${Config.levelUpEmoji} **[$boostedCreature](${creatureWikiUrl(boostedCreature)})**"), + presentation.BoostedEmbeds.create(creatureImageUrl(boostedCreature),s"The boosted creature today is:\n### ${Config.indentEmoji}${Config.levelUpEmoji} **[$boostedCreature](${creatureWikiUrl(boostedCreature)})**"), boostedCreature.toLowerCase != currentCreature.toLowerCase && currentCreature.toLowerCase != "none", boostedCreature ) @@ -354,7 +376,7 @@ object BotApp extends App with StrictLogging { val rashidEmbed = new EmbedBuilder() rashidEmbed.setDescription(s"Today Rashid can be found in:\n### ${Config.indentEmoji}${Config.goldEmoji} **[${rashidLocation}](https://tibia.fandom.com/wiki/Rashid)**") rashidEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Rashid.gif") - rashidEmbed.setColor(3092790) + rashidEmbed.setColor(BrandColor) // Drome Timer val now = Instant.now() @@ -362,12 +384,12 @@ object BotApp extends App with StrictLogging { val dromeEmbed = new EmbedBuilder() .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") - .setColor(3092790) + .setColor(BrandColor) val dreamScarEmbed = new EmbedBuilder() dreamScarEmbed.setDescription(s"The Dream Courts boss for **$lastWorld** is:\n### ${Config.indentEmoji}${Config.dreamScarEmoji} **[${dreamScarDaily}](https://tibia.fandom.com/wiki/Dream_Scar/Boss_of_the_Day)**") dreamScarEmbed.setThumbnail(creatureImageUrl(dreamScarDaily)) - dreamScarEmbed.setColor(3092790) + dreamScarEmbed.setColor(BrandColor) val embedsList = if (dromeShow) List(rashidEmbed.build(), dreamScarEmbed.build(), dromeEmbed.build()) else List(rashidEmbed.build(), dreamScarEmbed.build()) val addRashidDreamScarEmbeds: List[MessageEmbed] = embeds ++ embedsList @@ -397,22 +419,16 @@ object BotApp extends App with StrictLogging { case _ : Throwable => logger.info("Failed to update the boosted messages") } } - } - - // run hunted list cleanup every day at 10:30AM CET - private val currentTime = Instant.now - private val targetTime = LocalDateTime.of(LocalDate.now, LocalTime.of(10, 30, 0)).atZone(ZoneId.of("Europe/Berlin")).toInstant - private val initialDelay = Duration.fromNanos(targetTime.toEpochMilli - currentTime.toEpochMilli).toSeconds.seconds - private val interval = 24.hours + }) def cleanOnlineListCache(maxAgeMinutes: Long): Unit = { val currentTime = ZonedDateTime.now() - characterCache = characterCache.filter { + modifyCharacterCache(_.filter { case (_, timestamp) => val ageMinutes = timestamp.until(currentTime, java.time.temporal.ChronoUnit.MINUTES) ageMinutes <= maxAgeMinutes - } + }) } //WIP @@ -439,15 +455,15 @@ object BotApp extends App with StrictLogging { // get hunted guilds val huntedGuilds = guildConfig(guild.get, "hunted_guilds") - huntedGuildsData += (guildId -> huntedGuilds) + modifyHuntedGuildsData(_ + (guildId -> huntedGuilds)) // get allied guilds val alliedGuilds = guildConfig(guild.get, "allied_guilds") - alliedGuildsData += (guildId -> alliedGuilds) + modifyAlliedGuildsData(_ + (guildId -> alliedGuilds)) // get worlds val worldsInfo = worldConfig(guild.get) - worldsData += (guildId -> worldsInfo) + modifyWorldsData(_ + (guildId -> worldsInfo)) // get tracked activity characters val activityInfo = activityConfig(guild.get, "tracked_activity") @@ -455,10 +471,10 @@ object BotApp extends App with StrictLogging { // get customSort Data val customSortInfo = customSortConfig(guild.get, "online_list_categories") - customSortData += (guildId -> customSortInfo) + modifyCustomSortData(_ + (guildId -> customSortInfo)) // set default activityCommandBlocker state - activityCommandBlocker += (guildId -> false) + modifyActivityCommandBlocker(_ + (guildId -> false)) val adminChannels = discordRetrieveConfig(guild.get) val adminChannelId = if (adminChannels.nonEmpty) adminChannels("admin_channel") else "0" @@ -473,7 +489,7 @@ object BotApp extends App with StrictLogging { boostedChannel = boostedChannelId, boostedMessage = boostedMessageId ) - discordsData = discordsData.updated(w.name, discords :: discordsData.getOrElse(w.name, Nil)) + modifyDiscordsData(d => d.updated(w.name, discords :: d.getOrElse(w.name, Nil))) // Preserves prior behaviour: when the world stream already exists it was // left unchanged (the usedBy append was overwritten and never took effect); // only an absent world starts a new stream. @@ -501,15 +517,15 @@ object BotApp extends App with StrictLogging { // get hunted guilds val huntedGuilds = guildConfig(g, "hunted_guilds") - huntedGuildsData += (guildId -> huntedGuilds) + modifyHuntedGuildsData(_ + (guildId -> huntedGuilds)) // get allied guilds val alliedGuilds = guildConfig(g, "allied_guilds") - alliedGuildsData += (guildId -> alliedGuilds) + modifyAlliedGuildsData(_ + (guildId -> alliedGuilds)) // get worlds val worldsInfo = worldConfig(g) - worldsData += (guildId -> worldsInfo) + modifyWorldsData(_ + (guildId -> worldsInfo)) // get tracked activity characters val activityInfo = activityConfig(g, "tracked_activity") @@ -517,10 +533,10 @@ object BotApp extends App with StrictLogging { // get customSort Data val customSortInfo = customSortConfig(g, "online_list_categories") - customSortData += (guildId -> customSortInfo) + modifyCustomSortData(_ + (guildId -> customSortInfo)) // set default activityCommandBlocker state - activityCommandBlocker += (guildId -> false) + modifyActivityCommandBlocker(_ + (guildId -> false)) val adminChannels = discordRetrieveConfig(g) val adminChannelId = if (adminChannels.nonEmpty) adminChannels("admin_channel") else "0" @@ -535,7 +551,7 @@ object BotApp extends App with StrictLogging { boostedChannel = boostedChannelId, boostedMessage = boostedMessageId ) - discordsData = discordsData.updated(w.name, discords :: discordsData.getOrElse(w.name, Nil)) + modifyDiscordsData(d => d.updated(w.name, discords :: d.getOrElse(w.name, Nil))) } } //} @@ -571,14 +587,14 @@ object BotApp extends App with StrictLogging { // add guild to hunted list and database val gText = gData.reasonText val gUser = gData.addedBy - val gNameFormal = subOptionValueLower.split(" ").map(_.capitalize).mkString(" ") + val gNameFormal = presentation.Names.capitalizeWords(subOptionValueLower) val gLink = guildUrl(gNameFormal) embedText = s"**Guild:** [$gNameFormal]($gLink)\n **added by:** <@$gUser>\n **reason:** $gText" val embed = new EmbedBuilder() embed.setTitle(s":gear: hunted guild details:") embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Tibiapedia.gif") - embed.setColor(3092790) + embed.setColor(BrandColor) embed.setDescription(embedText) val returnEmbed = embed.build() return returnEmbed @@ -593,14 +609,14 @@ object BotApp extends App with StrictLogging { // add guild to hunted list and database val pText = pData.reasonText val pUser = pData.addedBy - val pNameFormal = subOptionValueLower.split(" ").map(_.capitalize).mkString(" ") + val pNameFormal = presentation.Names.capitalizeWords(subOptionValueLower) val pLink = charUrl(pNameFormal) embedText = s"**Player:** [$pNameFormal]($pLink)\n **added by:** <@$pUser>\n **reason:** $pText" val embed = new EmbedBuilder() embed.setTitle(s":gear: hunted player details:") embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Tibiapedia.gif") - embed.setColor(3092790) + embed.setColor(BrandColor) embed.setDescription(embedText) val returnEmbed = embed.build() return returnEmbed @@ -612,10 +628,7 @@ object BotApp extends App with StrictLogging { } else { embedText = s"${Config.noEmoji} You need to run `/setup` and add a world first." } - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedText) - .build() + presentation.Embeds.response(embedText) } def infoAllies(event: SlashCommandInteractionEvent, subCommand: String, subOptionValue: String): MessageEmbed = { @@ -633,14 +646,14 @@ object BotApp extends App with StrictLogging { // add guild to hunted list and database val gText = gData.reasonText val gUser = gData.addedBy - val gNameFormal = subOptionValueLower.split(" ").map(_.capitalize).mkString(" ") + val gNameFormal = presentation.Names.capitalizeWords(subOptionValueLower) val gLink = guildUrl(gNameFormal) embedText = s"**Guild:** [$gNameFormal]($gLink)\n **added by:** <@$gUser>\n **reason:** $gText" val embed = new EmbedBuilder() embed.setTitle(s":gear: allied guild details:") embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Tibiapedia.gif") - embed.setColor(3092790) + embed.setColor(BrandColor) embed.setDescription(embedText) val returnEmbed = embed.build() return returnEmbed @@ -655,14 +668,14 @@ object BotApp extends App with StrictLogging { // add guild to hunted list and database val pText = pData.reasonText val pUser = pData.addedBy - val pNameFormal = subOptionValueLower.split(" ").map(_.capitalize).mkString(" ") + val pNameFormal = presentation.Names.capitalizeWords(subOptionValueLower) val pLink = charUrl(pNameFormal) embedText = s"**Player: [$pNameFormal]($pLink)**\n **added by:** <@$pUser>\n **reason:** $pText" val embed = new EmbedBuilder() embed.setTitle(s":gear: allied player details:") embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Tibiapedia.gif") - embed.setColor(3092790) + embed.setColor(BrandColor) embed.setDescription(embedText) val returnEmbed = embed.build() return returnEmbed @@ -674,10 +687,7 @@ object BotApp extends App with StrictLogging { } else { embedText = s"${Config.noEmoji} You need to run `/setup` and add a world first." } - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedText) - .build() + presentation.Embeds.response(embedText) } def listAlliesAndHuntedGuilds(event: SlashCommandInteractionEvent, arg: String, callback: List[MessageEmbed] => Unit): Unit = { @@ -713,34 +723,15 @@ object BotApp extends App with StrictLogging { guildApiBuffer += s"**$name** *(This guild doesn't exist)*" } val guildsAsList: List[String] = List(guildHeader) ++ guildApiBuffer - var field = "" - var isFirstEmbed = true - guildsAsList.foreach { v => - val currentField = field + "\n" + v - if (currentField.length <= 4096) { // don't add field yet, there is still room - field = currentField - } else { // it's full, add the field - val interimEmbed = new EmbedBuilder() - interimEmbed.setDescription(field) - interimEmbed.setColor(embedColor) - if (isFirstEmbed) { - interimEmbed.setThumbnail(guildThumbnail) - isFirstEmbed = false - } - guildBuffer += interimEmbed.build() - field = v - } - } - val finalEmbed = new EmbedBuilder() - finalEmbed.setDescription(field) - finalEmbed.setColor(embedColor) - if (isFirstEmbed) { - finalEmbed.setThumbnail(guildThumbnail) - isFirstEmbed = false - } - guildBuffer += finalEmbed.build() + guildBuffer ++= presentation.ListEmbeds.paginate(guildsAsList, guildThumbnail, embedColor) callback(guildBuffer.toList) - case Failure(_) => // e.printStackTrace + case Failure(exception) => + // Don't leave the deferred /allies|/hunted reply hanging on failure. + logger.error(s"Failed to build the $arg guilds list for Guild '${guild.getName}': ${exception.getMessage}", exception) + val errorEmbed = new EmbedBuilder() + errorEmbed.setColor(embedColor) + errorEmbed.setDescription(s"${Config.noEmoji} Failed to load the guilds list, try again.") + callback(List(errorEmbed.build())) } } else { // guild list is empty val listIsEmpty = new EmbedBuilder() @@ -766,7 +757,7 @@ object BotApp extends App with StrictLogging { if (listGuilds.nonEmpty) { modifyActivityData { m => - m.mapValues { + m.view.mapValues { _.filterNot(pc => guildNamesToRemove.contains(pc.guild.toLowerCase)) }.toMap } @@ -792,10 +783,7 @@ object BotApp extends App with StrictLogging { } val embedText = s"${Config.yesEmoji} The allies list has been reset." - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedText) - .build() + presentation.Embeds.response(embedText) } def clearHunted(event: SlashCommandInteractionEvent): MessageEmbed = { @@ -809,7 +797,7 @@ object BotApp extends App with StrictLogging { if (listGuilds.nonEmpty) { // Filter out activityData in one pass by using a Set for efficient lookup modifyActivityData { m => - m.mapValues { + m.view.mapValues { _.filterNot(pc => guildNamesToRemove.contains(pc.guild.toLowerCase)) }.toMap } @@ -834,10 +822,7 @@ object BotApp extends App with StrictLogging { } } var embedText = s"${Config.yesEmoji} The hunted list has been reset." - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedText) - .build() + presentation.Embeds.response(embedText) // } @@ -850,18 +835,8 @@ object BotApp extends App with StrictLogging { private def cleanHuntedList(): Unit = cacheRepository.removeExpiredList(ZonedDateTime.now()) - def dateStringToEpochSeconds(dateString: String): String = { - if (dateString != "") { - val formatter = DateTimeFormatter.ISO_INSTANT - val instant = Instant.from(formatter.parse(dateString)) - val now = Instant.now() - if (Math.abs(instant.until(now, ChronoUnit.HOURS)) <= 24) { - s"<:daily:1133349016814485584>" - } else { - "" - } - } else "" - } + def dateStringToEpochSeconds(dateString: String): String = + presentation.RecentLogin.stamp(dateString, Instant.now()) def listAlliesAndHuntedPlayers(event: SlashCommandInteractionEvent, arg: String, callback: List[MessageEmbed] => Unit): Unit = { // get command option @@ -869,7 +844,6 @@ object BotApp extends App with StrictLogging { val guildId = guild.getId val embedColor = 3092790 - //val playerHeader = if (arg == "allies") s"${Config.allyGuild} **Players** ${Config.allyGuild}" else if (arg == "hunted") s"${Config.enemy} **Players** ${Config.enemy}" else "" val playerHeader = s"__**Players:**__" val listPlayers: List[Players] = if (arg == "allies") alliedPlayersData.getOrElse(guild.getId, List.empty[Players]).map(g => g) else if (arg == "hunted") huntedPlayersData.getOrElse(guild.getId, List.empty[Players]).map(g => g) @@ -904,12 +878,7 @@ object BotApp extends App with StrictLogging { futureResults.onComplete { case Success(output) => val vocationBuffers = ListMap( - "druid" -> ListBuffer[(Int, String, String)](), - "knight" -> ListBuffer[(Int, String, String)](), - "paladin" -> ListBuffer[(Int, String, String)](), - "sorcerer" -> ListBuffer[(Int, String, String)](), - "monk" -> ListBuffer[(Int, String, String)](), - "none" -> ListBuffer[(Int, String, String)]() + domain.Vocations.displayOrder.map(_ -> ListBuffer[(Int, String, String)]()): _* ) // Add concatenatedCacheNames to the respective vocationBuffers based on their vocations for (player <- filteredConcatenatedListCache) { @@ -917,29 +886,11 @@ object BotApp extends App with StrictLogging { val pWorld = player.world val pLvl = player.level // You might want to set an appropriate level here for characters in the cache val pVoc = player.vocation.toLowerCase.split(' ').last - val pEmoji = pVoc match { - case "knight" => ":shield:" - case "druid" => ":snowflake:" - case "sorcerer" => ":fire:" - case "paladin" => ":bow_and_arrow:" - case "monk" => ":fist::skin-tone-3:" - case "none" => ":hatching_chick:" - case _ => "" - } + val pEmoji = presentation.Emojis.vocEmoji(pVoc) val pGuild = player.guild val allyGuildCheck = if (pGuild != "") alliedGuildsData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == pGuild.toLowerCase()) else false val huntedGuildCheck = if (pGuild != "") huntedGuildsData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == pGuild.toLowerCase()) else false - val pIcon = (pGuild, allyGuildCheck, huntedGuildCheck, arg) match { - case (_, true, _, "allies") => Config.allyGuild // allied guilds - case (_, _, true, "allies") => s"${Config.enemyGuild}${Config.ally}" // allied players but in enemy guild(?) - case (_, _, true, "hunted") => s"${Config.enemyGuild}" // enemy player in hunted guild - case (_, true, _, "hunted") => s"${Config.allyGuild}${Config.enemy}" // hunted players but in ally guild(?) - case ("", _, _, "hunted") => Config.enemy // hunted players no guild - case ("", _, _, "allies") => Config.ally // allied player in no guild - case (_, _, _, "hunted") => s"${Config.otherGuild}${Config.enemy}" // hunted in neutral guild - case (_, _, _, "allies") => s"${Config.otherGuild}${Config.ally}" // ally in neutral guild - case _ => "" - } + val pIcon = presentation.GuildIcons.listGuildIcon(pGuild, allyGuildCheck, huntedGuildCheck, arg) val pLoginRelative = dateStringToEpochSeconds(player.last_login) // "2022-01-01T01:00:00Z" if (pVoc != "") { // only show players on worlds that you have setup @@ -957,29 +908,18 @@ object BotApp extends App with StrictLogging { val charGuildName = if(charGuild.isDefined) charGuild.head.name else "" val allyGuildCheck = if (charGuildName != "") alliedGuildsData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == charGuildName.toLowerCase()) else false val huntedGuildCheck = if (charGuildName != "") huntedGuildsData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == charGuildName.toLowerCase()) else false - val guildIcon = (charGuildName, allyGuildCheck, huntedGuildCheck, arg) match { - case (_, true, _, "allies") => Config.allyGuild // allied guilds - case (_, _, true, "allies") => s"${Config.enemyGuild}${Config.ally}" // allied players but in enemy guild(?) - case (_, _, true, "hunted") => s"${Config.enemyGuild}" // enemy player in hunted guild - case (_, true, _, "hunted") => s"${Config.allyGuild}${Config.enemy}" // hunted players but in ally guild(?) - case ("", _, _, "hunted") => Config.enemy // hunted players no guild - case ("", _, _, "allies") => Config.ally // allied player in no guild - case (_, _, _, "hunted") => s"${Config.otherGuild}${Config.enemy}" // hunted in neutral guild - case (_, _, _, "allies") => s"${Config.otherGuild}${Config.ally}" // ally in neutral guild - case _ => "" - } + val guildIcon = presentation.GuildIcons.listGuildIcon(charGuildName, allyGuildCheck, huntedGuildCheck, arg) val charVocation = charResponse.character.character.vocation val charWorld = charResponse.character.character.world val charLink = charUrl(charName) val charEmoji = vocEmoji(charResponse) - val pNameFormal = name.split(" ").map(_.capitalize).mkString(" ") + val pNameFormal = presentation.Names.capitalizeWords(name) val voc = charVocation.toLowerCase.split(' ').last val lastLoginTime = charResponse.character.character.last_login.getOrElse("") // only show players on worlds that you have setup if (allWorlds.exists(_.name.toLowerCase == charWorld.toLowerCase)) { vocationBuffers(voc) += ((charLevel, charWorld, s"$charEmoji **${charLevel.toString}** — **[$pNameFormal]($charLink)** $guildIcon ${dateStringToEpochSeconds(lastLoginTime)}")) } - //def addListToCache(name: String, formerNames: List[String], world: String, formerWorlds: List[String], guild: String, level: String, vocation: String, lastLogin: String, updatedTime: ZonedDateTime): Unit = { val formerNamesList = charResponse.character.character.former_names.map(_.toList).getOrElse(Nil) val formerWorldsList = charResponse.character.character.former_worlds.map(_.toList).getOrElse(Nil) val charLastLogin = charResponse.character.character.last_login.getOrElse("") @@ -990,83 +930,24 @@ object BotApp extends App with StrictLogging { case (Left(errorMessage), name, _, _) => vocationBuffers("none") += ((0, "Character does not exist", s"${Config.noEmoji} **N/A** — **$name**")) } - // group by world - val vocationWorldBuffers = vocationBuffers.map { - case (voc, buffer) => - voc -> buffer.groupBy(_._2) - } - - // druids grouped by world sorted by level - val druidsWorldLists = vocationWorldBuffers("druid").map { - case (world, worldBuffer) => - world -> worldBuffer.toList.sortBy(-_._1).map(_._3) - } - // knights - val knightsWorldLists = vocationWorldBuffers("knight").map { - case (world, worldBuffer) => - world -> worldBuffer.toList.sortBy(-_._1).map(_._3) - } - // paladins - val paladinsWorldLists = vocationWorldBuffers("paladin").map { - case (world, worldBuffer) => - world -> worldBuffer.toList.sortBy(-_._1).map(_._3) - } - // sorcerers - val sorcerersWorldLists = vocationWorldBuffers("sorcerer").map { - case (world, worldBuffer) => - world -> worldBuffer.toList.sortBy(-_._1).map(_._3) - } - // monks - val monksWorldLists = vocationWorldBuffers("monk").map { - case (world, worldBuffer) => - world -> worldBuffer.toList.sortBy(-_._1).map(_._3) - } - // none - val noneWorldLists = vocationWorldBuffers("none").map { - case (world, worldBuffer) => - world -> worldBuffer.toList.sortBy(-_._1).map(_._3) - } - - // combine these into one list now that its ordered by level and grouped by world - val allPlayers = List(noneWorldLists, monksWorldLists, sorcerersWorldLists, paladinsWorldLists, knightsWorldLists, druidsWorldLists).foldLeft(Map.empty[String, List[String]]) { - (acc, m) => m.foldLeft(acc) { - case (map, (k, v)) => map + (k -> (v ++ map.getOrElse(k, List()))) - } - } + // group by world, ordered within each world by vocation then level + val allPlayers = presentation.WorldList.byWorld( + vocationBuffers.map { case (voc, buffer) => voc -> buffer.toSeq }) // output a List[String] for the embed - val playersList = List(playerHeader) ++ createWorldList(allPlayers) - - // build the embed - var field = "" - var isFirstEmbed = true - playersList.foreach { v => - val currentField = field + "\n" + v - if (currentField.length <= 4096) { // don't add field yet, there is still room - field = currentField - } else { // it's full, add the field - val interimEmbed = new EmbedBuilder() - interimEmbed.setDescription(field) - interimEmbed.setColor(embedColor) - if (isFirstEmbed) { - interimEmbed.setThumbnail(embedThumbnail) - isFirstEmbed = false - } - playerBuffer += interimEmbed.build() - field = v - } - } - val finalEmbed = new EmbedBuilder() - finalEmbed.setDescription(field) - finalEmbed.setColor(embedColor) - if (isFirstEmbed) { - finalEmbed.setThumbnail(embedThumbnail) - isFirstEmbed = false - } - playerBuffer += finalEmbed.build() + val playersList = List(playerHeader) ++ presentation.WorldList.format(allPlayers) + + // build the embeds + playerBuffer ++= presentation.ListEmbeds.paginate(playersList, embedThumbnail, embedColor) callback(playerBuffer.toList) - case Failure(_) => // e.printStackTrace + case Failure(exception) => + // Don't leave the deferred /allies|/hunted reply hanging on failure. + logger.error(s"Failed to build the $arg players list for Guild '${guild.getName}': ${exception.getMessage}", exception) + val errorEmbed = new EmbedBuilder() + errorEmbed.setColor(embedColor) + errorEmbed.setDescription(s"${Config.noEmoji} Failed to load the players list, try again.") + callback(List(errorEmbed.build())) } } else { // player list is empty val listIsEmpty = new EmbedBuilder() @@ -1081,47 +962,124 @@ object BotApp extends App with StrictLogging { } def vocEmoji(char: CharacterResponse): String = - presentation.Emojis.vocEmojiWithoutMonk(char.character.character.vocation) - - private def createWorldList(worlds: Map[String, List[String]]): List[String] = { - val sortedWorlds = worlds.toList.sortBy(_._1) - .sortWith((a, b) => { - if (a._1 == "Character does not exist") false - else if (b._1 == "Character does not exist") true - else a._1 < b._1 - }) - sortedWorlds.flatMap { - case (world, players) => - s":globe_with_meridians: **$world** :globe_with_meridians:" :: players + presentation.Emojis.vocEmoji(char.character.character.vocation) + + /** Fetch a character and reduce it to the (name, world, vocation-emoji, level) + * summary the add/remove player commands render. On lookup failure yields the + * empty/"does not exist" summary (name == ""). Shared by those commands. */ + private def fetchPlayerSummary(name: String): Future[(String, String, String, Int)] = + tibiaDataClient.getCharacter(name).map { + case Right(charResponse) => + val character = charResponse.character.character + (character.name, character.world, vocEmoji(charResponse), character.level.toInt) + case Left(_) => + ("", "", s"${Config.noEmoji}", 0) + } + + /** Post a "command was run" audit embed to a guild's command-log channel, if + * it exists and is writable. Centralises the block repeated by every command + * that audits itself (title/colour are fixed; description/thumbnail vary). */ + private def postAdminLog(adminChannel: TextChannel, description: String, thumbnail: String): Unit = + if (adminChannel != null && (adminChannel.canTalk() || !Config.prod)) { + val adminEmbed = new EmbedBuilder() + adminEmbed.setTitle(":gear: a command was run:") + adminEmbed.setDescription(description) + adminEmbed.setThumbnail(thumbnail) + adminEmbed.setColor(BrandColor) + adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() } + + /** The "boosted boss today" embed (with a Podium fallback if the API fails). + * Shared by the channel-setup and server-save-notification paths. */ + private def boostedBossEmbed(): Future[MessageEmbed] = + tibiaDataClient.getBoostedBoss().map { + case Right(boostedResponse) => + val boostedBoss = boostedResponse.boostable_bosses.boosted.name + presentation.BoostedEmbeds.create(creatureImageUrl(boostedBoss),s"The boosted boss today is:\n### ${Config.indentEmoji}${Config.archfoeEmoji} **[$boostedBoss](${creatureWikiUrl(boostedBoss)})**") + case Left(_) => + presentation.BoostedEmbeds.create(creatureImageUrl("Podium_of_Vigour"),"The boosted boss today failed to load?") + } + + /** The "boosted creature today" embed (with a Podium fallback if the API fails). */ + private def boostedCreatureEmbed(): Future[MessageEmbed] = + tibiaDataClient.getBoostedCreature().map { + case Right(creatureResponse) => + val boostedCreature = creatureResponse.creatures.boosted.name + presentation.BoostedEmbeds.create(creatureImageUrl(boostedCreature),s"The boosted creature today is:\n### ${Config.indentEmoji}${Config.levelUpEmoji} **[$boostedCreature](${creatureWikiUrl(boostedCreature)})**") + case Left(_) => + presentation.BoostedEmbeds.create(creatureImageUrl("Podium_of_Tenacity"),"The boosted creature today failed to load?") + } + + /** The Rashid / Dream Courts / (Drome, when active) server-save embeds for a + * world, appended after the boosted embeds in the notifications message. + * Reads the live dreamScar map and dromeTime. */ + private def serverSaveExtraEmbeds(world: String): List[MessageEmbed] = { + val dreamScarDaily = dreamScar.getOrElse(world, "World not found") + val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) + val rashidEmbed = new EmbedBuilder() + .setDescription(s"Today Rashid can be found in:\n### ${Config.indentEmoji}${Config.goldEmoji} **[${rashidLocation}](https://tibia.fandom.com/wiki/Rashid)**") + .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Rashid.gif") + .setColor(BrandColor) + .build() + val dreamScarEmbed = new EmbedBuilder() + .setDescription(s"The Dream Courts boss for **$world** is:\n### ${Config.indentEmoji}${Config.dreamScarEmoji} **[${dreamScarDaily}](https://tibia.fandom.com/wiki/Dream_Scar/Boss_of_the_Day)**") + .setThumbnail(creatureImageUrl(dreamScarDaily)) + .setColor(BrandColor) + .build() + val dromeShow = ServerSaveSchedule.shouldShowDrome(Instant.now(), dromeTime) + val dromeEmbed = new EmbedBuilder() + .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") + .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") + .setColor(BrandColor) + .build() + if (dromeShow) List(rashidEmbed, dreamScarEmbed, dromeEmbed) else List(rashidEmbed, dreamScarEmbed) } + /** The role-subscription buttons under the fullbless/notifications embed. */ + private def fullblessRoleButtons: List[Button] = List( + Button.success("fullbless", " ").withEmoji(Emoji.fromFormatted(Config.inqEmoji)), + Button.primary("nemesis", " ").withEmoji(Emoji.fromFormatted(Config.bossEmoji)), + Button.danger("allypk", " ").withEmoji(Emoji.fromFormatted(Config.hazardEmoji)), + Button.secondary("masslog", " ").withEmoji(Emoji.fromFormatted(Config.masslogEmoji)) + ) + + /** The "the bot will poke" role-notification embed for a world. Built by both + * /setup (initial post) and /fullbless (edits the existing message). */ + private def fullblessRoleEmbed(world: String, fullblessRoleId: String, nemesisRoleId: String, allyPkRoleId: String, masslogRoleId: String, level: Int): MessageEmbed = + new EmbedBuilder() + .setTitle(s":crossed_swords: $world :crossed_swords:", s"https://www.tibia.com/community/?subtopic=worlds&world=$world") + .setThumbnail("https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Phantasmal_Ooze.gif") + .setColor(BrandColor) + .setFooter("Add or remove yourself from the role using the buttons below:") + .setDescription(s"The bot will poke:\n${Config.inqEmoji}<@&$fullblessRoleId> If an enemy fullblesses and is over level `$level`\n${Config.bossEmoji}<@&$nemesisRoleId> If anyone dies to a rare boss\n${Config.hazardEmoji}<@&$allyPkRoleId> If an ally gets pked\n${Config.masslogEmoji}<@&$masslogRoleId> If enemies masslog on **$world**") + .build() + def charUrl(char: String): String = presentation.Urls.charUrl(char) def guildUrl(guild: String): String = presentation.Urls.guildUrl(guild) def updateAdminChannel(inputId: String, channelId: String): Unit = { - discordsData = discordsData.view.mapValues(_.map { + modifyDiscordsData(dd => dd.view.mapValues(_.map { case discord @ Discords(id, _, _, _) if id == inputId => discord.copy(adminChannel = channelId) case other => other - }).toMap + }).toMap) } def updateBoostedChannel(inputId: String, channelId: String): Unit = { - discordsData = discordsData.view.mapValues(_.map { + modifyDiscordsData(dd => dd.view.mapValues(_.map { case discord @ Discords(id, _, _, _) if id == inputId => discord.copy(boostedChannel = channelId) case other => other - }).toMap + }).toMap) } def updateBoostedMessage(inputId: String, messageId: String): Unit = { - discordsData = discordsData.view.mapValues(_.map { + modifyDiscordsData(dd => dd.view.mapValues(_.map { case discord @ Discords(id, _, _, _) if id == inputId => discord.copy(boostedMessage = messageId) case other => other - }).toMap + }).toMap) } def addHunted(event: SlashCommandInteractionEvent, subCommand: String, subOptionValue: String, subOptionReason: String, callback: MessageEmbed => Unit): Unit = { @@ -1131,7 +1089,7 @@ object BotApp extends App with StrictLogging { val commandUser = event.getUser.getId val guild = event.getGuild val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) // default embed content var embedText = s"${Config.noEmoji} An error occurred while running the /hunted command" if (checkConfigDatabase(guild)) { @@ -1153,21 +1111,12 @@ object BotApp extends App with StrictLogging { if (guildName != "") { if (!huntedGuildsData.getOrElse(guildId, List()).exists(g => g.name == subOptionValueLower)) { // add guild to hunted list and database - huntedGuildsData = huntedGuildsData + (guildId -> (Guilds(subOptionValueLower, reason, subOptionReason, commandUser) :: huntedGuildsData.getOrElse(guildId, List()))) + modifyHuntedGuildsData(m => m + (guildId -> (Guilds(subOptionValueLower, reason, subOptionReason, commandUser) :: m.getOrElse(guildId, List())))) addHuntedToDatabase(guild, "guild", subOptionValueLower, reason, subOptionReason, commandUser) embedText = s":gear: The guild **[$guildName](${guildUrl(guildName)})** has been added to the hunted list." // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> added the guild **[$guildName](${guildUrl(guildName)})** to the hunted list.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> added the guild **[$guildName](${guildUrl(guildName)})** to the hunted list.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") // add each player in the guild to the activity list guildMembers.foreach { member => @@ -1197,14 +1146,7 @@ object BotApp extends App with StrictLogging { } } else if (subCommand == "player") { // command run with 'player' // run api against player - val playerCheck: Future[Either[String, CharacterResponse]] = tibiaDataClient.getCharacter(subOptionValueLower) - playerCheck.map { - case Right(charResponse) => - val character = charResponse.character.character - (character.name, character.world, vocEmoji(charResponse), character.level.toInt) - case Left(errorMessage) => - ("", "" , s"${Config.noEmoji}", 0) - }.map { case (playerName, world, vocation, level) => + fetchPlayerSummary(subOptionValueLower).map { case (playerName, world, vocation, level) => if (playerName != "") { if (!huntedPlayersData.getOrElse(guildId, List()).exists(g => g.name == subOptionValueLower)) { // add player to hunted list and database @@ -1213,16 +1155,7 @@ object BotApp extends App with StrictLogging { embedText = s":gear: The player **[$playerName](${charUrl(playerName)})** has been added to the hunted list." // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> added the player\n$vocation **$level** — **[$playerName](${charUrl(playerName)})**\nto the hunted list for **$world**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> added the player\n$vocation **$level** — **[$playerName](${charUrl(playerName)})**\nto the hunted list for **$world**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") embedBuild.setDescription(embedText) callback(embedBuild.build()) @@ -1256,7 +1189,7 @@ object BotApp extends App with StrictLogging { val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) // default embed content var embedText = s"${Config.noEmoji} An error occurred while running the /allies command" if (checkConfigDatabase(guild)) { @@ -1277,20 +1210,11 @@ object BotApp extends App with StrictLogging { }.map { case (guildName, guildMembers) => if (guildName != "") { if (!alliedGuildsData.getOrElse(guildId, List()).exists(g => g.name == subOptionValueLower)) { - alliedGuildsData = alliedGuildsData + (guildId -> (Guilds(subOptionValueLower, reason, subOptionReason, commandUser) :: alliedGuildsData.getOrElse(guildId, List()))) + modifyAlliedGuildsData(m => m + (guildId -> (Guilds(subOptionValueLower, reason, subOptionReason, commandUser) :: m.getOrElse(guildId, List())))) addAllyToDatabase(guild, "guild", subOptionValueLower, reason, subOptionReason, commandUser) embedText = s":gear: The guild **[$guildName](${guildUrl(guildName)})** has been added to the allies list." - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> added the guild **[$guildName](${guildUrl(guildName)})** to the allies list.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> added the guild **[$guildName](${guildUrl(guildName)})** to the allies list.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") // add each player in the guild to the hunted list /*** @@ -1331,30 +1255,14 @@ object BotApp extends App with StrictLogging { } } else if (subCommand == "player") { // run api against player - val playerCheck: Future[Either[String, CharacterResponse]] = tibiaDataClient.getCharacter(subOptionValueLower) - playerCheck.map { - case Right(charResponse) => - val character = charResponse.character.character - (character.name, character.world, vocEmoji(charResponse), character.level.toInt) - case Left(errorMessage) => - ("", "", s"${Config.noEmoji}", 0) - }.map { case (playerName, world, vocation, level) => + fetchPlayerSummary(subOptionValueLower).map { case (playerName, world, vocation, level) => if (playerName != "") { if (!alliedPlayersData.getOrElse(guildId, List()).exists(g => g.name == subOptionValueLower)) { modifyAlliedPlayersData(m => m + (guildId -> (Players(subOptionValueLower, reason, subOptionReason, commandUser) :: m.getOrElse(guildId, List())))) addAllyToDatabase(guild, "player", subOptionValueLower, reason, subOptionReason, commandUser) embedText = s":gear: The player **[$playerName](${charUrl(playerName)})** has been added to the allies list." - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> added the player\n$vocation **$level** — **[$playerName](${charUrl(playerName)})**\nto the allies list for **$world**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> added the player\n$vocation **$level** — **[$playerName](${charUrl(playerName)})**\nto the allies list for **$world**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") embedBuild.setDescription(embedText) callback(embedBuild.build()) @@ -1387,7 +1295,7 @@ object BotApp extends App with StrictLogging { val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) var embedText = s"${Config.noEmoji} An error occurred while running the /removehunted command" if (checkConfigDatabase(guild)) { val guildId = guild.getId @@ -1413,7 +1321,7 @@ object BotApp extends App with StrictLogging { case Some(_) => val updatedList = huntedGuildsList.filterNot(_.name.toLowerCase == subOptionValueLower) // Remove guilds from cache and db - huntedGuildsData = huntedGuildsData.updated(guildId, updatedList) + modifyHuntedGuildsData(_.updated(guildId, updatedList)) removeHuntedFromDatabase(guild, "guild", subOptionValueLower) modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(_.guild.equalsIgnoreCase(subOptionValueLower)))) @@ -1434,16 +1342,7 @@ object BotApp extends App with StrictLogging { } // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> removed guild **$guildString** from the hunted list.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> removed guild **$guildString** from the hunted list.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") embedText = s":gear: The guild **$guildString** was removed from the hunted list." embedBuild.setDescription(embedText) @@ -1475,14 +1374,7 @@ object BotApp extends App with StrictLogging { } else if (subCommand == "player") { var playerString = subOptionValueLower // run api against player - val playerCheck: Future[Either[String, CharacterResponse]] = tibiaDataClient.getCharacter(subOptionValueLower) - playerCheck.map { - case Right(charResponse) => - val character = charResponse.character.character - (character.name, character.world, vocEmoji(charResponse), character.level.toInt) - case Left(errorMessage) => - ("", "", s"${Config.noEmoji}", 0) - }.map { case (playerName, world, vocation, level) => + fetchPlayerSummary(subOptionValueLower).map { case (playerName, world, vocation, level) => if (playerName != "") { playerString = s"[$playerName](${charUrl(playerName)})" } @@ -1498,16 +1390,7 @@ object BotApp extends App with StrictLogging { removePlayerActivityfromDatabase(guild, subOptionValueLower) // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> removed the player\n$vocation **$level** — **$playerString**\nfrom the hunted list for **$world**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> removed the player\n$vocation **$level** — **$playerString**\nfrom the hunted list for **$world**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Stone_Coffin.gif") embedText = s":gear: The player **$playerString** was removed from the hunted list." embedBuild.setDescription(embedText) @@ -1532,7 +1415,7 @@ object BotApp extends App with StrictLogging { val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) var embedText = s"${Config.noEmoji} An error occurred while running the /removehunted command" if (checkConfigDatabase(guild)) { val guildId = guild.getId @@ -1557,23 +1440,14 @@ object BotApp extends App with StrictLogging { alliedGuildsList.find(_.name.toLowerCase == subOptionValueLower) match { case Some(_) => val updatedList = alliedGuildsList.filterNot(_.name.toLowerCase == subOptionValueLower) - alliedGuildsData = alliedGuildsData.updated(guildId, updatedList) + modifyAlliedGuildsData(_.updated(guildId, updatedList)) removeAllyFromDatabase(guild, "guild", subOptionValueLower) modifyActivityData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(_.guild.equalsIgnoreCase(subOptionValueLower)))) removeGuildActivityfromDatabase(guild, subOptionValueLower) // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> removed **$guildString** from the allies list.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> removed **$guildString** from the allies list.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") embedText = s":gear: The guild **$guildString** was removed from the allies list." embedBuild.setDescription(embedText) @@ -1589,14 +1463,7 @@ object BotApp extends App with StrictLogging { } else if (subCommand == "player") { var playerString = subOptionValueLower // run api against player - val playerCheck: Future[Either[String, CharacterResponse]] = tibiaDataClient.getCharacter(subOptionValueLower) - playerCheck.map { - case Right(charResponse) => - val character = charResponse.character.character - (character.name, character.world, vocEmoji(charResponse), character.level.toInt) - case Left(errorMessage) => - ("", "", s"${Config.noEmoji}", 0) - }.map { case (playerName, world, vocation, level) => + fetchPlayerSummary(subOptionValueLower).map { case (playerName, world, vocation, level) => if (playerName != "") { playerString = s"[$playerName](${charUrl(playerName)})" } @@ -1611,16 +1478,7 @@ object BotApp extends App with StrictLogging { removePlayerActivityfromDatabase(guild, subOptionValueLower) // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> removed the player\n$vocation **$level** — **$playerString**\nfrom the allies list for **$world**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> removed the player\n$vocation **$level** — **$playerString**\nfrom the allies list for **$world**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Angel_Statue.gif") embedText = s":gear: The player **$playerString** was removed from the allies list." embedBuild.setDescription(embedText) @@ -1670,8 +1528,6 @@ object BotApp extends App with StrictLogging { private def checkConfigDatabase(guild: Guild): Boolean = schemaInitializer.guildDatabaseExists(guild.getId) - private def createPremiumDatabase(): Unit = schemaInitializer.initPremium() - private def createCacheDatabase(): Unit = schemaInitializer.initCache() def getDeathsCache(world: String): List[DeathsCache] = cacheRepository.getDeaths(world) @@ -1692,9 +1548,6 @@ object BotApp extends App with StrictLogging { private def createConfigDatabase(guild: Guild): Unit = schemaInitializer.initGuild(guild.getId, guild.getName) - private def getConnection(guild: Guild): Connection = - connectionProvider.guild(guild.getId) - private def playerConfig(guild: Guild, query: String): List[Players] = huntedAlliedRepository.getPlayers(guild.getId, query) @@ -1727,8 +1580,13 @@ object BotApp extends App with StrictLogging { def createChannels(event: SlashCommandInteractionEvent): MessageEmbed = { // get guild & world information from the slash interaction - val world: String = event.getInteraction.getOptions.asScala.find(_.getName == "world").map(_.getAsString).getOrElse("").trim().toLowerCase().capitalize - val embedText = if (worlds.contains(world)) { + val world: String = domain.WorldName.formal(event.getInteraction.getOptions.asScala.find(_.getName == "world").map(_.getAsString).getOrElse("").trim()) + // The role/category/channel/permission creation below is a long sequence of + // blocking .complete() calls. If any one throws (missing permission, Discord + // error, channel cap) the server is left half-built and the slash interaction + // would otherwise hang with no reply — so report it cleanly and point at /repair. + val embedText = try { + if (worlds.contains(world)) { // get guild id val guild = event.getGuild @@ -1736,24 +1594,14 @@ object BotApp extends App with StrictLogging { createConfigDatabase(guild) val botRole = guild.getBotRole - val fullblessRoleString = s"$world Fullbless" - val fullblessRoleCheck = guild.getRolesByName(fullblessRoleString, true) - val fullblessRole = if (!fullblessRoleCheck.isEmpty) fullblessRoleCheck.get(0) else guild.createRole().setName(fullblessRoleString).setColor(new Color(0, 156, 70)).complete() - - val nemesisRoleString = s"$world Rare Boss" - val nemesisRoleCheck = guild.getRolesByName(nemesisRoleString, true) - val nemesisRole = if (!nemesisRoleCheck.isEmpty) nemesisRoleCheck.get(0) else guild.createRole().setName(nemesisRoleString).setColor(new Color(164, 76, 230)).complete() - - val allyPkRoleString = s"$world PVP" - val allyPkCheck = guild.getRolesByName(allyPkRoleString, true) - val allyPkRole = if (!allyPkCheck.isEmpty) allyPkCheck.get(0) else guild.createRole().setName(allyPkRoleString).setColor(new Color(220, 0, 0)).complete() - - val masslogRoleString = s"$world Masslog" - val masslogCheck = guild.getRolesByName(masslogRoleString, true) - val masslogRole = if (!masslogCheck.isEmpty) masslogCheck.get(0) else guild.createRole().setName(masslogRoleString).setColor(new Color(219, 175, 72)).complete() + val fullblessRole = getOrCreateRole(guild, s"$world Fullbless", new Color(0, 156, 70)) + val nemesisRole = getOrCreateRole(guild, s"$world Rare Boss", new Color(164, 76, 230)) + val allyPkRole = getOrCreateRole(guild, s"$world PVP", new Color(220, 0, 0)) + val masslogRole = getOrCreateRole(guild, s"$world Masslog", new Color(219, 175, 72)) - val worldCount = worldConfig(guild) - val count = worldCount.length + // touch the worlds config so listWorlds runs its ALTER TABLE column + // migrations on older databases before /setup writes to the table + worldConfig(guild) // see if admin channels exist val discordConfig = discordRetrieveConfig(guild) @@ -1768,6 +1616,7 @@ object BotApp extends App with StrictLogging { // restrict the channel so only roles with Permission.MANAGE_MESSAGES can write to the channels adminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_SEND).complete() adminChannel.upsertPermissionOverride(botRole).grant(Permission.VIEW_CHANNEL).complete() + adminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_EMBED_LINKS).complete() adminChannel.upsertPermissionOverride(guild.getPublicRole).deny(Permission.VIEW_CHANNEL).queue() val guildOwner = if (guild.getOwner == null) "Not Available" else guild.getOwner.getEffectiveName discordCreateConfig(guild, guild.getName, guildOwner, adminCategory.getId, adminChannel.getId, "0", "0", ZonedDateTime.now()) @@ -1779,108 +1628,9 @@ object BotApp extends App with StrictLogging { boostedChannel.upsertPermissionOverride(guild.getPublicRole).grant(Permission.VIEW_CHANNEL).queue() discordUpdateConfig(guild, "", "", boostedChannel.getId, "", world) - val galthenEmbed = new EmbedBuilder() - galthenEmbed.setColor(3092790) - galthenEmbed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nManage your cooldowns here:") - galthenEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - boostedChannel.sendMessageEmbeds(galthenEmbed.build()).addActionRow( - Button.primary("galthen default", "Cooldowns").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) - ).queue() - - // Boosted Boss - val boostedBoss: Future[Either[String, BoostedResponse]] = tibiaDataClient.getBoostedBoss() - val bossEmbedFuture: Future[MessageEmbed] = boostedBoss.map { - case Right(boostedResponse) => - val boostedBoss = boostedResponse.boostable_bosses.boosted.name - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), s"The boosted boss today is:\n### ${Config.indentEmoji}${Config.archfoeEmoji} **[$boostedBoss](${creatureWikiUrl(boostedBoss)})**") + postGalthenTracker(boostedChannel) - case Left(errorMessage) => - val boostedBoss = "Podium_of_Vigour" - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), "The boosted boss today failed to load?") - } - - // Boosted Creature - val boostedCreature: Future[Either[String, CreatureResponse]] = tibiaDataClient.getBoostedCreature() - val creatureEmbedFuture: Future[MessageEmbed] = boostedCreature.map { - case Right(creatureResponse) => - val boostedCreature = creatureResponse.creatures.boosted.name - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), s"The boosted creature today is:\n### ${Config.indentEmoji}${Config.levelUpEmoji} **[$boostedCreature](${creatureWikiUrl(boostedCreature)})**") - - case Left(errorMessage) => - val boostedCreature = "Podium_of_Tenacity" - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), "The boosted creature today failed to load?") - } - - // Combine both futures and send the message - val combinedFutures: Future[List[MessageEmbed]] = for { - bossEmbed <- bossEmbedFuture - creatureEmbed <- creatureEmbedFuture - } yield List(bossEmbed, creatureEmbed) - - combinedFutures.map { embeds => - - val dreamScarDaily = - dreamScar.getOrElse(world, "World not found") - - val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) - - val rashidEmbed = new EmbedBuilder() - .setDescription( - s"Today Rashid can be found in:\n### ${Config.indentEmoji}${Config.goldEmoji} **[${rashidLocation}](https://tibia.fandom.com/wiki/Rashid)**" - ) - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Rashid.gif") - .setColor(3092790) - .build() - - val dreamScarEmbed = new EmbedBuilder() - .setDescription( - s"The Dream Courts boss for **$world** is:\n### ${Config.indentEmoji}${Config.dreamScarEmoji} **[${dreamScarDaily}](https://tibia.fandom.com/wiki/Dream_Scar/Boss_of_the_Day)**" - ) - .setThumbnail(creatureImageUrl(dreamScarDaily)) - .setColor(3092790) - .build() - - // Drome Timer - val now = Instant.now() - val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) - val dromeEmbed = new EmbedBuilder() - .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") - .setColor(3092790) - .build() - - val embedsList = if (dromeShow) List(rashidEmbed, dreamScarEmbed, dromeEmbed) else List(rashidEmbed, dreamScarEmbed) - - val addRashidDreamScarEmbeds: List[MessageEmbed] = - embeds ++ embedsList - - boostedChannel - .sendMessageEmbeds(addRashidDreamScarEmbeds.asJava) - .setActionRow( - Button.primary( - "boosted list", - "Server Save Notifications" - ).withEmoji(Emoji.fromFormatted(Config.letterEmoji)) - ) - .queue( - (message: Message) => { - discordUpdateConfig( - guild, - "", - "", - "", - message.getId, - world - ) - }, - (e: Throwable) => { - logger.warn( - s"Failed to send boosted boss/creature message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}':", - e - ) - } - ) - } + postBoostedNotifications(boostedChannel, guild, world) } else { var adminCategoryCheck = guild.getCategoryById(discordConfig("admin_category")) val adminChannelCheck = guild.getTextChannelById(discordConfig("admin_channel")) @@ -1914,94 +1664,9 @@ object BotApp extends App with StrictLogging { boostedChannel.upsertPermissionOverride(guild.getPublicRole).deny(Permission.VIEW_CHANNEL).queue() discordUpdateConfig(guild, "", "", boostedChannel.getId, "", world) - val galthenEmbed = new EmbedBuilder() - galthenEmbed.setColor(3092790) - galthenEmbed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nManage your cooldowns here:") - galthenEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - boostedChannel.sendMessageEmbeds(galthenEmbed.build()).addActionRow( - Button.primary("galthen default", "Cooldowns").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) - ).queue() - - // Boosted Boss - val boostedBoss: Future[Either[String, BoostedResponse]] = tibiaDataClient.getBoostedBoss() - val bossEmbedFuture: Future[MessageEmbed] = boostedBoss.map { - case Right(boostedResponse) => - val boostedBoss = boostedResponse.boostable_bosses.boosted.name - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), s"The boosted boss today is:\n### ${Config.indentEmoji}${Config.archfoeEmoji} **[$boostedBoss](${creatureWikiUrl(boostedBoss)})**") - - case Left(errorMessage) => - val boostedBoss = "Podium_of_Vigour" - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), "The boosted boss today failed to load?") - } - - // Boosted Creature - val boostedCreature: Future[Either[String, CreatureResponse]] = tibiaDataClient.getBoostedCreature() - val creatureEmbedFuture: Future[MessageEmbed] = boostedCreature.map { - case Right(creatureResponse) => - val boostedCreature = creatureResponse.creatures.boosted.name - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), s"The boosted creature today is:\n### ${Config.indentEmoji}${Config.levelUpEmoji} **[$boostedCreature](${creatureWikiUrl(boostedCreature)})**") - - case Left(errorMessage) => - val boostedCreature = "Podium_of_Tenacity" - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), "The boosted creature today failed to load?") - } - - // Combine both futures and send the message - val combinedFutures: Future[List[MessageEmbed]] = for { - bossEmbed <- bossEmbedFuture - creatureEmbed <- creatureEmbedFuture - } yield List(bossEmbed, creatureEmbed) + postGalthenTracker(boostedChannel) - combinedFutures.map { embeds => - val dreamScarDaily = - dreamScar.getOrElse(world, "World not found") - - val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) - - val rashidEmbed = new EmbedBuilder() - .setDescription( - s"Today Rashid can be found in:\n### ${Config.indentEmoji}${Config.goldEmoji} **[${rashidLocation}](https://tibia.fandom.com/wiki/Rashid)**" - ) - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Rashid.gif") - .setColor(3092790) - .build() - - val dreamScarEmbed = new EmbedBuilder() - .setDescription( - s"The Dream Courts boss for **$world** is:\n### ${Config.indentEmoji}${Config.dreamScarEmoji} **[${dreamScarDaily}](https://tibia.fandom.com/wiki/Dream_Scar/Boss_of_the_Day)**" - ) - .setThumbnail(creatureImageUrl(dreamScarDaily)) - .setColor(3092790) - .build() - - // Drome Timer - val now = Instant.now() - val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) - val dromeEmbed = new EmbedBuilder() - .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") - .setColor(3092790) - .build() - - val embedsList = if (dromeShow) List(rashidEmbed, dreamScarEmbed, dromeEmbed) else List(rashidEmbed, dreamScarEmbed) - val addRashidDreamScarEmbeds: List[MessageEmbed] = - embeds ++ embedsList - - boostedChannel - .sendMessageEmbeds(addRashidDreamScarEmbeds.asJava) - .setActionRow( - Button.primary( - "boosted list", - "Server Save Notifications" - ).withEmoji(Emoji.fromFormatted(Config.letterEmoji)) - ) - .queue((message: Message) => { - //updateBoostedMessage(guild.getId, message.getId) - discordUpdateConfig(guild, "", "", "", message.getId, world) - }, (e: Throwable) => { - logger.warn(s"Failed to send boosted boss/creature message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}':", e) - }) - } + postBoostedNotifications(boostedChannel, guild, world) } } // check is world has already been setup @@ -2010,19 +1675,9 @@ object BotApp extends App with StrictLogging { if (worldConfigData.isEmpty) { // create the category val newCategory = guild.createCategory(world).complete() - newCategory.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - newCategory.upsertPermissionOverride(guild.getPublicRole).deny(Permission.MESSAGE_SEND).complete() + grantWorldPerms(newCategory, botRole, guild.getPublicRole) // create the channels val alliesChannel = guild.createTextChannel("📈・ᴏɴʟɪɴᴇ", newCategory).complete() - //val enemiesChannel = guild.createTextChannel("enemies", newCategory).complete() - //val neutralsChannel = guild.createTextChannel("neutrals", newCategory).complete() val deathsChannel = guild.createTextChannel("💀・ᴅᴇᴀᴛʜs", newCategory).complete() val levelsChannel = guild.createTextChannel("💖・ʟᴇᴠᴇʟs", newCategory).complete() @@ -2030,19 +1685,7 @@ object BotApp extends App with StrictLogging { val publicRole = guild.getPublicRole val channelList = List(alliesChannel, levelsChannel, deathsChannel, activityChannel) - channelList.asInstanceOf[Iterable[TextChannel]].foreach { channel => - channel.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - channel.upsertPermissionOverride(publicRole) - .deny(Permission.MESSAGE_SEND) - .complete() - } + channelList.foreach(grantWorldPerms(_, botRole, publicRole)) val notificationsConfig = discordRetrieveConfig(guild) val notificationsChannel = guild.getTextChannelById(notificationsConfig("boosted_channel")) @@ -2051,20 +1694,8 @@ object BotApp extends App with StrictLogging { if (notificationsChannel.canTalk()) { // Fullbless Role - val fullblessEmbed = new EmbedBuilder() - val fullblessEmbedText = s"The bot will poke:\n${Config.inqEmoji}<@&${fullblessRole.getId}> If an enemy fullblesses and is over level `250`\n${Config.bossEmoji}<@&${nemesisRole.getId}> If anyone dies to a rare boss\n${Config.hazardEmoji}<@&${allyPkRole.getId}> If an ally gets pked\n${Config.masslogEmoji}<@&${masslogRole.getId}> If enemies masslog on **$world**" - fullblessEmbed.setTitle(s":crossed_swords: $world :crossed_swords:", s"https://www.tibia.com/community/?subtopic=worlds&world=$world") - fullblessEmbed.setThumbnail(s"https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Phantasmal_Ooze.gif") - fullblessEmbed.setColor(3092790) - fullblessEmbed.setFooter("Add or remove yourself from the role using the buttons below:") - fullblessEmbed.setDescription(fullblessEmbedText) - notificationsChannel.sendMessageEmbeds(fullblessEmbed.build()) - .setActionRow( - Button.success("fullbless", " ").withEmoji(Emoji.fromFormatted(s"${Config.inqEmoji}")), - Button.primary("nemesis", " ").withEmoji(Emoji.fromFormatted(s"${Config.bossEmoji}")), - Button.danger("allypk", " ").withEmoji(Emoji.fromFormatted(s"${Config.hazardEmoji}")), - Button.secondary("masslog", " ").withEmoji(Emoji.fromFormatted(s"${Config.masslogEmoji}")) - ) + notificationsChannel.sendMessageEmbeds(fullblessRoleEmbed(world, fullblessRole.getId, nemesisRole.getId, allyPkRole.getId, masslogRole.getId, 250)) + .setActionRow(fullblessRoleButtons: _*) .queue() } } @@ -2077,64 +1708,49 @@ object BotApp extends App with StrictLogging { val categoryId = newCategory.getId val activityId = activityChannel.getId - // post initial embed in levels channel - val levelsTextChannel: TextChannel = guild.getTextChannelById(levelsId) - if (levelsTextChannel != null) { - val levelsEmbed = new EmbedBuilder() - levelsEmbed.setDescription(s":speech_balloon: This channel shows levels that have been gained on this world.\n\nYou can filter what appears in this channel using the **`/levels filter`** command.") - levelsEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Sign_(Library).gif") - levelsEmbed.setColor(3092790) - levelsTextChannel.sendMessageEmbeds(levelsEmbed.build()).queue() - } - - // post initial embed in deaths channel - val deathsTextChannel: TextChannel = guild.getTextChannelById(deathsId) - if (deathsTextChannel != null) { - val deathsEmbed = new EmbedBuilder() - deathsEmbed.setDescription(s":speech_balloon: This channel shows deaths that occur on this world.\n\nYou can filter what appears in this channel using the **`/deaths filter`** command.") - deathsEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Sign_(Library).gif") - deathsEmbed.setColor(3092790) - deathsTextChannel.sendMessageEmbeds(deathsEmbed.build()).queue() - } - - // post initial embed in activity channel - val activityTextChannel: TextChannel = guild.getTextChannelById(activityId) - if (activityTextChannel != null) { - val activityEmbed = new EmbedBuilder() - activityEmbed.setDescription(s":speech_balloon: This channel shows change activity for *allied* or *enemy* players.\n\nIt will show events when a players **joins** or **leaves** one of these tracked guilds or **changes their name**.") - activityEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Sign_(Library).gif") - activityEmbed.setColor(3092790) - activityTextChannel.sendMessageEmbeds(activityEmbed.build()).queue() - } + // post initial embeds in the levels / deaths / activity channels + postChannelIntro(guild.getTextChannelById(levelsId), s":speech_balloon: This channel shows levels that have been gained on this world.\n\nYou can filter what appears in this channel using the **`/levels filter`** command.") + postChannelIntro(guild.getTextChannelById(deathsId), s":speech_balloon: This channel shows deaths that occur on this world.\n\nYou can filter what appears in this channel using the **`/deaths filter`** command.") + postChannelIntro(guild.getTextChannelById(activityId), s":speech_balloon: This channel shows change activity for *allied* or *enemy* players.\n\nIt will show events when a players **joins** or **leaves** one of these tracked guilds or **changes their name**.") // update the database worldCreateConfig(guild, world, alliesId, enemiesId, neutralsId, levelsId, deathsId, categoryId, fullblessRole.getId, nemesisRole.getId, allyPkRole.getId, masslogRole.getId, "0", "0", activityId) startBot(Some(guild), Some(world)) + + // audit the setup in the command-log channel, matching /repair and /remove + val adminChannel = guild.getTextChannelById(discordRetrieveConfig(guild).getOrElse("admin_channel", "0")) + postAdminLog(adminChannel, s"<@${event.getUser.getId}> has run `/setup` for the world **$world** and created its channels.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Hammer.gif") + s":gear: The channels for **$world** have been configured successfully.\n⚠️ *You should probably mute the <#$levelsId> channel*" } else { // channels already exist logger.info(s"The channels have already been setup on '${guild.getName} - ${guild.getId}'.") s"${Config.noEmoji} The channels for **$world** have already been setup.\nUse `/repair` if you need to recreate channels for **$world** that you have deleted." } - } else { - s"${Config.noEmoji} This is not a valid World on Tibia." + } else { + s"${Config.noEmoji} This is not a valid World on Tibia." + } + } catch { + case e: net.dv8tion.jda.api.exceptions.PermissionException => + logger.warn(s"/setup of '$world' on guild '${event.getGuild.getId}' aborted on a missing permission: ${e.getMessage}") + s"${Config.noEmoji} I couldn't finish setting up **$world** because I'm missing a required permission. Grant me **Manage Roles**, **Manage Channels** and **Manage Permissions**, then run `/repair $world`." + case e: Exception => + logger.warn(s"/setup of '$world' on guild '${event.getGuild.getId}' failed before completing", e) + s"${Config.noEmoji} Something went wrong while setting up **$world**, so it may be only partially configured. Wait a moment, then run `/repair $world` (or `/setup` again) to finish." } // embed reply - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedText) - .build() + presentation.Embeds.response(embedText) } def detectHunted(event: SlashCommandInteractionEvent): MessageEmbed = { val options: Map[String, String] = event.getInteraction.getOptions.asScala.map(option => option.getName.toLowerCase() -> option.getAsString.trim()).toMap val worldOption: String = options.getOrElse("world", "") val settingOption: String = options.getOrElse("option", "") - val worldFormal = worldOption.toLowerCase().capitalize.trim + val worldFormal = domain.WorldName.formal(worldOption).trim val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) val cache = worldsData.getOrElse(guild.getId, List()).filter(w => w.name.toLowerCase() == worldOption.toLowerCase()) val detectSetting = cache.headOption.map(_.detectHunteds).getOrElse(null) if (detectSetting != null) { @@ -2151,22 +1767,13 @@ object BotApp extends App with StrictLogging { w } } - worldsData = worldsData + (guild.getId -> modifiedWorlds) + modifyWorldsData(_ + (guild.getId -> modifiedWorlds)) detectHuntedsToDatabase(guild, worldFormal, settingOption) val discordConfig = discordRetrieveConfig(guild) val adminChannelId = if (discordConfig.nonEmpty) discordConfig("admin_channel") else "" val adminChannel: TextChannel = guild.getTextChannelById(adminChannelId) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> set **automatic enemy detection** to **$settingOption** for the world **$worldFormal**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Armillary_Sphere_(TibiaMaps).gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> set **automatic enemy detection** to **$settingOption** for the world **$worldFormal**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Armillary_Sphere_(TibiaMaps).gif") embedBuild.setDescription(s":gear: **Automatic enemy detection** is now set to **$settingOption** for the world **$worldFormal**.") embedBuild.build() @@ -2178,15 +1785,15 @@ object BotApp extends App with StrictLogging { } private def detectHuntedsToDatabase(guild: Guild, world: String, detectSetting: String): Unit = - worldConfigRepository.updateWorldString(guild.getId, world.toLowerCase().capitalize, "detect_hunteds", detectSetting) + worldConfigRepository.updateWorldString(guild.getId, domain.WorldName.formal(world), "detect_hunteds", detectSetting) def deathsLevelsHideShow(event: SlashCommandInteractionEvent, world: String, setting: String, playerType: String, channelType: String): MessageEmbed = { - val worldFormal = world.toLowerCase().capitalize + val worldFormal = domain.WorldName.formal(world) val guild = event.getGuild val commandUser = event.getUser.getId val settingType = if (setting == "show") "true" else "false" val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) val thumbnailIcon = playerType match { case "allies" => "Angel_Statue" case "neutrals" => "Guardian_Statue" @@ -2249,22 +1856,13 @@ object BotApp extends App with StrictLogging { w } } - worldsData = worldsData + (guild.getId -> modifiedWorlds) + modifyWorldsData(_ + (guild.getId -> modifiedWorlds)) deathsLevelsHideShowToDatabase(guild, world, settingType, playerType, channelType) val discordConfig = discordRetrieveConfig(guild) val adminChannelId = if (discordConfig.nonEmpty) discordConfig("admin_channel") else "" val adminChannel: TextChannel = guild.getTextChannelById(adminChannelId) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> set the **$channelType** channel to **$setting $playerType** for the world **$worldFormal**.") - adminEmbed.setThumbnail(s"https://www.tibiawiki.com.br/wiki/Special:Redirect/file/$thumbnailIcon.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> set the **$channelType** channel to **$setting $playerType** for the world **$worldFormal**.", s"https://www.tibiawiki.com.br/wiki/Special:Redirect/file/$thumbnailIcon.gif") embedBuild.setDescription(s":gear: The **$channelType** channel is now set to **$setting $playerType** for the world **$worldFormal**.") embedBuild.build() @@ -2280,11 +1878,11 @@ object BotApp extends App with StrictLogging { val worldOption: String = options.getOrElse("world", "") val settingOption: String = options.getOrElse("option", "") val settingType = if (settingOption == "show") "true" else "false" - val worldFormal = worldOption.toLowerCase().capitalize.trim + val worldFormal = domain.WorldName.formal(worldOption).trim val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) val cache = worldsData.getOrElse(guild.getId, List()).filter(w => w.name.toLowerCase() == worldOption.toLowerCase()) val detectSetting = cache.headOption.map(_.exivaList).getOrElse(null) if (detectSetting != null) { @@ -2301,22 +1899,13 @@ object BotApp extends App with StrictLogging { w } } - worldsData = worldsData + (guild.getId -> modifiedWorlds) + modifyWorldsData(_ + (guild.getId -> modifiedWorlds)) exivaListToDatabase(guild, worldFormal, settingType) val discordConfig = discordRetrieveConfig(guild) val adminChannelId = if (discordConfig.nonEmpty) discordConfig("admin_channel") else "" val adminChannel: TextChannel = guild.getTextChannelById(adminChannelId) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> set **exiva list on deaths** to **$settingOption** for the world **$worldFormal**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Find_Person.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> set **exiva list on deaths** to **$settingOption** for the world **$worldFormal**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Find_Person.gif") embedBuild.setDescription(s":gear: **exiva list on deaths** is now set to **$settingOption** for the world **$worldFormal**.") embedBuild.build() @@ -2328,15 +1917,15 @@ object BotApp extends App with StrictLogging { } private def exivaListToDatabase(guild: Guild, world: String, detectSetting: String): Unit = - worldConfigRepository.updateWorldString(guild.getId, world.toLowerCase().capitalize, "exiva_list", detectSetting) + worldConfigRepository.updateWorldString(guild.getId, domain.WorldName.formal(world), "exiva_list", detectSetting) def onlineListConfig(event: SlashCommandInteractionEvent, world: String, setting: String): MessageEmbed = { - val worldFormal = world.toLowerCase().capitalize + val worldFormal = domain.WorldName.formal(world) val guild = event.getGuild val commandUser = event.getUser.getId val settingType = if (setting == "combine") "true" else "false" val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) val thumbnailIcon = "Blackboard" val cache = worldsData.getOrElse(guild.getId, List()).filter(w => w.name.toLowerCase() == world.toLowerCase()) val existingSetting = cache.headOption.map(_.onlineCombined) @@ -2409,15 +1998,7 @@ object BotApp extends App with StrictLogging { if (category == null) { // create the category val newCategory = guild.createCategory(worldFormal).complete() - newCategory.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - newCategory.upsertPermissionOverride(publicRole).deny(Permission.MESSAGE_SEND).complete() + grantWorldPerms(newCategory, botRole, publicRole) category = newCategory worldRepairConfig(guild, worldFormal, "category", newCategory.getId) @@ -2431,7 +2012,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } // create the online channel @@ -2447,20 +2028,10 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // apply permissions to created channel - recreateAlliesChannel.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - recreateAlliesChannel.upsertPermissionOverride(publicRole) - .deny(Permission.MESSAGE_SEND) - .complete() + grantWorldPerms(recreateAlliesChannel, botRole, publicRole) disclaimer += s"\n- *You may want to move the new <#${recreateAlliesChannel.getId}> channel.*" } catch { case ex: Throwable => logger.info(s"Failed to create category or online channels for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}' while combining the online list", ex) @@ -2479,15 +2050,7 @@ object BotApp extends App with StrictLogging { if (category == null) { // create the category val newCategory = guild.createCategory(worldFormal).complete() - newCategory.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - newCategory.upsertPermissionOverride(publicRole).deny(Permission.MESSAGE_SEND).complete() + grantWorldPerms(newCategory, botRole, publicRole) category = newCategory worldRepairConfig(guild, worldFormal, "category", newCategory.getId) @@ -2501,7 +2064,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } else { try { @@ -2540,7 +2103,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } disclaimer += s"\n- *The channel <#${recreateAlliesChannel.getId}> has been recreated (you may want to move it).*" @@ -2558,7 +2121,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } disclaimer += s"\n- *The channel <#${recreateEnemiesChannel.getId}> has been recreated (you may want to move it).*" } @@ -2577,24 +2140,14 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } disclaimer += s"\n- *The channel <#${recreateNeutralsChannel.getId}> has been recreated (you may want to move it).*" } // apply required permissions to the new channel(s) if (channelList.nonEmpty) { - channelList.foreach { case (channel, webhooks) => - channel.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - channel.upsertPermissionOverride(publicRole) - .deny(Permission.MESSAGE_SEND) - .complete() + channelList.foreach { case (channel, _) => + grantWorldPerms(channel, botRole, publicRole) } } } catch { @@ -2611,22 +2164,13 @@ object BotApp extends App with StrictLogging { } } - worldsData = worldsData + (guild.getId -> modifiedWorlds) + modifyWorldsData(_ + (guild.getId -> modifiedWorlds)) onlineListConfigToDatabase(guild, world, settingType) val discordConfig = discordRetrieveConfig(guild) val adminChannelId = if (discordConfig.nonEmpty) discordConfig("admin_channel") else "" val adminChannel: TextChannel = guild.getTextChannelById(adminChannelId) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> set the online list channel to **$setting** for the world **$worldFormal**.\n$disclaimer") - adminEmbed.setThumbnail(s"https://www.tibiawiki.com.br/wiki/Special:Redirect/file/$thumbnailIcon.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> set the online list channel to **$setting** for the world **$worldFormal**.\n$disclaimer", s"https://www.tibiawiki.com.br/wiki/Special:Redirect/file/$thumbnailIcon.gif") embedBuild.setDescription(s":gear: The online list channel is now set to **$setting** for the world **$worldFormal**.\n$disclaimer") embedBuild.build() @@ -2638,7 +2182,7 @@ object BotApp extends App with StrictLogging { } private def onlineListConfigToDatabase(guild: Guild, world: String, setting: String): Unit = - worldConfigRepository.updateWorldString(guild.getId, world.toLowerCase().capitalize, "online_combined", setting) + worldConfigRepository.updateWorldString(guild.getId, domain.WorldName.formal(world), "online_combined", setting) private def customSortConfig(guild: Guild, query: String): List[CustomSort] = customSortRepository.getAll(guild.getId) @@ -2650,7 +2194,7 @@ object BotApp extends App with StrictLogging { val labelCapital = label.capitalize val guild = event.getGuild val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) // default embed content var embedText = s"${Config.noEmoji} An error occurred while running the `/online` command" if (checkConfigDatabase(guild)) { @@ -2675,22 +2219,12 @@ object BotApp extends App with StrictLogging { val emojiDupe = emojiDupeOption.map(_.emoji).getOrElse(emoji) // add guild to hunted list and database - // case class CustomSort(type: String, name: String, emoji: String, label: String) - customSortData = customSortData + (guildId -> (CustomSort(guildOrPlayer, guildName, labelCapital, emojiDupe) :: customSortData.getOrElse(guildId, List()))) + modifyCustomSortData(m => m + (guildId -> (CustomSort(guildOrPlayer, guildName, labelCapital, emojiDupe) :: m.getOrElse(guildId, List())))) addOnlineListCategoryToDatabase(guild, guildOrPlayer, guildName, labelCapital, emojiDupe) embedText = s":gear: The guild **[$guildName](${guildUrl(guildName)})** has been tagged with: $emojiDupe **$labelCapital** $emojiDupe" // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> tagged the guild **[$guildName](${guildUrl(guildName)})** with: $emojiDupe **$labelCapital** $emojiDupe") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> tagged the guild **[$guildName](${guildUrl(guildName)})** with: $emojiDupe **$labelCapital** $emojiDupe", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") embedBuild.setDescription(embedText) callback(embedBuild.build()) @@ -2710,14 +2244,7 @@ object BotApp extends App with StrictLogging { } } else if (guildOrPlayer == "player") { // command run with 'player' // run api against player - val playerCheck: Future[Either[String, CharacterResponse]] = tibiaDataClient.getCharacter(nameLower) - playerCheck.map { - case Right(charResponse) => - val character = charResponse.character.character - (character.name, character.world, vocEmoji(charResponse), character.level.toInt) - case Left(errorMessage) => - ("", "", s"${Config.noEmoji}", 0) - }.map { case (playerName, world, vocation, level) => + fetchPlayerSummary(nameLower).map { case (playerName, world, vocation, level) => if (playerName != "") { if (!customSortData.getOrElse(guildId, List()).exists(g => g.entityType == "player" && g.name.toLowerCase == nameLower)) { @@ -2725,21 +2252,12 @@ object BotApp extends App with StrictLogging { val emojiDupe = emojiDupeOption.map(_.emoji).getOrElse(emoji) // add player to hunted list and database - customSortData = customSortData + (guildId -> (CustomSort(guildOrPlayer, playerName, labelCapital, emojiDupe) :: customSortData.getOrElse(guildId, List()))) + modifyCustomSortData(m => m + (guildId -> (CustomSort(guildOrPlayer, playerName, labelCapital, emojiDupe) :: m.getOrElse(guildId, List())))) addOnlineListCategoryToDatabase(guild, guildOrPlayer, playerName, labelCapital, emojiDupe) embedText = s":gear: The player **[$playerName](${charUrl(playerName)})** has been tagged with: $emojiDupe **$labelCapital** $emojiDupe" // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> tagged the player\n$vocation **$level** — **[$playerName](${charUrl(playerName)})**\nwith: $emojiDupe **$labelCapital** $emojiDupe") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> tagged the player\n$vocation **$level** — **[$playerName](${charUrl(playerName)})**\nwith: $emojiDupe **$labelCapital** $emojiDupe", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") embedBuild.setDescription(embedText) callback(embedBuild.build()) @@ -2774,7 +2292,7 @@ object BotApp extends App with StrictLogging { val nameLower = name.toLowerCase val guild = event.getGuild val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) // default embed content var embedText = s"${Config.noEmoji} An error occurred while running the `/online` command" if (checkConfigDatabase(guild)) { @@ -2785,22 +2303,13 @@ object BotApp extends App with StrictLogging { if (guildOrPlayer == "guild") { // command run with 'guild' if (customSortData.getOrElse(guildId, List()).exists(g => g.entityType == "guild" && g.name.toLowerCase == nameLower)) { - customSortData = customSortData + (guildId -> customSortData.getOrElse(guildId, List()).filterNot(entry => entry.entityType == "guild" && entry.name.equalsIgnoreCase(nameLower))) + modifyCustomSortData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(entry => entry.entityType == "guild" && entry.name.equalsIgnoreCase(nameLower)))) removeOnlineListCategoryFromDatabase(guild, guildOrPlayer, nameLower) embedText = s":gear: The guild **$nameLower** had its tag removed." // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> removed the guild **$nameLower** from custom tagging.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> removed the guild **$nameLower** from custom tagging.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") } else { embedText = s"${Config.noEmoji} The guild **$nameLower** does not have a tag assigned." @@ -2808,22 +2317,13 @@ object BotApp extends App with StrictLogging { } else if (guildOrPlayer == "player") { // command run with 'player' if (customSortData.getOrElse(guildId, List()).exists(g => g.entityType == "player" && g.name.toLowerCase == nameLower)) { - customSortData = customSortData + (guildId -> customSortData.getOrElse(guildId, List()).filterNot(entry => entry.entityType == "player" && entry.name.equalsIgnoreCase(nameLower))) + modifyCustomSortData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(entry => entry.entityType == "player" && entry.name.equalsIgnoreCase(nameLower)))) removeOnlineListCategoryFromDatabase(guild, guildOrPlayer, nameLower) embedText = s":gear: The player **$nameLower** had its tag removed." // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> removed the player **$nameLower** from custom tagging.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> removed the player **$nameLower** from custom tagging.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") } else { embedText = s"${Config.noEmoji} The player **$nameLower** already has a tag assigned." } @@ -2844,7 +2344,7 @@ object BotApp extends App with StrictLogging { val labelLower = label.toLowerCase val guild = event.getGuild val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) // default embed content var embedText = s"${Config.noEmoji} An error occurred while running the `/online` command" if (checkConfigDatabase(guild)) { @@ -2854,22 +2354,13 @@ object BotApp extends App with StrictLogging { val adminChannel = guild.getTextChannelById(discordInfo("admin_channel")) if (customSortData.getOrElse(guildId, List()).exists(g => g.label.toLowerCase == labelLower)) { - customSortData = customSortData + (guildId -> customSortData.getOrElse(guildId, List()).filterNot(entry => entry.label.equalsIgnoreCase(labelLower))) + modifyCustomSortData(m => m + (guildId -> m.getOrElse(guildId, List()).filterNot(entry => entry.label.equalsIgnoreCase(labelLower)))) clearOnlineListCategoryFromDatabase(guild, labelLower) embedText = s":gear: The tag **$labelLower** has been cleared." // send embed to admin channel - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> cleared everyone from the tag **$labelLower**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> cleared everyone from the tag **$labelLower**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Library_Ticket.gif") } else { embedText = s"${Config.noEmoji} The tag **$labelLower** does not exist." @@ -2896,21 +2387,21 @@ object BotApp extends App with StrictLogging { if (guildTags.isEmpty) { val interimEmbed = new EmbedBuilder() interimEmbed.setDescription(s"${Config.noEmoji} You do not have any custom tags.") - interimEmbed.setColor(3092790) + interimEmbed.setColor(BrandColor) embedBuffer += interimEmbed.build() } else { val groupedTags: Map[(String, String), List[CustomSort]] = guildTags.groupBy(tag => (tag.label, tag.emoji)) val groupList = ListBuffer[String]() val infoEmbed = new EmbedBuilder() - infoEmbed.setDescription(s":speech_balloon: Tags are for *players* or *guilds* that arn't in your **allies** or **enemies** lists.\n\n- Their deaths will be highlighted **yellow**.\n- If you use the **`/online list combine`** version of the online list they will appear under their own category.") + infoEmbed.setDescription(s":speech_balloon: Tags are for *players* or *guilds* that aren't in your **allies** or **enemies** lists.\n\n- Their deaths will be highlighted **yellow**.\n- If you use the **`/online list combine`** version of the online list they will appear under their own category.") infoEmbed.setColor(14397256) embedBuffer += infoEmbed.build() // guildTags contains data groupedTags.foreach { case ((label, emoji), tags) => groupList += s"\n$emoji **$label** $emoji" - val tagInformation = tags.map { customSort => + tags.foreach { customSort => groupList += s"- ${customSort.name} *(${customSort.entityType})*" } } @@ -2939,7 +2430,7 @@ object BotApp extends App with StrictLogging { } private def deathsLevelsHideShowToDatabase(guild: Guild, world: String, setting: String, playerType: String, channelType: String): Unit = { - val worldFormal = world.toLowerCase().capitalize + val worldFormal = domain.WorldName.formal(world) val tablePrefix = playerType match { case "allies" => "show_allies_" case "neutrals" => "show_neutral_" @@ -2951,11 +2442,11 @@ object BotApp extends App with StrictLogging { } def fullblessLevel(event: SlashCommandInteractionEvent, world: String, level: Int): MessageEmbed = { - val worldFormal = world.toLowerCase().capitalize + val worldFormal = domain.WorldName.formal(world) val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) val cache = worldsData.getOrElse(guild.getId, List()).filter(w => w.name.toLowerCase() == world.toLowerCase()) val levelSetting = cache.headOption.map(_.fullblessLevel).getOrElse(null) if (levelSetting != null) { @@ -2972,7 +2463,7 @@ object BotApp extends App with StrictLogging { w } } - worldsData = worldsData + (guild.getId -> modifiedWorlds) + modifyWorldsData(_ + (guild.getId -> modifiedWorlds)) fullblessLevelToDatabase(guild, worldFormal, level) // edit the fullblesschannel embeds @@ -2992,34 +2483,13 @@ object BotApp extends App with StrictLogging { val masslogRole = worldConfigData("masslog_role") // Fullbless Role - val fullblessEmbed = new EmbedBuilder() - val fullblessEmbedText = s"The bot will poke:\n${Config.inqEmoji}<@&${fullblessRole}> If an enemy fullblesses and is over level `${level}`\n${Config.bossEmoji}<@&${nemesisRole}> If anyone dies to a rare boss\n${Config.hazardEmoji}<@&${allyPkRole}> If an ally gets pked\n${Config.masslogEmoji}<@&${masslogRole}> If enemies masslog on **$worldFormal**" - fullblessEmbed.setTitle(s":crossed_swords: $worldFormal :crossed_swords:", s"https://www.tibia.com/community/?subtopic=worlds&world=$worldFormal") - fullblessEmbed.setThumbnail(s"https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Phantasmal_Ooze.gif") - fullblessEmbed.setColor(3092790) - fullblessEmbed.setFooter("Add or remove yourself from the role using the buttons below:") - fullblessEmbed.setDescription(fullblessEmbedText) - message.editMessageEmbeds(fullblessEmbed.build()) - .setActionRow( - Button.success("fullbless", " ").withEmoji(Emoji.fromFormatted(s"${Config.inqEmoji}")), - Button.primary("nemesis", " ").withEmoji(Emoji.fromFormatted(s"${Config.bossEmoji}")), - Button.danger("allypk", " ").withEmoji(Emoji.fromFormatted(s"${Config.hazardEmoji}")), - Button.secondary("masslog", " ").withEmoji(Emoji.fromFormatted(s"${Config.masslogEmoji}")) - ) + message.editMessageEmbeds(fullblessRoleEmbed(worldFormal, fullblessRole, nemesisRole, allyPkRole, masslogRole, level)) + .setActionRow(fullblessRoleButtons: _*) .queue() } } } - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> changed the level to poke for **enemy fullblesses**\nto **$level** for the world **$worldFormal**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Amulet_of_Loss.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> changed the level to poke for **enemy fullblesses**\nto **$level** for the world **$worldFormal**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Amulet_of_Loss.gif") embedBuild.setDescription(s":gear: The level to poke for **enemy fullblesses**\nis now set to **$level** for the world **$worldFormal**.") embedBuild.build() @@ -3031,9 +2501,9 @@ object BotApp extends App with StrictLogging { } def leaderboards(event: SlashCommandInteractionEvent, world: String, callback: MessageEmbed => Unit): Unit = { - val worldFormal = world.toLowerCase.capitalize + val worldFormal = domain.WorldName.formal(world) val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) if (Config.worldList.exists(_.equalsIgnoreCase(world))) { // Get the high scores @@ -3063,11 +2533,11 @@ object BotApp extends App with StrictLogging { def repairChannel(event: SlashCommandInteractionEvent, world: String): MessageEmbed = { - val worldFormal = world.toLowerCase().capitalize + val worldFormal = domain.WorldName.formal(world) val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) embedBuild.setDescription(s"${Config.noEmoji} No action was taken as all channels for **$worldFormal** still exist.") val cache: Option[List[Worlds]] = worldsData.get(guild.getId) match { case Some(worlds) => @@ -3076,6 +2546,10 @@ object BotApp extends App with StrictLogging { else None case None => None } + // Like /setup, this recreates roles/channels/overrides through blocking + // .complete() calls; guard so a mid-way failure reports cleanly instead of + // hanging the interaction with channels left half-recreated. + try { if (cache.isDefined) { // get the bots main roles val botRole = guild.getBotRole @@ -3170,7 +2644,7 @@ object BotApp extends App with StrictLogging { val fullblessEmbedText = s"The bot will poke:\n${Config.inqEmoji}<@&${fullblessRole.getId}> If an enemy fullblesses and is over level `${fullblessLevel}`\n${Config.bossEmoji}<@&${nemesisRole.getId}> If anyone dies to a rare boss\n${Config.hazardEmoji}<@&${allyPkRole.getId}> If an ally gets pked\n${Config.masslogEmoji}<@&${masslogRole.getId}> If enemies masslog on **$worldFormal**" fullblessEmbed.setTitle(s":crossed_swords: $worldFormal :crossed_swords:", s"https://www.tibia.com/community/?subtopic=worlds&world=$worldFormal") fullblessEmbed.setThumbnail(s"https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Phantasmal_Ooze.gif") - fullblessEmbed.setColor(3092790) + fullblessEmbed.setColor(BrandColor) fullblessEmbed.setFooter("Add or remove yourself from the role using the buttons below:") fullblessEmbed.setDescription(fullblessEmbedText) boostedChannel.sendMessageEmbeds(fullblessEmbed.build()) @@ -3198,7 +2672,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // update the record in worldsData if (worldsData.contains(guild.getId)) { @@ -3210,7 +2684,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // update the record in worldsData if (worldsData.contains(guild.getId)) { @@ -3222,7 +2696,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // update the record in worldsData if (worldsData.contains(guild.getId)) { @@ -3234,7 +2708,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } embedBuild.setDescription(s"${Config.yesEmoji} Missing notification message was recreated.") } @@ -3244,89 +2718,7 @@ object BotApp extends App with StrictLogging { boostedMessageAction.complete() } catch { case e: Throwable => - // Boosted Boss - val boostedBoss: Future[Either[String, BoostedResponse]] = tibiaDataClient.getBoostedBoss() - val bossEmbedFuture: Future[MessageEmbed] = boostedBoss.map { - case Right(boostedResponse) => - val boostedBoss = boostedResponse.boostable_bosses.boosted.name - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), s"The boosted boss today is:\n### ${Config.indentEmoji}${Config.archfoeEmoji} **[$boostedBoss](${creatureWikiUrl(boostedBoss)})**") - - case Left(errorMessage) => - val boostedBoss = "Podium_of_Vigour" - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), "The boosted boss today failed to load?") - } - - // Boosted Creature - val boostedCreature: Future[Either[String, CreatureResponse]] = tibiaDataClient.getBoostedCreature() - val creatureEmbedFuture: Future[MessageEmbed] = boostedCreature.map { - case Right(creatureResponse) => - val boostedCreature = creatureResponse.creatures.boosted.name - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), s"The boosted creature today is:\n### ${Config.indentEmoji}${Config.levelUpEmoji} **[$boostedCreature](${creatureWikiUrl(boostedCreature)})**") - - case Left(errorMessage) => - val boostedCreature = "Podium_of_Tenacity" - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), "The boosted creature today failed to load?") - } - - // Combine both futures and send the message - val combinedFutures: Future[List[MessageEmbed]] = for { - bossEmbed <- bossEmbedFuture - creatureEmbed <- creatureEmbedFuture - } yield List(bossEmbed, creatureEmbed) - - combinedFutures.map { embeds => - val dreamScarDaily = dreamScar.getOrElse(worldFormal, "World not found") - val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) - - val rashidEmbed = new EmbedBuilder() - .setDescription( - s"Today Rashid can be found in:\n### ${Config.indentEmoji}${Config.goldEmoji} **[${rashidLocation}](https://tibia.fandom.com/wiki/Rashid)**" - ) - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Rashid.gif") - .setColor(3092790) - .build() - - val dreamScarEmbed = new EmbedBuilder() - .setDescription( - s"The Dream Courts boss for **$worldFormal** is:\n### ${Config.indentEmoji}${Config.dreamScarEmoji} **[${dreamScarDaily}](https://tibia.fandom.com/wiki/Dream_Scar/Boss_of_the_Day)**" - ) - .setThumbnail(creatureImageUrl(dreamScarDaily)) - .setColor(3092790) - .build() - - // Drome Timer - val now = Instant.now() - val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) - val dromeEmbed = new EmbedBuilder() - .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") - .setColor(3092790) - .build() - - val embedsList = if (dromeShow) List(rashidEmbed, dreamScarEmbed, dromeEmbed) else List(rashidEmbed, dreamScarEmbed) - val finalEmbeds = - embeds ++ embedsList - - boostedChannel - .sendMessageEmbeds(finalEmbeds.asJava) - .setActionRow( - Button.primary( - "boosted list", - "Server Save Notifications" - ).withEmoji(Emoji.fromFormatted(Config.letterEmoji)) - ) - .queue( - (message: Message) => { - discordUpdateConfig(guild, "", "", "", message.getId, worldFormal) - }, - (e: Throwable) => { - logger.warn( - s"Failed to send boosted boss/creature message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}':", - e - ) - } - ) - } + postBoostedNotifications(boostedChannel, guild, worldFormal) } } } else { @@ -3338,15 +2730,7 @@ object BotApp extends App with StrictLogging { if (category == null) { // category has been deleted: // create the category val newCategory = guild.createCategory(world).complete() - newCategory.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - newCategory.upsertPermissionOverride(guild.getPublicRole).deny(Permission.MESSAGE_SEND).complete() + grantWorldPerms(newCategory, botRole, guild.getPublicRole) category = newCategory worldRepairConfig(guild, worldFormal, "category", newCategory.getId) @@ -3360,7 +2744,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } val channelList = ListBuffer[(TextChannel, Boolean)]() @@ -3380,7 +2764,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } if (enemiesChannel == null && onlineCombinedVal == "false") { @@ -3397,7 +2781,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } if (neutralsChannel == null && onlineCombinedVal == "false") { @@ -3414,7 +2798,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } if (deathsChannel == null) { @@ -3431,7 +2815,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } if (levelsChannel == null) { @@ -3448,7 +2832,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } if (activityChannel == null) { @@ -3465,16 +2849,10 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // post initial embed in activity channel - if (recreateActivityChannel != null) { - val activityEmbed = new EmbedBuilder() - activityEmbed.setDescription(s":speech_balloon: This channel shows change activity for *allied* or *enemy* players.\n\nIt will show events when a players **joins** or **leaves** one of these tracked guilds or **changes their name**.") - activityEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Sign_(Library).gif") - activityEmbed.setColor(3092790) - recreateActivityChannel.sendMessageEmbeds(activityEmbed.build()).queue() - } + postChannelIntro(recreateActivityChannel, s":speech_balloon: This channel shows change activity for *allied* or *enemy* players.\n\nIt will show events when a players **joins** or **leaves** one of these tracked guilds or **changes their name**.") } if (boostedChannel == null) { @@ -3493,6 +2871,7 @@ object BotApp extends App with StrictLogging { // restrict the channel so only roles with Permission.MANAGE_MESSAGES can write to the channels newBoostedChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_SEND).complete() newBoostedChannel.upsertPermissionOverride(botRole).grant(Permission.VIEW_CHANNEL).complete() + newBoostedChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_EMBED_LINKS).complete() newBoostedChannel.upsertPermissionOverride(guild.getPublicRole).grant(Permission.VIEW_CHANNEL).queue() boostedChannel = newBoostedChannel // update db & cache @@ -3510,95 +2889,11 @@ object BotApp extends App with StrictLogging { .deny(Permission.MESSAGE_SEND) .complete() - val galthenEmbed = new EmbedBuilder() - galthenEmbed.setColor(3092790) - galthenEmbed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nManage your cooldowns here:") - galthenEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - boostedChannel.sendMessageEmbeds(galthenEmbed.build()).addActionRow( - Button.primary("galthen default", "Cooldowns").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) - ).queue() - - // Boosted Boss - val boostedBoss: Future[Either[String, BoostedResponse]] = tibiaDataClient.getBoostedBoss() - val bossEmbedFuture: Future[MessageEmbed] = boostedBoss.map { - case Right(boostedResponse) => - val boostedBoss = boostedResponse.boostable_bosses.boosted.name - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), s"The boosted boss today is:\n### ${Config.indentEmoji}${Config.archfoeEmoji} **[$boostedBoss](${creatureWikiUrl(boostedBoss)})**") - - case Left(errorMessage) => - val boostedBoss = "Podium_of_Vigour" - createBoostedEmbed("Boosted Boss", Config.bossEmoji, "https://www.tibia.com/library/?subtopic=boostablebosses", creatureImageUrl(boostedBoss), "The boosted boss today failed to load?") - } + postGalthenTracker(boostedChannel) - // Boosted Creature - val boostedCreature: Future[Either[String, CreatureResponse]] = tibiaDataClient.getBoostedCreature() - val creatureEmbedFuture: Future[MessageEmbed] = boostedCreature.map { - case Right(creatureResponse) => - val boostedCreature = creatureResponse.creatures.boosted.name - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), s"The boosted creature today is:\n### ${Config.indentEmoji}${Config.levelUpEmoji} **[$boostedCreature](${creatureWikiUrl(boostedCreature)})**") - - case Left(errorMessage) => - val boostedCreature = "Podium_of_Tenacity" - createBoostedEmbed("Boosted Creature", Config.creatureEmoji, "https://www.tibia.com/library/?subtopic=creatures", creatureImageUrl(boostedCreature), "The boosted creature today failed to load?") - } - - // Combine both futures and send the message - val combinedFutures: Future[List[MessageEmbed]] = for { - bossEmbed <- bossEmbedFuture - creatureEmbed <- creatureEmbedFuture - } yield List(bossEmbed, creatureEmbed) - - combinedFutures.map { embeds => - - val dreamScarDaily = - dreamScar.getOrElse(world, "World not found") - - val rashidLocation = ServerSaveSchedule.rashidLocation(ZonedDateTime.now(ZoneId.of("Europe/Berlin")).minusHours(10).getDayOfWeek) - - val rashidEmbed = new EmbedBuilder() - .setDescription( - s"Today Rashid can be found in:\n### ${Config.indentEmoji}${Config.goldEmoji} **[${rashidLocation}](https://tibia.fandom.com/wiki/Rashid)**" - ) - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Rashid.gif") - .setColor(3092790) - .build() - - val dreamScarEmbed = new EmbedBuilder() - .setDescription( - s"The Dream Courts boss for **$world** is:\n### ${Config.indentEmoji}${Config.dreamScarEmoji} **[${dreamScarDaily}](https://tibia.fandom.com/wiki/Dream_Scar/Boss_of_the_Day)**" - ) - .setThumbnail(creatureImageUrl(dreamScarDaily)) - .setColor(3092790) - .build() - - // Drome Timer - val now = Instant.now() - val dromeShow = ServerSaveSchedule.shouldShowDrome(now, dromeTime) - val dromeEmbed = new EmbedBuilder() - .setDescription(s"The current Drome cycle will end:\n### ${Config.indentEmoji}${Config.dromeEmoji} ${TimeFormat.RELATIVE.format(dromeTime)}") - .setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Phant.gif") - .setColor(3092790) - .build() - - val embedsList = if (dromeShow) List(rashidEmbed, dreamScarEmbed, dromeEmbed) else List(rashidEmbed, dreamScarEmbed) - val addRashidDreamScarEmbeds: List[MessageEmbed] = - embeds ++ embedsList - - boostedChannel - .sendMessageEmbeds(addRashidDreamScarEmbeds.asJava) - .setActionRow( - Button.primary( - "boosted list", - "Server Save Notifications" - ).withEmoji(Emoji.fromFormatted(Config.letterEmoji)) - ) - .queue((message: Message) => { - //updateBoostedMessage(guild.getId, message.getId) - discordUpdateConfig(guild, "", "", "", message.getId, worldFormal) - }, (e: Throwable) => { - logger.warn(s"Failed to send boosted boss/creature message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}':", e) - }) - } + // Boosted Boss + creature + server-save notifications (use the canonical + // world name so the Dream Courts lookup resolves) + postBoostedNotifications(boostedChannel, guild, worldFormal) val worldConfigData = worldRetrieveConfig(guild, world) val fullblessLevel = worldConfigData("fullbless_level") @@ -3616,7 +2911,7 @@ object BotApp extends App with StrictLogging { val fullblessEmbedText = s"The bot will poke:\n${Config.inqEmoji}<@&${fullblessRole.getId}> If an enemy fullblesses and is over level `${fullblessLevel}`\n${Config.bossEmoji}<@&${nemesisRole.getId}> If anyone dies to a rare boss\n${Config.hazardEmoji}<@&${allyPkRole.getId}> If an ally gets pked\n${Config.masslogEmoji}<@&${masslogRole.getId}> If enemies masslog on **$worldFormal**" fullblessEmbed.setTitle(s":crossed_swords: $worldFormal :crossed_swords:", s"https://www.tibia.com/community/?subtopic=worlds&world=$worldFormal") fullblessEmbed.setThumbnail(s"https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Phantasmal_Ooze.gif") - fullblessEmbed.setColor(3092790) + fullblessEmbed.setColor(BrandColor) fullblessEmbed.setFooter("Add or remove yourself from the role using the buttons below:") fullblessEmbed.setDescription(fullblessEmbedText) boostedChannel.sendMessageEmbeds(fullblessEmbed.build()) @@ -3639,7 +2934,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // Update role id if it changed @@ -3655,7 +2950,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // Update role id if it changed worldRepairConfig(guild, worldFormal, "allypk_role", allyPkRole.getId) @@ -3670,7 +2965,7 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } // Update role id if it changed @@ -3686,27 +2981,14 @@ object BotApp extends App with StrictLogging { world } } - worldsData += (guild.getId -> updatedWorldsList) + modifyWorldsData(_ + (guild.getId -> updatedWorldsList)) } } // apply required permissions to the new channel(s) if (channelList.nonEmpty) { - channelList.foreach { case (channel, webhooks) => - channel.upsertPermissionOverride(botRole) - .grant(Permission.VIEW_CHANNEL) - .grant(Permission.MESSAGE_SEND) - .grant(Permission.MESSAGE_MENTION_EVERYONE) - .grant(Permission.MESSAGE_EMBED_LINKS) - .grant(Permission.MESSAGE_HISTORY) - .grant(Permission.MANAGE_CHANNEL) - .complete() - channel.upsertPermissionOverride(publicRole) - .deny(Permission.MESSAGE_SEND) - .complete() - if (webhooks) { - // - } + channelList.foreach { case (channel, _) => + grantWorldPerms(channel, botRole, publicRole) } } // recreate admin channel and/or category @@ -3725,27 +3007,27 @@ object BotApp extends App with StrictLogging { // restrict the channel so only roles with Permission.MANAGE_MESSAGES can write to the channels newAdminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_SEND).complete() newAdminChannel.upsertPermissionOverride(botRole).grant(Permission.VIEW_CHANNEL).complete() + newAdminChannel.upsertPermissionOverride(botRole).grant(Permission.MESSAGE_EMBED_LINKS).complete() newAdminChannel.upsertPermissionOverride(guild.getPublicRole).deny(Permission.VIEW_CHANNEL).queue() adminChannel = newAdminChannel // update db & cache discordUpdateConfig(guild, adminCategory.getId, newAdminChannel.getId, "", "", worldFormal) updateAdminChannel(guild.getId, newAdminChannel.getId) } - if (adminChannel != null) { - if (adminChannel.canTalk()) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> has run `/repair` on the world **$worldFormal** and recreated missing channels.\n\nYou may need to rearrange their position within your discord server.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Hammer.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> has run `/repair` on the world **$worldFormal** and recreated missing channels.\n\nYou may need to rearrange their position within your discord server.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Hammer.gif") embedBuild.setDescription(s":gear: The missing channels for **$worldFormal** have been recreated.\nYou may need to rearrange their position within your discord server.") } } else { embedBuild.setDescription(s"${Config.noEmoji} You cannot run a `/repair` on **$worldFormal** because that world has not been `/setup` yet.") } + } catch { + case e: net.dv8tion.jda.api.exceptions.PermissionException => + logger.warn(s"/repair of '$worldFormal' on guild '${guild.getId}' aborted on a missing permission: ${e.getMessage}") + embedBuild.setDescription(s"${Config.noEmoji} I couldn't finish repairing **$worldFormal** because I'm missing a required permission. Grant me **Manage Roles**, **Manage Channels** and **Manage Permissions**, then run `/repair $world` again.") + case e: Exception => + logger.warn(s"/repair of '$worldFormal' on guild '${guild.getId}' failed before completing", e) + embedBuild.setDescription(s"${Config.noEmoji} Something went wrong while repairing **$worldFormal**; some channels may still be missing. Wait a moment, then run `/repair $world` again.") + } embedBuild.build() } @@ -3753,11 +3035,11 @@ object BotApp extends App with StrictLogging { worldConfigRepository.updateWorldString(guild.getId, world, tableName, newValue) def minLevel(event: SlashCommandInteractionEvent, world: String, level: Int, levelsOrDeaths: String): MessageEmbed = { - val worldFormal = world.toLowerCase().capitalize + val worldFormal = domain.WorldName.formal(world) val guild = event.getGuild val commandUser = event.getUser.getId val embedBuild = new EmbedBuilder() - embedBuild.setColor(3092790) + embedBuild.setColor(BrandColor) val cache = worldsData.getOrElse(guild.getId, List()).filter(w => w.name.toLowerCase() == world.toLowerCase()) val levelSetting = cache.headOption.map(_.levelsMin).getOrElse(null) val deathSetting = cache.headOption.map(_.deathsMin).getOrElse(null) @@ -3780,21 +3062,12 @@ object BotApp extends App with StrictLogging { w } } - worldsData = worldsData + (guild.getId -> modifiedWorlds) + modifyWorldsData(_ + (guild.getId -> modifiedWorlds)) minLevelToDatabase(guild, worldFormal, level, levelsOrDeaths) val discordConfig = discordRetrieveConfig(guild) val adminChannel = guild.getTextChannelById(discordConfig("admin_channel")) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s":gear: a command was run:") - adminEmbed.setDescription(s"<@$commandUser> changed the minimum level for the **$levelsOrDeaths channel**\nto `$level` for the world **$worldFormal**.") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Royal_Fanfare.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } - } + postAdminLog(adminChannel, s"<@$commandUser> changed the minimum level for the **$levelsOrDeaths channel**\nto `$level` for the world **$worldFormal**.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Royal_Fanfare.gif") embedBuild.setDescription(s":gear: The minimum level for the **$levelsOrDeaths channel**\nis now set to `$level` for the world **$worldFormal**.") embedBuild.build() } @@ -3812,88 +3085,93 @@ object BotApp extends App with StrictLogging { worldConfigRepository.updateWorldInt(guild.getId, world, columnName, level) } - def discordLeave(event: GuildLeaveEvent): Unit = { - val guildId = event.getGuild.getId - - // Remove from worldsData if exists - if (worldsData.contains(guildId)) { - val updatedWorldsData = worldsData - guildId - worldsData = updatedWorldsData + /** Build the boosted boss + creature + server-save embeds and post them to a + * guild's notifications channel with the server-save button, storing the + * message id so the daily scheduler can edit it later. Used when /setup or + * /repair (re)creates the notifications channel. */ + private def postBoostedNotifications(channel: TextChannel, guild: Guild, world: String): Unit = { + val combinedFutures: Future[List[MessageEmbed]] = for { + bossEmbed <- boostedBossEmbed() + creatureEmbed <- boostedCreatureEmbed() + } yield List(bossEmbed, creatureEmbed) + + combinedFutures.map { embeds => + val allEmbeds = embeds ++ serverSaveExtraEmbeds(world) + channel + .sendMessageEmbeds(allEmbeds.asJava) + .setActionRow(Button.primary("boosted list", "Server Save Notifications").withEmoji(Emoji.fromFormatted(Config.letterEmoji))) + .queue( + (message: Message) => discordUpdateConfig(guild, "", "", "", message.getId, world), + (e: Throwable) => logger.warn(s"Failed to send boosted boss/creature message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}':", e) + ) } + } - // Remove from discordsData if exists - val updatedDiscordsData = discordsData.map { case (world, discordsList) => - if (discordsList.exists(_.id == guildId)) { - val updatedDiscords = discordsList.filterNot(_.id == guildId) - world -> updatedDiscords - } else { - world -> discordsList - } - } - // Only update discordsData if the guild existed in it - if (updatedDiscordsData != discordsData) { - discordsData = updatedDiscordsData + /** Post a channel's intro/help embed (the "this channel shows ..." message) + * if the channel exists. Used for the levels/deaths/activity channels. */ + private def postChannelIntro(channel: TextChannel, description: String): Unit = + if (channel != null) { + val embed = new EmbedBuilder() + embed.setDescription(description) + embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Sign_(Library).gif") + embed.setColor(BrandColor) + channel.sendMessageEmbeds(embed.build()).queue() } - // Remove this guild from every world stream, cancelling any left unused - streamSupervisor.removeGuild(guildId) - - logger.info(guildId) - - if (guildId == "912739993015947324" || guildId == "1176279097001918516" || guildId == "1224670957466161234") { - // Config is shared with Pulsera Bot - logger.info("Config is shared between Pulsera Bot, will use as alpha environment will delete when guild wants it deleted") - } else { - removeConfigDatabase(guildId) - } + /** Post the Galthen's Satchel cooldown-tracker embed + button into a guild's + * notifications channel (done on every /setup and /repair of that channel). */ + private def postGalthenTracker(channel: TextChannel): Unit = { + val galthenEmbed = new EmbedBuilder() + galthenEmbed.setColor(BrandColor) + galthenEmbed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nManage your cooldowns here:") + galthenEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") + channel.sendMessageEmbeds(galthenEmbed.build()).addActionRow( + Button.primary("galthen default", "Cooldowns").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) + ).queue() + } + /** Apply the standard per-world channel/category permissions: grant the bot + * the channel-management set and deny @everyone the ability to post. Used for + * the world category and each world channel in /setup and /repair. */ + private def grantWorldPerms(entity: IPermissionContainer, botRole: Role, publicRole: Role): Unit = { + entity.upsertPermissionOverride(botRole) + .grant(Permission.VIEW_CHANNEL) + .grant(Permission.MESSAGE_SEND) + .grant(Permission.MESSAGE_MENTION_EVERYONE) + .grant(Permission.MESSAGE_EMBED_LINKS) + .grant(Permission.MESSAGE_HISTORY) + .grant(Permission.MANAGE_CHANNEL) + .complete() + entity.upsertPermissionOverride(publicRole).deny(Permission.MESSAGE_SEND).complete() } - def discordJoin(event: GuildJoinEvent): Unit = { - val guild = event.getGuild - val publicChannel = guild.getTextChannelById(guild.getDefaultChannel.getId) - if (publicChannel != null) { - if (publicChannel.canTalk() || !(Config.prod)) { - val embedBuilder = new EmbedBuilder() - val descripText = Config.helpText - embedBuilder.setAuthor("Violent Beams", "https://www.tibia.com/community/?subtopic=characters&name=Violent+Beams", "https://github.com/Leo32onGIT.png") - embedBuilder.setDescription(descripText) - embedBuilder.setThumbnail(Config.webHookAvatar) - embedBuilder.setColor(14397256) // orange for bot auto command - try { - publicChannel.sendMessageEmbeds(embedBuilder.build()).queue() - } catch { - case ex: Throwable => logger.error(s"Failed to send 'New Discord Join' message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - } - } - } + /** Reuse the guild's existing role of this name, or create it with the given + * colour. Used by /setup and /repair to (re)build the per-world poke roles. */ + private def getOrCreateRole(guild: Guild, name: String, color: Color): Role = { + val existing = guild.getRolesByName(name, true) + if (!existing.isEmpty) existing.get(0) + else guild.createRole().setName(name).setColor(color).complete() } - private def removeConfigDatabase(guildId: String): Unit = { - val conn = connectionProvider.admin() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") - val exist = result.next() - - // if bot_configuration exists - if (exist) { - statement.executeUpdate(s"DROP DATABASE _$guildId;") - logger.info(s"Database '$guildId' removed successfully") - statement.close() - conn.close() - } else { - logger.info(s"Database '$guildId' was not removed as it doesn't exist") - statement.close() - conn.close() + /** Delete a world's role if it still exists, logging (not throwing) on failure. */ + private def deleteRoleQuietly(role: Role, roleId: String, guild: Guild): Unit = + if (role != null) { + try role.delete().queue() + catch { + case ex: Throwable => logger.info(s"Failed to delete Role ID: '$roleId' for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + } } - } def removeChannels(event: SlashCommandInteractionEvent): MessageEmbed = { // get guild & world information from the slash interaction - val world: String = event.getInteraction.getOptions.asScala.find(_.getName == "world").map(_.getAsString).getOrElse("").trim().toLowerCase().capitalize + val world: String = domain.WorldName.formal(event.getInteraction.getOptions.asScala.find(_.getName == "world").map(_.getAsString).getOrElse("").trim()) val embedText = if (worlds.contains(world) || Config.mergedWorlds.contains(world)) { val guild = event.getGuild val worldConfigData = worldRetrieveConfig(guild, world) + // Channel/category deletion below goes through blocking .complete() calls; + // guard so a mid-way failure reports cleanly instead of hanging the + // interaction with the world left partially removed. + try { if (worldConfigData.nonEmpty) { // get channel ids val alliesChannelId = worldConfigData("allies_channel") @@ -3909,10 +3187,7 @@ object BotApp extends App with StrictLogging { // check if command is being run in one of the channels being deleted if (channelIds.contains(event.getChannel.getId)) { - return new EmbedBuilder() - .setColor(3092790) - .setDescription(s"${Config.noEmoji} That command would delete this channel, run it somewhere else.") - .build() + return presentation.Embeds.response(s"${Config.noEmoji} That command would delete this channel, run it somewhere else.") } val fullblessRoleId = worldConfigData("fullbless_role") @@ -3920,43 +3195,15 @@ object BotApp extends App with StrictLogging { val allyPkRoleId = worldConfigData("allypk_role") val masslogRoleId = worldConfigData("masslog_role") - val fullblessRole = guild.getRoleById(nemesisRoleId) - val nemesisRole = guild.getRoleById(fullblessRoleId) + val fullblessRole = guild.getRoleById(fullblessRoleId) + val nemesisRole = guild.getRoleById(nemesisRoleId) val allyPkRole = guild.getRoleById(allyPkRoleId) val masslogRole = guild.getRoleById(masslogRoleId) - //@unkown role-fix WIP - if (fullblessRole != null) { - try { - fullblessRole.delete().queue() - } catch { - case ex: Throwable => logger.info(s"Failed to delete Role ID: '${fullblessRoleId}' for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - } - } - - if (nemesisRole != null) { - try { - nemesisRole.delete().queue() - } catch { - case ex: Throwable => logger.info(s"Failed to delete Role ID: '${nemesisRoleId}' for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - - if (allyPkRole != null) { - try { - allyPkRole.delete().queue() - } catch { - case ex: Throwable => logger.info(s"Failed to delete Role ID: '${allyPkRoleId}' for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - - if (masslogRole != null) { - try { - masslogRole.delete().queue() - } catch { - case ex: Throwable => logger.info(s"Failed to delete Role ID: '${masslogRoleId}' for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } + deleteRoleQuietly(fullblessRole, fullblessRoleId, guild) + deleteRoleQuietly(nemesisRole, nemesisRoleId, guild) + deleteRoleQuietly(allyPkRole, allyPkRoleId, guild) + deleteRoleQuietly(masslogRole, masslogRoleId, guild) // remove the guild from the world stream, cancelling it if now unused streamSupervisor.removeGuildFromWorld(world, guild.getId) @@ -3979,60 +3226,62 @@ object BotApp extends App with StrictLogging { .map(_.filterNot(_.name.toLowerCase() == world.toLowerCase())) .map(worlds => worldsData + (guild.getId -> worlds)) .getOrElse(worldsData) - worldsData = updatedWorldsData + modifyWorldsData(_ => updatedWorldsData) // remove from discordsData discordsData.get(world) .foreach { discords => val updatedDiscords = discords.filterNot(_.id == guild.getId) - discordsData += (world -> updatedDiscords) + modifyDiscordsData(_ + (world -> updatedDiscords)) } // update the database worldRemoveConfig(guild, world) + // If that was the guild's last world, the guild-level command-log and + // notifications channels (and the "Violent Bot" category) would be left + // orphaned, so remove them too. Otherwise audit the removal in the + // command-log channel (which survives). + val remainingWorlds = updatedWorldsData.get(guild.getId).getOrElse(Nil) + val discordConfig = discordRetrieveConfig(guild) + if (remainingWorlds.isEmpty) { + val boostedChannel = guild.getTextChannelById(discordConfig.getOrElse("boosted_channel", "0")) + if (boostedChannel != null) boostedChannel.delete().complete() + val adminChannel = guild.getTextChannelById(discordConfig.getOrElse("admin_channel", "0")) + if (adminChannel != null) adminChannel.delete().complete() + val adminCategory = guild.getCategoryById(discordConfig.getOrElse("admin_category", "0")) + if (adminCategory != null) adminCategory.delete().complete() + } else { + val adminChannel = guild.getTextChannelById(discordConfig.getOrElse("admin_channel", "0")) + postAdminLog(adminChannel, s"<@${event.getUser.getId}> has run `/remove` on the world **$world** and deleted its channels.", "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Hammer.gif") + } + s":gear: The world **$world** has been removed." } else { s"${Config.noEmoji} The world **$world** is not configured here." } + } catch { + case e: net.dv8tion.jda.api.exceptions.PermissionException => + logger.warn(s"/remove of '$world' on guild '${guild.getId}' aborted on a missing permission: ${e.getMessage}") + s"${Config.noEmoji} I couldn't finish removing **$world** because I'm missing a required permission. Grant me **Manage Channels** and **Manage Roles**, then run `/remove $world` again." + case e: Exception => + logger.warn(s"/remove of '$world' on guild '${guild.getId}' failed before completing", e) + s"${Config.noEmoji} Something went wrong while removing **$world**; some channels may still remain. Wait a moment, then run `/remove $world` again." + } } else { s"${Config.noEmoji} This is not a valid World on Tibia." } // embed reply - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedText) - .build() + presentation.Embeds.response(embedText) } - private def creatureImageUrl(creature: String): String = { - val key = creature.toLowerCase - - key match { - case "death" => - "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Death_Effect.gif" - case "ice" => - "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Ice_Explosion_Effect.gif" - case "drowning" => - "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Reaper_Effect.gif" - case "pvp" => - "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Phantasmal_Ooze.gif" - case "life drain" => - "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Red_Sparkles_Effect.gif" - - case _ => - presentation.Urls.creatureImageUrl(creature, Config.creatureUrlMappings) - } - } + private def creatureImageUrl(creature: String): String = + presentation.Urls.creatureImageUrl(creature, Config.creatureUrlMappings) def creatureWikiUrl(creature: String): String = presentation.Urls.creatureWikiUrl(creature, Config.creatureUrlMappings) - // V1.9 Boosted Command - def createBoostedEmbed(name: String, emoji: String, wikiUrl: String, thumbnail: String, embedText: String): MessageEmbed = - presentation.BoostedEmbeds.create(name, emoji, wikiUrl, thumbnail, embedText) - // Death screenshot database methods def storeDeathScreenshot(guildId: String, world: String, characterName: String, deathTime: Long, screenshotUrl: String, addedBy: String, addedName: String, messageId: String): Unit = deathScreenshotRepository.store(guildId, world, characterName, deathTime, screenshotUrl, addedBy, addedName, messageId) diff --git a/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala b/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala index c57d399..2bea4f6 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/BotListener.scala @@ -11,33 +11,19 @@ import net.dv8tion.jda.api.events.message.MessageReceivedEvent import net.dv8tion.jda.api.hooks.ListenerAdapter import com.typesafe.scalalogging.StrictLogging import scala.jdk.CollectionConverters._ -import scala.collection.mutable import com.tibiabot.domain.PendingScreenshot -import com.tibiabot.commands.CommandRouter -import com.tibiabot.commands.handlers.{AdminCommands, AlliesCommands, BoostedCommands, ChannelCommands, ExivaCommands, FilterCommands, FullblessCommands, GalthenCommands, HelpCommands, HuntedCommands, LeaderboardCommands, NeutralCommands, OnlineListCommands} +import com.tibiabot.commands.{CommandRouter, SlashRouting} class BotListener extends ListenerAdapter with StrictLogging { - private val pendingScreenshots = mutable.Map[String, PendingScreenshot]() + // Mutated from both onButtonInteraction and onMessageReceived, which JDA + // dispatches on a thread pool — use a thread-safe map (a plain mutable.Map + // can corrupt structurally under concurrent put/remove). TrieMap is a + // mutable.Map, so the handler signatures are unchanged. + private val pendingScreenshots = scala.collection.concurrent.TrieMap[String, PendingScreenshot]() - // Slash-command dispatch table. Adding a command means adding one entry here. - private val slashRouter = new CommandRouter[SlashCommandInteractionEvent](Map( - "setup" -> (ChannelCommands.setup _), - "remove" -> (ChannelCommands.remove _), - "hunted" -> (HuntedCommands.handle _), - "allies" -> (AlliesCommands.handle _), - "neutral" -> (NeutralCommands.handle _), - "fullbless" -> (FullblessCommands.handle _), - "filter" -> (FilterCommands.handle _), - "admin" -> (AdminCommands.handle _), - "exiva" -> (ExivaCommands.handle _), - "help" -> (HelpCommands.handle _), - "repair" -> (ChannelCommands.repair _), - "galthen" -> (GalthenCommands.handle _), - "online" -> (OnlineListCommands.handle _), - "boosted" -> (BoostedCommands.handle _), - "leaderboards" -> (LeaderboardCommands.handle _) - )) + // Slash-command dispatch table lives in commands.SlashRouting (one entry per command). + private val slashRouter = new CommandRouter[SlashCommandInteractionEvent](SlashRouting.handlers) override def onSlashCommandInteraction(event: SlashCommandInteractionEvent): Unit = { event.deferReply(true).queue() @@ -45,23 +31,19 @@ class BotListener extends ListenerAdapter with StrictLogging { slashRouter.route(event.getName, event) } else { val responseText = s"${Config.noEmoji} The bot is still starting up, try running your command later." - val embed = new EmbedBuilder().setDescription(responseText).setColor(3092790).build() + val embed = new EmbedBuilder().setDescription(responseText).setColor(presentation.Embeds.BrandColor).build() event.getHook.sendMessageEmbeds(embed).queue() } } override def onGuildJoin(event: GuildJoinEvent): Unit = { val guild = event.getGuild - //if (Config.verifiedDiscords.contains(guild.getId)) { - guild.updateCommands().addCommands(commands.asJava).complete() - BotApp.discordJoin(event) - //} else { - // guild.updateCommands().queue() - //} + guild.updateCommands().addCommands(commands.asJava).complete() + BotApp.channelService.discordJoin(event) } override def onGuildLeave(event: GuildLeaveEvent): Unit = { - BotApp.discordLeave(event) + BotApp.channelService.discordLeave(event) } override def onModalInteraction(event: ModalInteractionEvent): Unit = interactions.ModalHandler.handle(event) diff --git a/tibia-bot/src/main/scala/com/tibiabot/CachedList.scala b/tibia-bot/src/main/scala/com/tibiabot/CachedList.scala new file mode 100644 index 0000000..233551b --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/CachedList.scala @@ -0,0 +1,38 @@ +package com.tibiabot + +import java.time.{Duration, ZonedDateTime} + +/** A thread-safe TTL cache for a list that is expensive to fetch (a blocking + * API call). Within `ttl` of the last successful fetch the cached value is + * returned without re-fetching; after that it re-fetches. On a failed fetch it + * falls back to the last good value, or to `fallback` if there is none. + * + * The fetcher, clock and fallback are injected so the hit/miss/expiry/fallback + * behaviour is unit-testable with no network or ActorSystem (CachedListSpec). + */ +final class CachedList[A]( + fetch: () => Either[String, List[A]], + fallback: => List[A], + ttl: Duration, + now: () => ZonedDateTime +) { + private var cached: Option[List[A]] = None + private var fetchedAt: Option[ZonedDateTime] = None + + def get(): List[A] = synchronized { + val fresh = (cached, fetchedAt) match { + case (Some(value), Some(t)) if now().isBefore(t.plus(ttl)) => Some(value) + case _ => None + } + fresh.getOrElse { + fetch() match { + case Right(value) => + cached = Some(value) + fetchedAt = Some(now()) + value + case Left(_) => + cached.getOrElse(fallback) + } + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/Config.scala b/tibia-bot/src/main/scala/com/tibiabot/Config.scala index 5ada8d4..00f1aaf 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/Config.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/Config.scala @@ -3,7 +3,6 @@ package com.tibiabot import com.typesafe.config.ConfigFactory import scala.jdk.CollectionConverters._ -import scala.util.Try object Config { // prod or dev environment @@ -43,11 +42,6 @@ object Config { val guildJoinRed: String = discord.getString("guild-join-thumbnail-red") val guildJoinGreen: String = discord.getString("guild-join-thumbnail-green") - // Legacy emoji support (fallback to discord.conf if EmojiManager not available) - private def getLegacyEmoji(key: String): String = { - Try(discord.getString(key)).getOrElse("❓") - } - // Emojis val nemesisEmoji: String = discord.getString("nemesis-emoji") val archfoeEmoji: String = discord.getString("archfoe-emoji") @@ -212,6 +206,6 @@ object Config { ) // creatures - dynamically fetched from TibiaData API - val creaturesListFromApi: List[String] = BotApp.fetchCreatureNames + val creaturesListFromApi: List[String] = BotApp.fetchCreatureNames() val creaturesList: List[String] = creaturesListFromApi.map(_.toLowerCase.trim) } diff --git a/tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala b/tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala deleted file mode 100644 index 365e974..0000000 --- a/tibia-bot/src/main/scala/com/tibiabot/CreatureManager.scala +++ /dev/null @@ -1,55 +0,0 @@ -package com.tibiabot - -import com.tibiabot.tibiadata.TibiaDataClient -import com.typesafe.scalalogging.StrictLogging - -import scala.concurrent.duration._ -import scala.concurrent.{Await, ExecutionContextExecutor, Future} -import scala.util.{Failure, Success, Try} -import java.time.ZonedDateTime - -object CreatureManager extends StrictLogging { - - implicit private val system: akka.actor.ActorSystem = akka.actor.ActorSystem() - implicit private val executionContext: ExecutionContextExecutor = scala.concurrent.ExecutionContext.global - - private val tibiaDataClient = new TibiaDataClient() - private var cachedCreatureList: Option[List[String]] = None - private var lastFetchTime: Option[ZonedDateTime] = None - - // Creatures endpoint on TibiaData Api uses pluralization, race is unconventional name - // Can't be used yet, needs work - - // Fallback static creature list in case API fails (truncated for brevity) - private val fallbackCreatureList = List( - "abyssal calamary", "acid blob" - ) - - def getCreaturesList(): List[String] = { - logger.info("Cache expired or empty, fetching fresh creature list from API") - refreshCreatureList() - } - - private def refreshCreatureList(): List[String] = { - Try { - val creaturesResponse = Await.result(tibiaDataClient.getCreatures(), Duration(30, "seconds")) - creaturesResponse match { - case Right(response) => - // Convert creature names to lowercase and extract from the creature_list - val creatureNames = response.creatures.creature_list.map(_.name.toLowerCase).sorted - cachedCreatureList = Some(creatureNames) - lastFetchTime = Some(ZonedDateTime.now()) - logger.info(s"Successfully fetched ${creatureNames.length} creatures from TibiaData API") - creatureNames - case Left(error) => - logger.warn(s"Failed to fetch creatures from API: $error, using fallback list") - cachedCreatureList.getOrElse(fallbackCreatureList) - } - } match { - case Success(creatures) => creatures - case Failure(exception) => - logger.error(s"Exception while fetching creatures from API: ${exception.getMessage}, using fallback list") - cachedCreatureList.getOrElse(fallbackCreatureList) - } - } -} diff --git a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala index b6944ba..44c9d92 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala @@ -9,6 +9,7 @@ import com.tibiabot.tibiadata.{TibiaApi, TibiaDataClient} import com.tibiabot.tibiadata.response.{CharacterResponse, Deaths, OnlinePlayers, WorldResponse} import com.typesafe.scalalogging.StrictLogging import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.Guild import net.dv8tion.jda.api.entities.channel.concrete.TextChannel import net.dv8tion.jda.api.exceptions.ErrorHandler import net.dv8tion.jda.api.requests.ErrorResponse @@ -24,8 +25,6 @@ import scala.concurrent.{Await, ExecutionContextExecutor, Future} import scala.jdk.CollectionConverters._ import scala.util.{Failure, Success} import java.time.OffsetDateTime -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import java.time.{LocalTime, ZoneId} import java.util.concurrent.ConcurrentHashMap import java.time.Instant @@ -36,22 +35,19 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext // A date-based "key" for a character, used to track recent deaths and recent online entries private case class CharKey(char: String, time: ZonedDateTime) private case class CharKeyBypass(char: String, level: Int, time: ZonedDateTime) - private case class CurrentOnline(name: String, level: Int, vocation: String, guildName: String, time: ZonedDateTime, duration: Long = 0L, flag: String) private case class CharDeath(char: CharacterResponse, death: Deaths) private case class CharSort(guildName: String, allyGuild: Boolean, huntedGuild: Boolean, allyPlayer: Boolean, huntedPlayer: Boolean, vocation: String, level: Int, message: String) private case class OnlineListEntry(name: String, level: Int, lastUpdated: ZonedDateTime) - //val guildId: String = guild.getId - private val recentDeaths = mutable.Set.empty[CharKey] private val levelTracker = new tracking.LevelTracker private val recentOnline = mutable.Set.empty[CharKey] private val recentOnlineBypass = mutable.Set.empty[CharKeyBypass] - private var currentOnline = mutable.Set.empty[CurrentOnline] + private val onlineTracker = new tracking.OnlineTracker val masspokeCooldowns = new ConcurrentHashMap[String, ZonedDateTime]() // Dedicated online list table for killer level lookups - updated every 5 minutes - private var onlineListTable = mutable.Map.empty[String, OnlineListEntry] + private val onlineListTable = mutable.Map.empty[String, OnlineListEntry] // initialize cached deaths/levels from database recentDeaths ++= BotApp.getDeathsCache(world).map(deathsCache => CharKey(deathsCache.name, ZonedDateTime.parse(deathsCache.time))) @@ -64,7 +60,6 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext private var enemiesListPurgeTimer: Map[String, ZonedDateTime] = Map.empty private var neutralsListPurgeTimer: Map[String, ZonedDateTime] = Map.empty private var onlineListTableUpdateTimer: ZonedDateTime = ZonedDateTime.now().minusMinutes(10) // Start immediately - // ZonedDateTime.parse("2022-01-01T01:00:00Z") private val tibiaDataClient: TibiaApi = new TibiaDataClient() @@ -98,19 +93,9 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val now = ZonedDateTime.now() val online: List[OnlinePlayers] = worldResponse.world.online_players.getOrElse(List.empty[OnlinePlayers]) - // get online data with durations - val onlineWithVocLvlAndDuration = online.map { player => - currentOnline.find(_.name == player.name) match { - case Some(existingPlayer) => - val duration = now.toEpochSecond - existingPlayer.time.toEpochSecond - CurrentOnline(player.name, player.level.toInt, player.vocation, existingPlayer.guildName, now, existingPlayer.duration + duration, existingPlayer.flag) - case None => CurrentOnline(player.name, player.level.toInt, player.vocation, "", now, 0L, "") - } - } - - // Add online data to sets - currentOnline.clear() - currentOnline.addAll(onlineWithVocLvlAndDuration) + // get online data with durations (carries over guild/duration/flag, drops log-offs) + onlineTracker.updateFromOnline(online.map(player => (player.name, player.level.toInt, player.vocation)), now) + val onlineWithVocLvlAndDuration = onlineTracker.snapshot // Update online list table every 5 minutes for killer level lookups if (now.isAfter(onlineListTableUpdateTimer.plusMinutes(5))) { @@ -209,12 +194,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext ) // add guild to online list cache - currentOnline.find(_.name == charName).foreach { onlinePlayer => - if (onlinePlayer.guildName != guildName){ - val updatedPlayer = onlinePlayer.copy(guildName = guildName) - currentOnline = currentOnline.filterNot(_ == onlinePlayer) + updatedPlayer - } - } + onlineTracker.setGuild(charName, guildName) // Activity channel if (!blocker) { @@ -335,14 +315,13 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext //charResponse.character.character.world // Guild has changed if (guildName != guildNameFromActivityData) { - //val newGuild = if (guildName == "") "None" else guildName val newGuildLess = if (guildName == "") true else false val oldGuildLess = if (guildNameFromActivityData == "") true else false val wasInHuntedGuild = huntedGuildsData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == guildNameFromActivityData.toLowerCase()) val wasInAlliedGuild = alliedGuildsData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == guildNameFromActivityData.toLowerCase()) // Left a tracked guild if (wasInHuntedGuild || wasInAlliedGuild) { - val guildType = if (wasInHuntedGuild) "hunted" else if (wasInAlliedGuild) "allied" else "neutral" + val guildType = presentation.GuildActivity.guildType(wasInHuntedGuild, wasInAlliedGuild) // No guild now if (newGuildLess) { // send message to activity channel @@ -356,7 +335,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } } } else { // Left a tracked guild, but joined a new one in the same turn - val colorType = if (huntedGuildCheck) 13773097 else if (allyGuildCheck) 36941 else 14397256 // hunted join = red, allied join = green, otherwise = yellow + val colorType = presentation.GuildActivity.activityColor(huntedGuildCheck, allyGuildCheck) // send message to activity channel if (activityTextChannel != null) { if (activityTextChannel.canTalk() || (!Config.prod)) { @@ -422,8 +401,8 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } if (huntedPlayerCheck && oldGuildLess) { - val colorType = if (huntedGuildCheck) 13773097 else if (allyGuildCheck) 36941 else 14397256 // hunted join = red, allied join = green, otherwise = yellow - val guildType = if (huntedGuildCheck) "hunted" else if (allyGuildCheck) "allied" else "neutral" + val colorType = presentation.GuildActivity.activityColor(huntedGuildCheck, allyGuildCheck) + val guildType = presentation.GuildActivity.guildType(huntedGuildCheck, allyGuildCheck) // joined a hunted guild if (huntedGuildCheck) { // remove from hunted 'Player' cache and db @@ -537,8 +516,8 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } } } - val guildType = if (huntedGuildCheck) "hunted" else if (allyGuildCheck) "allied" else "neutral" - val colorType = if (huntedGuildCheck) 13773097 else if (allyGuildCheck) 36941 else 14397256 + val guildType = presentation.GuildActivity.guildType(huntedGuildCheck, allyGuildCheck) + val colorType = presentation.GuildActivity.activityColor(huntedGuildCheck, allyGuildCheck) if (guildType != "neutral") { // ignore neutral guild changes, only show hunted/allied rejoins if (activityTextChannel != null) { if (activityTextChannel.canTalk() || (!Config.prod)) { @@ -577,7 +556,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } } if (!recentlyDied) { - currentOnline.find(_.name == charName).foreach { onlinePlayer => + onlineTracker.find(charName).foreach { onlinePlayer => // level (i need to add logic here to batch messages control throughput a bit) if (onlinePlayer.level > sheetLevel) { val newLevelRecord = tracking.LevelRecord(charName, onlinePlayer.level, sheetVocation, sheetLastLogin, now) @@ -593,16 +572,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val huntedGuildCheck = huntedGuildsData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == guildName.toLowerCase()) val allyPlayerCheck = alliedPlayersData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == charName.toLowerCase()) val huntedPlayerCheck = huntedPlayersData.getOrElse(guildId, List()).exists(_.name.toLowerCase() == charName.toLowerCase()) - val guildIcon = (guildName, allyGuildCheck, huntedGuildCheck, allyPlayerCheck, huntedPlayerCheck) match { - case (_, true, _, _, _) => Config.allyGuild // allied-guilds - case (_, _, true, _, _) => Config.enemyGuild // hunted-guilds - case ("", _, _, true, _) => Config.ally // allied-players not in any guild - case (_, _, _, true, _) => s"${Config.otherGuild}${Config.ally}" // allied-players but in neutral guild - case ("", _, _, _, true) => Config.enemy // hunted-players no guild - case (_, _, _, _, true) => s"${Config.otherGuild}${Config.enemy}" // hunted-players but in neutral guild - case ("", _, _, _, _) => "" // no guild (not ally or hunted) - case _ => Config.otherGuild // guild (not ally or hunted) - } + val guildIcon = presentation.GuildIcons.guildIcon(guildName, allyGuildCheck, huntedGuildCheck, allyPlayerCheck, huntedPlayerCheck) val worldData = worldsData.getOrElse(guildId, List()).filter(w => w.name.toLowerCase() == world.toLowerCase()) val levelsChannel = worldData.headOption.map(_.levelsChannel).getOrElse("0") val webhookMessage = s"${vocEmoji(onlinePlayer.vocation)} **[$charName](${charUrl(charName)})** advanced to level **${onlinePlayer.level}** $guildIcon" @@ -617,19 +587,10 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val enemyIcons = List(Config.enemy, Config.enemyGuild, s"${Config.otherGuild}${Config.enemy}") val alliesIcons = List(Config.allyGuild, Config.ally, s"${Config.otherGuild}${Config.ally}") val neutralIcons = List(Config.otherGuild, "") - // don't post level if showNeutrals is set to false and its a neutral level - val levelsCheck = - if (showNeutralLevels == "false" && neutralIcons.contains(guildIcon)) { - false - } else if (showAlliesLevels == "false" && alliesIcons.contains(guildIcon)) { - false - } else if (showEnemiesLevels == "false" && enemyIcons.contains(guildIcon)) { - false - } else if (onlinePlayer.level < minimumLevel) { - false - } else { - true - } + // suppress the level-up for a category whose show-flag is off, or below the minimum level + val levelsCheck = presentation.LevelVisibility.shouldPost( + neutralIcons.contains(guildIcon), alliesIcons.contains(guildIcon), enemyIcons.contains(guildIcon), + showNeutralLevels, showAlliesLevels, showEnemiesLevels, onlinePlayer.level, minimumLevel) if (levelTracker.shouldRecord(charName, onlinePlayer.level, sheetLastLogin)) { if (levelsCheck) { sendMessageWithRateLimit(levelsTextChannel, message = webhookMessage) @@ -640,10 +601,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } } // add flag to onlineList if player has leveled - currentOnline.find(_.name == charName).foreach { onlinePlayer => - currentOnline -= onlinePlayer - currentOnline += onlinePlayer.copy(flag = Config.levelUpEmoji) - } + onlineTracker.setFlag(charName, Config.levelUpEmoji) if (levelTracker.shouldRecord(charName, onlinePlayer.level, sheetLastLogin)) { levelTracker.record(newLevelRecord) BotApp.addLevelsCache(world, charName, onlinePlayer.level.toString, sheetVocation, sheetLastLogin.toString, now.toString) @@ -680,10 +638,8 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val enemiesChannel = worldData.headOption.map(_.enemiesChannel).getOrElse("0") val categoryChannel = worldData.headOption.map(_.category).getOrElse("0") val onlineCombinedOption = worldData.headOption.map(_.onlineCombined).getOrElse("false") - //if (currentOnlineList.size > 1) { - onlineListTimer = onlineListTimer + (guildId -> ZonedDateTime.now()) - onlineList(currentOnline.toList, guildId, alliesChannel, neutralsChannel, enemiesChannel, categoryChannel, onlineCombinedOption, world) - //} + onlineListTimer = onlineListTimer + (guildId -> ZonedDateTime.now()) + onlineList(onlineTracker.snapshot, guildId, alliesChannel, neutralsChannel, enemiesChannel, categoryChannel, onlineCombinedOption, world) } } } @@ -704,7 +660,6 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val nemesisRole = worldData.headOption.map(_.nemesisRole).getOrElse("0") val fullblessRole = worldData.headOption.map(_.fullblessRole).getOrElse("0") val allyHelpRole = worldData.headOption.map(_.allyPkRole).getOrElse("0") - val masslogRole = worldData.headOption.map(_.masslogRole).getOrElse("0") val exivaListCheck = worldData.headOption.map(_.exivaList).getOrElse("true") val deathsTextChannel = guild.getTextChannelById(deathsChannel) /** @@ -722,7 +677,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val killer = charDeath.death.killers.last.name var context = "Died" var embedColor = 3092790 // background default - var embedThumbnail = creatureImageUrl(killer) + var embedThumbnail = presentation.DeathEffect.thumbnail(killer).getOrElse(creatureImageUrl(killer)) var vowelCheck = "" // this is for adding "an" or "a" in front of creature names val killerBuffer = ListBuffer[String]() val exivaBuffer = ListBuffer[String]() @@ -732,7 +687,6 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext // guild rank and name val guildName = charDeath.char.character.character.guild.map(_.name).getOrElse("") val guildRank = charDeath.char.character.character.guild.map(_.rank).getOrElse("") - //var guildText = ":x: **No Guild**\n" var guildText = "" // guild @@ -807,85 +761,36 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext if (embedColor == 3092790 || embedColor == 4540237) { embedColor = 14869218 // bone white } - embedThumbnail = s"https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Phantasmal_Ooze.gif" - val isSummon = k.name.split(" of ", 2) // e.g: fire elemental of Violent Beams - if (isSummon.length > 1) { - if (!isSummon(0).exists(_.isUpper)) { // summons will be lowercase, a player with " of " in their name will have a capital letter - val vowel = isSummon(0).take(1) match { - case "a" => "an" - case "e" => "an" - case "i" => "an" - case "o" => "an" - case "u" => "an" - case _ => "a" - } - val summonerLevelText = getKillerLevel(isSummon(1)).map(level => s" [$level]").getOrElse("") - killerBuffer += s"$vowel ${Config.summonEmoji} **${isSummon(0)} of [${isSummon(1)}$summonerLevelText](${charUrl(isSummon(1))})**" + embedThumbnail = presentation.DeathEffect.pvp + domain.Killers.parseSummon(k.name) match { + case Some((creature, summoner)) => // e.g: fire elemental of Violent Beams + val vowel = domain.Killers.article(creature) + val summonerLevelText = getKillerLevel(summoner).map(level => s" [$level]").getOrElse("") + killerBuffer += s"$vowel ${Config.summonEmoji} **$creature of [$summoner$summonerLevelText](${charUrl(summoner)})**" if (embedColor == 13773097) { if (exivaListCheck == "true") { - exivaBuffer += isSummon(1) + exivaBuffer += summoner } } - } else { + case None => // a player (incl. names with " of " like "Knight of Flame") or an undetected summon val levelText = getKillerLevel(k.name).map(level => s" [$level]").getOrElse("") - killerBuffer += s"**[${k.name}$levelText](${charUrl(k.name)})**" // player with " of " in the name e.g: Knight of Flame + killerBuffer += s"**[${k.name}$levelText](${charUrl(k.name)})**" if (embedColor == 13773097) { if (exivaListCheck == "true") { exivaBuffer += k.name } } - } - } else { - val levelText = getKillerLevel(k.name).map(level => s" [$level]").getOrElse("") - killerBuffer += s"**[${k.name}$levelText](${charUrl(k.name)})**" // summon not detected - if (embedColor == 13773097) { - if (exivaListCheck == "true") { - exivaBuffer += k.name - } - } } } } else { - // custom emojis for flavour - // map boss lists to their respesctive emojis - val creatureEmojis: Map[List[String], String] = Map( - Config.nemesisCreatures -> Config.nemesisEmoji, - Config.archfoeCreatures -> Config.archfoeEmoji, - Config.baneCreatures -> Config.baneEmoji, - Config.bossSummons -> Config.summonEmoji, - Config.cubeBosses -> Config.cubeEmoji, - Config.mkBosses -> Config.mkEmoji, - Config.svarGreenBosses -> Config.svarGreenEmoji, - Config.svarScrapperBosses -> Config.svarScrapperEmoji, - Config.svarWarlordBosses -> Config.svarWarlordEmoji, - Config.zelosBosses -> Config.zelosEmoji, - Config.libBosses -> Config.libEmoji, - Config.hodBosses -> Config.hodEmoji, - Config.feruBosses -> Config.feruEmoji, - Config.inqBosses -> Config.inqEmoji, - Config.kilmareshBosses -> Config.kilmareshEmoji, - Config.primalCreatures -> Config.primalEmoji, - Config.hazardCreatures -> Config.hazardEmoji - ) - // assign the appropriate emoji - val bossIcon = creatureEmojis.find { - case (creatures, _) => creatures.contains(k.name.toLowerCase()) - }.map(_._2 + " ").getOrElse("") + // map boss lists to their respective emojis (built once in BossEmoji) + val bossIcon = presentation.BossEmoji.of(k.name) // add "an" or "a" depending on first letter of creatures name // ignore capitalized names (nouns) as they are bosses // if player dies to a neutral source show 'died by energy' instead of 'died by an energy' if (!k.name.exists(_.isUpper)) { - val elements = List("death", "earth", "energy", "fire", "ice", "holy", "a trap", "agony", "life drain", "drowning") - vowelCheck = k.name.take(1) match { - case _ if elements.contains(k.name) => "" - case "a" => "an " - case "e" => "an " - case "i" => "an " - case "o" => "an " - case "u" => "an " - case _ => "a " - } + vowelCheck = domain.Killers.sourceArticle(k.name) } killerBuffer += s"$vowelCheck$bossIcon**${k.name}**" } @@ -959,23 +864,18 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } } } - case Failure(_) => // e.printStackTrace + case Failure(exception) => + logger.warn(s"Failed to scan the exiva list for auto-hunt detection on world '$world': ${exception.getMessage}") } } } - // convert formatted killer list to one string - val killerInit = if (killerBuffer.nonEmpty) killerBuffer.view.init else None - var killerText = - //noinspection ScalaDeprecation - if (killerInit.iterator.nonEmpty) { - //noinspection ScalaDeprecation - killerInit.iterator.mkString(", ") + " and " + killerBuffer.last - } else killerBuffer.headOption.getOrElse("") + // convert formatted killer list to one string ("a, b and c") + var killerText = domain.Killers.joinNatural(killerBuffer.toSeq) // this should only occur to pure suicides on bomb runes, or pure 'assists' deaths in yellow-skull friendy fire or retro/hardcore situations if (killerText == "") { - embedThumbnail = s"https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Ghost_Smoke_Effect.gif" + embedThumbnail = presentation.DeathEffect.suicide killerText = s"""`suicide`""" } @@ -995,20 +895,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val showNeutralDeaths = worldData.headOption.map(_.showNeutralDeaths).getOrElse("true") val showAlliesDeaths = worldData.headOption.map(_.showAlliesDeaths).getOrElse("true") val showEnemiesDeaths = worldData.headOption.map(_.showEnemiesDeaths).getOrElse("true") - var embedCheck = true - if (embedColor == 3092790 || embedColor == 14869218 || embedColor == 4540237 || embedColor == 14397256) { - if(showNeutralDeaths == "false") { - embedCheck = false - } - } else if (embedColor == 36941) { - if(showEnemiesDeaths == "false") { - embedCheck = false - } - } else if (embedColor == 13773097) { - if(showAlliesDeaths == "false") { - embedCheck = false - } - } + val embedCheck = presentation.DeathEmbeds.shouldShow(embedColor, showNeutralDeaths, showAlliesDeaths, showEnemiesDeaths) val embed = presentation.DeathEmbeds.build(charName, charDeath.char.character.character.vocation, embedText, embedThumbnail, embedColor) // return embed + poke @@ -1094,18 +981,13 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext cleanUp() - Future.successful() + Future.successful(()) }.withAttributes(logAndResume) - private def onlineList(onlineData: List[CurrentOnline], guildId: String, alliesChannel: String, neutralsChannel: String, enemiesChannel: String, categoryChannel: String, onlineCombined: String, world: String): Unit = { + private def onlineList(onlineData: List[tracking.OnlinePlayer], guildId: String, alliesChannel: String, neutralsChannel: String, enemiesChannel: String, categoryChannel: String, onlineCombined: String, world: String): Unit = { val vocationBuffers = ListMap( - "druid" -> ListBuffer[CharSort](), - "knight" -> ListBuffer[CharSort](), - "paladin" -> ListBuffer[CharSort](), - "sorcerer" -> ListBuffer[CharSort](), - "monk" -> ListBuffer[CharSort](), - "none" -> ListBuffer[CharSort]() + domain.Vocations.displayOrder.map(_ -> ListBuffer[CharSort]()): _* ) val sortedList = onlineData.sortWith(_.level > _.level) @@ -1123,16 +1005,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext .exists(_.name.equalsIgnoreCase(player.name)) val huntedPlayerCheck = huntedPlayersData.getOrElse(guildId, List()) .exists(_.name.equalsIgnoreCase(player.name)) - val guildIcon = (player.guildName, allyGuildCheck, huntedGuildCheck, allyPlayerCheck, huntedPlayerCheck) match { - case (_, true, _, _, _) => Config.allyGuild - case (_, _, true, _, _) => Config.enemyGuild - case ("", _, _, true, _) => Config.ally - case (_, _, _, true, _) => s"${Config.otherGuild}${Config.ally}" - case ("", _, _, _, true) => Config.enemy - case (_, _, _, _, true) => s"${Config.otherGuild}${Config.enemy}" - case ("", _, _, _, _) => "" - case _ => Config.otherGuild - } + val guildIcon = presentation.GuildIcons.guildIcon(player.guildName, allyGuildCheck, huntedGuildCheck, allyPlayerCheck, huntedPlayerCheck) // Masslog: only shows characters :zap: if they have only been logged in under 900 seconds (15 minutes) val justLogged = durationInSec < 900 && (huntedGuildCheck || huntedPlayerCheck) @@ -1144,7 +1017,6 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext // run channel checks before updating the channels val guild = BotApp.discordGateway.guildById(guildId) - val pattern = "^(.*?)(?:-[0-9]+)?$".r // default online list val alliesList: List[String] = vocationBuffers.values @@ -1224,7 +1096,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext .view.mapValues(_.size) .toMap - val updatedVocationBuffers = vocationBuffers.mapValues { charSorts => + val updatedVocationBuffers = vocationBuffers.view.mapValues { charSorts => val updatedCharSorts = charSorts.map { charSort => if (charSort.guildName != "" && guildNameCounts.getOrElse(charSort.guildName, 0) < 3) { charSort.copy(guildName = "") @@ -1235,82 +1107,23 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext updatedCharSorts } - val neutralsGroupedByGuild: List[(String, List[String])] = updatedVocationBuffers.values - .flatMap(_.filter(charSort => !charSort.huntedPlayer && !charSort.huntedGuild && !charSort.allyPlayer && !charSort.allyGuild)) - .groupBy(_.guildName) - .mapValues(_.map(_.message).toList) - .toList - .partition(_._1.isEmpty) match { - case (guildless, withGuilds) => - withGuilds.sortBy { case (_, messages) => -messages.length } ++ guildless - } - - val flattenedNeutralsList: List[String] = neutralsGroupedByGuild.zipWithIndex.flatMap { - case ((guildName, messages), index) => - if (guildName.isEmpty) { - s"### Others ${messages.length}" :: messages - } else { - s"### [$guildName](${guildUrl(guildName)}) ${messages.length}" :: messages - } - } + val neutralsGroupedByGuild: List[(String, List[String])] = presentation.OnlineListGrouping.groupByGuild( + updatedVocationBuffers.values.flatten + .filter(charSort => !charSort.huntedPlayer && !charSort.huntedGuild && !charSort.allyPlayer && !charSort.allyGuild) + .map(charSort => charSort.guildName -> charSort.message)) - /** - val flattenedNeutralsList: List[String] = neutralsGroupedByGuild.flatMap { - case ("", messages) => s"### No Guild ${messages.length}" :: messages - case (guildName, messages) => s"### [$guildName](${guildUrl(guildName)}) ${messages.length}" :: messages - } - **/ + val flattenedNeutralsList: List[String] = + presentation.OnlineListGrouping.withHeaders(neutralsGroupedByGuild, n => s"### Others $n") val totalCount = alliesList.size + neutralsList.size + enemiesList.size - val modifiedAlliesList = if (alliesList.nonEmpty) { - if (neutralsList.nonEmpty || enemiesList.nonEmpty) { - List(s"### ${Config.ally} **Allies** ${Config.ally} ${alliesList.size}") ++ alliesList - } else { - alliesList - } - } else { - alliesList - } - val modifiedEnemiesList = if (enemiesList.nonEmpty) { - if (alliesList.nonEmpty || neutralsList.nonEmpty) { - List(s"### ${Config.enemy} **Enemies** ${Config.enemy} ${enemiesList.size}") ++ enemiesList - } else { - enemiesList - } - } else { - enemiesList - } - - val combinedList = { - val headerToRemove = s"### Others" - val hasOtherHeaders = flattenedNeutralsList.exists(header => header.startsWith("### ") && !header.startsWith(headerToRemove)) - if (modifiedAlliesList.isEmpty && modifiedEnemiesList.isEmpty && !hasOtherHeaders) { - flattenedNeutralsList.filterNot(header => header.startsWith(headerToRemove)) - } else { - modifiedAlliesList ++ modifiedEnemiesList ++ flattenedNeutralsList - } - } + val combinedList = presentation.OnlineListGrouping.combinedChannelBody( + alliesList, enemiesList, neutralsList, flattenedNeutralsList, Config.ally, Config.enemy) // allow for custom channel names val channelName = combinedTextChannel.getName - val extractName = pattern.findFirstMatchIn(channelName) - val customName = if (extractName.isDefined) { - val m = extractName.get - m.group(1) - } else "online" - val onlineCategoryName = onlineListCategoryTimer.getOrElse(combinedTextChannel.getId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryName.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (combinedTextChannel.getId -> ZonedDateTime.now()) - if (channelName != s"$customName-$totalCount") { //WIP - try { - val channelManager = combinedTextChannel.getManager - channelManager.setName(s"$customName-$totalCount").queue() - } catch { - case ex: Throwable => logger.info(s"Failed to rename the online list channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + val customName = presentation.OnlineListEmbeds.baseName(channelName, "online") + renameOnlineChannelIfDue(combinedTextChannel, s"$customName-$totalCount", "online list channel", guildId, guild.getName) if (combinedList.nonEmpty) { updateMultiFields(combinedList, combinedTextChannel, "allies", guildId, guild.getName) @@ -1324,23 +1137,8 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext if (neutralsTextChannel.canTalk() || (!Config.prod)) { // allow for custom channel names val channelName = neutralsTextChannel.getName - val extractName = pattern.findFirstMatchIn(channelName) - val customName = if (extractName.isDefined) { - val m = extractName.get - m.group(1) - } else "neutrals" - val onlineCategoryName = onlineListCategoryTimer.getOrElse(neutralsTextChannel.getId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryName.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (neutralsTextChannel.getId -> ZonedDateTime.now()) - if (channelName != s"$customName-0") { - try { - val channelManager = neutralsTextChannel.getManager - channelManager.setName(s"$customName-0").queue() - } catch { - case ex: Throwable => logger.info(s"Failed to rename the disabled neutral channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + val customName = presentation.OnlineListEmbeds.baseName(channelName, "neutrals") + renameOnlineChannelIfDue(neutralsTextChannel, s"$customName-0", "disabled neutral channel", guildId, guild.getName) // placeholder message updateMultiFields(List("*This channel is `disabled` and can be deleted.*"), neutralsTextChannel, "neutrals", guildId, guild.getName) } @@ -1350,23 +1148,8 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext if (enemiesTextChannel.canTalk() || (!Config.prod)) { // allow for custom channel names val channelName = enemiesTextChannel.getName - val extractName = pattern.findFirstMatchIn(channelName) - val customName = if (extractName.isDefined) { - val m = extractName.get - m.group(1) - } else "enemies" - val onlineCategoryName = onlineListCategoryTimer.getOrElse(enemiesTextChannel.getId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryName.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (enemiesTextChannel.getId -> ZonedDateTime.now()) - if (channelName != s"$customName-0") { - try { - val channelManager = enemiesTextChannel.getManager - channelManager.setName(s"$customName-0").queue() - } catch { - case ex: Throwable => logger.info(s"Failed to rename the disabled enemies channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + val customName = presentation.OnlineListEmbeds.baseName(channelName, "enemies") + renameOnlineChannelIfDue(enemiesTextChannel, s"$customName-0", "disabled enemies channel", guildId, guild.getName) // placeholder message updateMultiFields(List("*This channel is `disabled` and can be deleted.*"), enemiesTextChannel, "enemies", guildId, guild.getName) } @@ -1377,25 +1160,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val cutoff = now.minusSeconds(30 * 60) val recentStart = BotApp.startTime.isAfter(cutoff) val masslogIcon = if (masslogCategory && !recentStart) s"⚡" else "" - val categoryLiteral = guild.getCategoryById(categoryChannel) - if (categoryLiteral != null){ - val onlineCategoryCounter = onlineListCategoryTimer.getOrElse(categoryChannel, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryCounter.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (categoryChannel -> ZonedDateTime.now()) - try { - val categoryName = categoryLiteral.getName - val categoryAllies = if (alliesList.size > 0) s"🤍${alliesList.size}" else "" - val categoryEnemies = if (enemiesList.size > 0) s"💀${enemiesList.size}" else "" - val categorySpacer = if (alliesList.size > 0 || enemiesList.size > 0) "・" else "" - if (categoryName != s"${world}$categorySpacer$categoryAllies$categoryEnemies") { - val channelManager = categoryLiteral.getManager - channelManager.setName(s"${world}$categorySpacer$categoryAllies$categoryEnemies$masslogIcon").queue() - } - } catch { - case ex: Throwable => logger.info(s"Failed to rename the category channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + renameOnlineCategoryIfDue(guild, categoryChannel, world, alliesList.size, enemiesList.size, masslogIcon) } else { // separated online list channels val alliesCount = alliesList.size @@ -1407,63 +1172,23 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val masslogIcon = if (masslogCategory && !recentStart) s"⚡" else "" // add allies/enemies count to the category - val categoryLiteral = guild.getCategoryById(categoryChannel) - if (categoryLiteral != null){ - val onlineCategoryCounter = onlineListCategoryTimer.getOrElse(categoryChannel, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryCounter.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (categoryChannel -> ZonedDateTime.now()) - try { - val categoryName = categoryLiteral.getName - val categoryAllies = if (alliesList.size > 0) s"🤍${alliesList.size}" else "" - val categoryEnemies = if (enemiesList.size > 0) s"💀${enemiesList.size}" else "" - val categorySpacer = if (alliesList.size > 0 || enemiesList.size > 0) "・" else "" - if (categoryName != s"${world}$categorySpacer$categoryAllies$categoryEnemies") { - val channelManager = categoryLiteral.getManager - channelManager.setName(s"${world}$categorySpacer$categoryAllies$categoryEnemies$masslogIcon").queue() - } - } catch { - case ex: Throwable => logger.info(s"Failed to rename the category channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + renameOnlineCategoryIfDue(guild, categoryChannel, world, alliesList.size, enemiesList.size, masslogIcon) // allies grouped by Guild - val alliesGroupedByGuild: List[(String, List[String])] = vocationBuffers.values - .flatMap(_.filter(charSort => charSort.allyPlayer || charSort.allyGuild)) - .groupBy(_.guildName) - .mapValues(_.map(_.message).toList) - .toList - .partition(_._1.isEmpty) match { - case (guildless, withGuilds) => - withGuilds.sortBy { case (_, messages) => -messages.length } ++ guildless - } - - val flattenedAlliesList: List[String] = alliesGroupedByGuild.flatMap { - case ("", messages) => s"### No Guild ${messages.length}" :: messages - case (guildName, messages) => s"### [$guildName](${guildUrl(guildName)}) ${messages.length}" :: messages - } + val alliesGroupedByGuild: List[(String, List[String])] = presentation.OnlineListGrouping.groupByGuild( + vocationBuffers.values.flatten + .filter(charSort => charSort.allyPlayer || charSort.allyGuild) + .map(charSort => charSort.guildName -> charSort.message)) + + val flattenedAlliesList: List[String] = + presentation.OnlineListGrouping.withHeaders(alliesGroupedByGuild, n => s"### No Guild $n") val alliesTextChannel = guild.getTextChannelById(alliesChannel) if (alliesTextChannel != null) { if (alliesTextChannel.canTalk() || (!Config.prod)) { // allow for custom channel names val channelName = alliesTextChannel.getName - val extractName = pattern.findFirstMatchIn(channelName) - val customName = if (extractName.isDefined) { - val m = extractName.get - m.group(1) - } else "allies" - val onlineCategoryName = onlineListCategoryTimer.getOrElse(alliesTextChannel.getId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryName.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (alliesTextChannel.getId -> ZonedDateTime.now()) - if (channelName != s"$customName-$alliesCount") { - try { - val channelManager = alliesTextChannel.getManager - channelManager.setName(s"$customName-$alliesCount").queue() - } catch { - case ex: Throwable => logger.info(s"Failed to rename the allies channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + val customName = presentation.OnlineListEmbeds.baseName(channelName, "allies") + renameOnlineChannelIfDue(alliesTextChannel, s"$customName-$alliesCount", "allies channel", guildId, guild.getName) if (alliesList.nonEmpty) { updateMultiFields(flattenedAlliesList, alliesTextChannel, "allies", guildId, guild.getName) } else { @@ -1473,43 +1198,21 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } // neutrals grouped by Guild - val neutralsGroupedByGuild: List[(String, List[String])] = vocationBuffers.values - .flatMap(_.filter(charSort => !charSort.huntedPlayer && !charSort.huntedGuild && !charSort.allyPlayer && !charSort.allyGuild)) - .groupBy(_.guildName) - .mapValues(_.map(_.message).toList) - .toList - .partition(_._1.isEmpty) match { - case (guildless, withGuilds) => - withGuilds.sortBy { case (_, messages) => -messages.length } ++ guildless - } - - val flattenedNeutralsList: List[String] = neutralsGroupedByGuild.flatMap { - case ("", messages) => s"### No Guild ${messages.length}" :: messages - case (guildName, messages) => s"### [$guildName](${guildUrl(guildName)}) ${messages.length}" :: messages - } + val neutralsGroupedByGuild: List[(String, List[String])] = presentation.OnlineListGrouping.groupByGuild( + vocationBuffers.values.flatten + .filter(charSort => !charSort.huntedPlayer && !charSort.huntedGuild && !charSort.allyPlayer && !charSort.allyGuild) + .map(charSort => charSort.guildName -> charSort.message)) + + val flattenedNeutralsList: List[String] = + presentation.OnlineListGrouping.withHeaders(neutralsGroupedByGuild, n => s"### No Guild $n") val neutralsTextChannel = guild.getTextChannelById(neutralsChannel) if (neutralsTextChannel != null) { if (neutralsTextChannel.canTalk() || (!Config.prod)) { // allow for custom channel names val channelName = neutralsTextChannel.getName - val extractName = pattern.findFirstMatchIn(channelName) - val customName = if (extractName.isDefined) { - val m = extractName.get - m.group(1) - } else "neutrals" - val onlineCategoryName = onlineListCategoryTimer.getOrElse(neutralsTextChannel.getId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryName.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (neutralsTextChannel.getId -> ZonedDateTime.now()) - if (channelName != s"$customName-$neutralsCount") { - try { - val channelManager = neutralsTextChannel.getManager - channelManager.setName(s"$customName-$neutralsCount").queue() - } catch { - case ex: Throwable => logger.info(s"Failed to rename the neutrals channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + val customName = presentation.OnlineListEmbeds.baseName(channelName, "neutrals") + renameOnlineChannelIfDue(neutralsTextChannel, s"$customName-$neutralsCount", "neutrals channel", guildId, guild.getName) if (neutralsList.nonEmpty) { updateMultiFields(flattenedNeutralsList, neutralsTextChannel, "neutrals", guildId, guild.getName) } else { @@ -1519,43 +1222,21 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } // enemies grouped by Guild - val enemiesGroupedByGuild: List[(String, List[String])] = vocationBuffers.values - .flatMap(_.filter(charSort => charSort.huntedPlayer || charSort.huntedGuild)) - .groupBy(_.guildName) - .mapValues(_.map(_.message).toList) - .toList - .partition(_._1.isEmpty) match { - case (guildless, withGuilds) => - withGuilds.sortBy { case (_, messages) => -messages.length } ++ guildless - } - - val flattenedEnemiesList: List[String] = enemiesGroupedByGuild.flatMap { - case ("", messages) => s"### No Guild ${messages.length}" :: messages - case (guildName, messages) => s"### [$guildName](${guildUrl(guildName)}) ${messages.length}" :: messages - } + val enemiesGroupedByGuild: List[(String, List[String])] = presentation.OnlineListGrouping.groupByGuild( + vocationBuffers.values.flatten + .filter(charSort => charSort.huntedPlayer || charSort.huntedGuild) + .map(charSort => charSort.guildName -> charSort.message)) + + val flattenedEnemiesList: List[String] = + presentation.OnlineListGrouping.withHeaders(enemiesGroupedByGuild, n => s"### No Guild $n") val enemiesTextChannel = guild.getTextChannelById(enemiesChannel) if (enemiesTextChannel != null) { if (enemiesTextChannel.canTalk() || (!Config.prod)) { // allow for custom channel names val channelName = enemiesTextChannel.getName - val extractName = pattern.findFirstMatchIn(channelName) - val customName = if (extractName.isDefined) { - val m = extractName.get - m.group(1) - } else "enemies" - val onlineCategoryName = onlineListCategoryTimer.getOrElse(enemiesTextChannel.getId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - if (ZonedDateTime.now().isAfter(onlineCategoryName.plusMinutes(6))) { - onlineListCategoryTimer = onlineListCategoryTimer + (enemiesTextChannel.getId -> ZonedDateTime.now()) - if (channelName != s"$customName-$enemiesCount") { - try { - val channelManager = enemiesTextChannel.getManager - channelManager.setName(s"$customName-$enemiesCount").queue() - } catch { - case ex: Throwable => logger.info(s"Failed to rename the enemies channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") - } - } - } + val customName = presentation.OnlineListEmbeds.baseName(channelName, "enemies") + renameOnlineChannelIfDue(enemiesTextChannel, s"$customName-$enemiesCount", "enemies channel", guildId, guild.getName) if (enemiesList.nonEmpty) { updateMultiFields(flattenedEnemiesList, enemiesTextChannel, "enemies", guildId, guild.getName) } else { @@ -1568,14 +1249,11 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } private def updateMultiFields(values: List[String], channel: TextChannel, purgeType: String, guildId: String, guildName: String): Unit = { - var field = "" val embedColor = 3092790 //get messages try { var messages = channel.getHistory.retrievePast(100).complete().asScala.filter(m => m.getAuthor.getId.equals(BotApp.botUser)).toList.reverse.asJava - // val enemyTimer = enemiesListPurgeTimer.getOrElse(guildId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) - // if (ZonedDateTime.now().isAfter(neutralTimer.plusHours(6))) { // clear the channel every 6 hours val allyTimer = alliesListPurgeTimer.getOrElse(guildId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) val neutralTimer = neutralsListPurgeTimer.getOrElse(guildId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) @@ -1600,62 +1278,28 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext } } - var currentMessage = 0 - values.foreach { v => - val currentField = field + "\n" + v - if (currentField.length >= 4060 || (currentField.length >= 3850 && v.startsWith(s"### ["))) { // don't add field yet, there is still room - val interimEmbed = new EmbedBuilder() - interimEmbed.setDescription(field) - interimEmbed.setColor(embedColor) - if (currentMessage < messages.size) { - // edit the existing message - messages.get(currentMessage).editMessageEmbeds(interimEmbed.build()).queue() - } - else { - // there isn't an existing message to edit, so post a new one - channel.sendMessageEmbeds(interimEmbed.build()).setSuppressedNotifications(true).queue() - } - field = v - currentMessage += 1 - } else if (v.matches("### [^\\[].*")) { - if (field == "") { - field = currentField - } else { - val interimEmbed = new EmbedBuilder() - interimEmbed.setDescription(field) - interimEmbed.setColor(embedColor) - if (currentMessage < messages.size) { - // edit the existing message - messages.get(currentMessage).editMessageEmbeds(interimEmbed.build()).queue() - } - else { - // there isn't an existing message to edit, so post a new one - channel.sendMessageEmbeds(interimEmbed.build()).setSuppressedNotifications(true).queue() - } - field = v - currentMessage += 1 - } - } else { // it's full, add the field - field = currentField + // Pack the lines into embed-sized descriptions, then reconcile against the + // existing messages: edit in place where one exists, otherwise post. Only + // the trailing embed carries the "Last updated" footer + timestamp. + val fields = presentation.OnlineListEmbeds.packFields(values) + val lastIndex = fields.size - 1 + fields.zipWithIndex.foreach { case (field, currentMessage) => + val embed = new EmbedBuilder() + embed.setDescription(field) + embed.setColor(embedColor) + if (currentMessage == lastIndex) { + embed.setFooter("Last updated") + embed.setTimestamp(OffsetDateTime.now()) + } + if (currentMessage < messages.size) { + messages.get(currentMessage).editMessageEmbeds(embed.build()).queue() + } else { + channel.sendMessageEmbeds(embed.build()).setSuppressedNotifications(true).queue() } } - val finalEmbed = new EmbedBuilder() - finalEmbed.setDescription(field) - finalEmbed.setColor(embedColor) - finalEmbed.setFooter("Last updated") - val timestamp = OffsetDateTime.now() - finalEmbed.setTimestamp(timestamp) - if (currentMessage < messages.size) { - // edit the existing message - messages.get(currentMessage).editMessageEmbeds(finalEmbed.build()).queue() - } - else { - // there isn't an existing message to edit, so post a new one - channel.sendMessageEmbeds(finalEmbed.build()).setSuppressedNotifications(true).queue() - } - if (currentMessage < messages.size - 1) { - // delete extra messages - val messagesToDelete = messages.subList(currentMessage + 1, messages.size) + if (lastIndex < messages.size - 1) { + // delete extra messages left over from a previously longer list + val messagesToDelete = messages.subList(lastIndex + 1, messages.size) channel.purgeMessages(messagesToDelete) } } catch { @@ -1763,6 +1407,47 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext ) } + /** Renames a world's online-list category to reflect the live ally/enemy + * counts (and the mass-log ⚡), throttled to once per 6-minute window. The + * name-change guard intentionally ignores the ⚡ suffix, matching the + * original — so the category re-renames once after a mass-log toggle. */ + private def renameOnlineCategoryIfDue(guild: Guild, categoryId: String, world: String, alliesCount: Int, enemiesCount: Int, masslogIcon: String): Unit = { + val category = guild.getCategoryById(categoryId) + if (category != null) { + val lastRename = onlineListCategoryTimer.getOrElse(categoryId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) + if (ZonedDateTime.now().isAfter(lastRename.plusMinutes(6))) { + onlineListCategoryTimer = onlineListCategoryTimer + (categoryId -> ZonedDateTime.now()) + try { + val baseName = presentation.OnlineListEmbeds.categoryName(world, alliesCount, enemiesCount) + if (category.getName != baseName) { + category.getManager.setName(s"$baseName$masslogIcon").queue() + } + } catch { + case ex: Throwable => logger.info(s"Failed to rename the category channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}': ${ex.getMessage}") + } + } + } + } + + /** Renames an online-list text channel to `targetName`, throttled to at most + * once per 6-minute window per channel (tracked in onlineListCategoryTimer) + * and skipped when the name is already correct. Rename failures (e.g. missing + * Manage Channels) are logged, not fatal — `label` names the channel in the + * log line. */ + private def renameOnlineChannelIfDue(channel: TextChannel, targetName: String, label: String, guildId: String, guildName: String): Unit = { + val lastRename = onlineListCategoryTimer.getOrElse(channel.getId, ZonedDateTime.parse("2022-01-01T01:00:00Z")) + if (ZonedDateTime.now().isAfter(lastRename.plusMinutes(6))) { + onlineListCategoryTimer = onlineListCategoryTimer + (channel.getId -> ZonedDateTime.now()) + if (channel.getName != targetName) { + try { + channel.getManager.setName(targetName).queue() + } catch { + case ex: Throwable => logger.info(s"Failed to rename the $label for Guild ID: '$guildId' Guild Name: '$guildName': ${ex.getMessage}") + } + } + } + } + // Helper method to queue messages with rate limiting private def sendMessageWithRateLimit( channel: TextChannel, diff --git a/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala b/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala index ef07884..1d8171c 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala @@ -4,18 +4,20 @@ import com.tibiabot.tibiadata.TibiaDataClient import com.typesafe.scalalogging.StrictLogging import scala.concurrent.duration._ -import scala.concurrent.{Await, ExecutionContextExecutor, Future} +import scala.concurrent.Await import scala.util.{Failure, Success, Try} -import java.time.ZonedDateTime +import java.time.{Duration, ZonedDateTime} object WorldManager extends StrictLogging { implicit private val system: akka.actor.ActorSystem = akka.actor.ActorSystem() - implicit private val executionContext: ExecutionContextExecutor = scala.concurrent.ExecutionContext.global private val tibiaDataClient = new TibiaDataClient() - private var cachedWorldList: Option[List[String]] = None - private var lastFetchTime: Option[ZonedDateTime] = None + + // The world list changes only at major game updates, so cache it for an hour + // instead of making a blocking API call on every getWorldList() (e.g. once per + // /leaderboards). Falls back to the last good value, then the static list. + private val cacheTtl = Duration.ofHours(1) // Fallback static world list in case API fails private val fallbackWorldList = List( @@ -34,30 +36,30 @@ object WorldManager extends StrictLogging { "Idyllia", "Hostera", "Dracobra", "Xymera", "Blumera", "Monstera", "Tempestera", "Terribra", "Sombra", "Eclipta", "Kalanta", "Citra", "Kanda", "Opulera", "Ignibra", "Maligna", "Junera", "Floribra" ) - def getWorldList(): List[String] = { - logger.info("Fetching world list from TibiaData API...") - refreshWorldList() - } + private val worldListCache = new CachedList[String]( + fetch = () => fetchWorldNames(), + fallback = fallbackWorldList, + ttl = cacheTtl, + now = () => ZonedDateTime.now() + ) + + def getWorldList(): List[String] = worldListCache.get() - private def refreshWorldList(): List[String] = { - Try { - val worldsResponse = Await.result(tibiaDataClient.getWorlds(), Duration(30, "seconds")) - worldsResponse match { - case Right(response) => - val worldNames = response.worlds.regular_worlds.map(_.name).sorted - cachedWorldList = Some(worldNames) - lastFetchTime = Some(ZonedDateTime.now()) - logger.info(s"Successfully fetched ${worldNames.length} worlds from TibiaData API") - worldNames - case Left(error) => - logger.warn(s"Failed to fetch worlds from API: $error, using fallback list") - cachedWorldList.getOrElse(fallbackWorldList) - } - } match { - case Success(worlds) => worlds + /** One blocking fetch of the sorted regular-world names, as an Either so the + * cache can decide whether to keep the previous value on failure. */ + private def fetchWorldNames(): Either[String, List[String]] = { + logger.info("Fetching world list from TibiaData API...") + Try(Await.result(tibiaDataClient.getWorlds(), 30.seconds)) match { + case Success(Right(response)) => + val worldNames = response.worlds.regular_worlds.map(_.name).sorted + logger.info(s"Successfully fetched ${worldNames.length} worlds from TibiaData API") + Right(worldNames) + case Success(Left(error)) => + logger.warn(s"Failed to fetch worlds from API: $error, using last good / fallback list") + Left(error) case Failure(exception) => - logger.error(s"Exception while fetching worlds from API: ${exception.getMessage}, using fallback list") - cachedWorldList.getOrElse(fallbackWorldList) + logger.error(s"Exception while fetching worlds from API: ${exception.getMessage}, using last good / fallback list") + Left(exception.getMessage) } } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala b/tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala index ca898e7..084639c 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/admin/AdminService.scala @@ -5,8 +5,8 @@ import com.tibiabot.discord.DiscordGateway import com.typesafe.scalalogging.StrictLogging import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.{Guild, MessageEmbed} +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel -import scala.collection.mutable.ListBuffer /** * Bot-creator-only `/admin` operations, moved from BotApp. The shared `dreamScar` @@ -20,6 +20,23 @@ final class AdminService( resyncDreamScar: () => Unit ) extends StrictLogging { + /** Post a "bot creator ran a command" notice to a guild's admin/command-log + * channel. No-op if the channel is missing or the bot can't talk there. */ + private def postCreatorLog(adminChannel: TextChannel, description: String, thumbnail: String): Unit = + if (adminChannel != null && (adminChannel.canTalk() || !Config.prod)) { + try { + val adminEmbed = new EmbedBuilder() + .setTitle(s"${Config.noEmoji} The creator of the bot has run a command:") + .setDescription(description) + .setThumbnail(thumbnail) + .setColor(com.tibiabot.presentation.Embeds.BrandColor) + adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() + } catch { + case ex: Throwable => + logger.info(s"Failed to send admin message for Guild ID: '${adminChannel.getGuild.getId}' Guild Name: '${adminChannel.getGuild.getName}'", ex) + } + } + /** Leave a guild, posting the reason to its admin channel first. */ def leave(guildId: String, reason: String): MessageEmbed = { val guild = discordGateway.guildById(guildId) @@ -30,37 +47,20 @@ final class AdminService( embedMessage = s":gear: The bot has left the Guild: **${guild.getName()}** without leaving a message for the owner." } else { val adminChannel = guild.getTextChannelById(discordInfo("admin_channel")) - if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - try { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} The creator of the bot has run a command:") - adminEmbed.setDescription(s"<@$botUserId> has left your discord because of the following reason:\n> ${reason}") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Abacus.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Throwable => logger.info(s"Failed to send admin message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - } - } - } + postCreatorLog(adminChannel, + s"<@$botUserId> has left your discord because of the following reason:\n> $reason", + "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Abacus.gif") embedMessage = s":gear: The bot has left the Guild: **${guild.getName()}** and left a message for the owner." } guild.leave().queue() - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedMessage) - .build() + com.tibiabot.presentation.Embeds.response(embedMessage) } /** Re-fetch the Dream Courts boss-of-the-day per world. */ def resyncDreamCourtBosses(): MessageEmbed = { resyncDreamScar() - new EmbedBuilder() - .setColor(3092790) - .setDescription(s":gear: The dreamcourts bosses for each world have been resynced.") - .build() + com.tibiabot.presentation.Embeds.response(s":gear: The dreamcourts bosses for each world have been resynced.") } /** Forward a message from the bot creator to a guild's admin channel. */ @@ -74,27 +74,17 @@ final class AdminService( } else { val adminChannel = guild.getTextChannelById(discordInfo("admin_channel")) if (adminChannel != null) { - if (adminChannel.canTalk() || !(Config.prod)) { - try { - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} The creator of the bot has run a command:") - adminEmbed.setDescription(s"<@$botUserId> has forwarded a message from the bot's creator:\n> ${message}") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Letter.gif") - adminEmbed.setColor(3092790) - adminChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Throwable => logger.info(s"Failed to send admin message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } + postCreatorLog(adminChannel, + s"<@$botUserId> has forwarded a message from the bot's creator:\n> $message", + "https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Letter.gif") + embedMessage = s":gear: The bot has left a message for the Guild: **${guild.getName()}**." } else { + // Previously a trailing assignment overwrote this, so the "channel deleted" + // feedback was unreachable and /admin message always reported success. embedMessage = s"${Config.noEmoji} The Guild: **${guild.getName()}** has deleted the `command-log` channel, so a message cannot be sent." } - embedMessage = s":gear: The bot has left a message for the Guild: **${guild.getName()}**." } - new EmbedBuilder() - .setColor(3092790) - .setDescription(embedMessage) - .build() + com.tibiabot.presentation.Embeds.response(embedMessage) } /** Paginated list of every guild the bot is in, delivered via callback. */ @@ -102,22 +92,9 @@ final class AdminService( val allGuilds = discordGateway.guilds val allGuildsCleaned: List[String] = allGuilds.map(guild => s"**${guild.getName}** - `${guild.getId}`") logger.info(allGuildsCleaned.toString) - val embedBuffer = ListBuffer[MessageEmbed]() - var field = "" - allGuildsCleaned.foreach { v => - val currentField = field + "\n" + v - if (currentField.length <= 3000) { - field = currentField - } else { - val interimEmbed = new EmbedBuilder() - interimEmbed.setDescription(field) - embedBuffer += interimEmbed.build() - field = v - } + val embeds = com.tibiabot.presentation.ListEmbeds.pack(allGuildsCleaned, 3000).map { description => + new EmbedBuilder().setDescription(description).build() } - val finalEmbed = new EmbedBuilder() - finalEmbed.setDescription(field) - embedBuffer += finalEmbed.build() - callback(embedBuffer.toList) + callback(embeds) } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala b/tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala index 68a9bc3..dd74eb5 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/boosted/BoostedService.scala @@ -1,9 +1,9 @@ package com.tibiabot.boosted import com.tibiabot.Config -import com.tibiabot.domain.BoostedStamp +import com.tibiabot.domain.{BoostedStamp, BoostedName} import com.tibiabot.persistence.{BoostedRepository, ConnectionProvider} -import com.tibiabot.presentation.Urls +import com.tibiabot.presentation.{Urls, EmbedText} import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.MessageEmbed @@ -12,8 +12,8 @@ import scala.collection.mutable.ListBuffer /** * Per-user boosted boss/creature notification subscriptions * (the boosted_notifications table) and the /boosted command logic. - * Moved verbatim from BotApp; the private helpers mirror the former - * BotApp.capitalizeAllWords / creatureWikiUrl. + * Moved verbatim from BotApp; name capitalisation is shared via + * presentation.Names and the private creatureWikiUrl mirrors BotApp's. */ final class BoostedService( connectionProvider: ConnectionProvider, @@ -26,13 +26,48 @@ final class BoostedService( def boostedList(userId: String): Boolean = boostedRepository.forUser(userId).exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") - private def capitalizeAllWords(s: String): String = s.split(" ").map(_.capitalize).mkString(" ") - private def creatureWikiUrl(creature: String): String = Urls.creatureWikiUrl(creature, Config.creatureUrlMappings) + // User-facing /boosted notification-list status messages, shared so the wording + // stays consistent. They were duplicated inline ~6x and had already drifted: a + // misspelling of "bosses" had crept into several copies but not others. + private def filterListMessage(list: String): String = + s"${Config.letterEmoji} You will be messaged if any of the following **bosses** or **creatures** are boosted:\n\n$list" + private val allBoostedMessage: String = + s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." + private val emptyListMessage: String = + s"${Config.letterEmoji} Your notification list is *empty*." + + /** Render a boosted entry's name: a wiki-linked bold name for a boss/creature, + * plain bold for the "all" group. */ + private def boostedNameMarkdown(boostedName: String, group: String): String = { + val name = com.tibiabot.presentation.Names.capitalizeWords(boostedName) + if (group == "boss" || group == "creature") s"**[$name](${creatureWikiUrl(name)})**" + else s"**$name**" + } + + /** Group a user's boosted subscriptions by type, sort each group by name, and + * render each as " ", newline-joined. */ + private def renderBoostedEntries(entries: List[BoostedStamp]): String = + entries + .groupBy(_.boostedType) + .view.mapValues(_.sortBy(_.boostedName.toLowerCase)) + .toSeq + .sortBy(_._1) + .flatMap { case (group, names) => + names.map { boosted => + val emoji = + if (group == "boss") Config.bossEmoji + else if (group == "creature") Config.creatureEmoji + else Config.indentEmoji + s"$emoji ${boostedNameMarkdown(boosted.boostedName, group)}" + } + }.mkString("\n") + def boosted(userId: String, boostedOption: String, boostedName: String): MessageEmbed = { val conn = connectionProvider.cache() + try { var embedMessage = s"${Config.noEmoji} This command failed to run, try again?" val statement = conn.createStatement() @@ -69,49 +104,23 @@ final class BoostedService( } statement.close() - val sanitizedName = boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase + val sanitizedName = BoostedName.sanitize(boostedName) val existingNames = boostedStampList.toList val replyEmbed = new EmbedBuilder() - replyEmbed.setColor(3092790) + replyEmbed.setColor(com.tibiabot.presentation.Embeds.BrandColor) if (boostedOption == "list") { // UNFINISHED if (existingNames.size > 0) { val listSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") - val groupedAndSorted = existingNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - embedMessage = if (listSetting) s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." else s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" - val combinedMessage = embedMessage - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = embedMessage.lastIndexOf('\n', (4090 - (substituteText.size))) - val truncatedMessage = embedMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText - } else { - embedMessage = combinedMessage - } + val groupedAndSorted = renderBoostedEntries(existingNames) + embedMessage = if (listSetting) allBoostedMessage else filterListMessage(groupedAndSorted) + embedMessage = EmbedText.fit(embedMessage) } else { - embedMessage = s"${Config.letterEmoji} Your notification list is *empty*." + embedMessage = emptyListMessage } } else if (boostedOption == "add"){ if (sanitizedName != "") { - if (existingNames.exists(_.boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase == sanitizedName)) { + if (existingNames.exists(bs => BoostedName.sanitize(bs.boostedName) == sanitizedName)) { embedMessage = s"${Config.noEmoji} **$sanitizedName** already exists." } else { if (sanitizedName == "all") { @@ -129,45 +138,17 @@ final class BoostedService( val isBoostedBoss = boostedBosses().exists(_.equalsIgnoreCase(sanitizedName)) // Check if sanitizedName is a valid creature - //val boostedCreature: Future[Either[String, RaceResponse]] = tibiaDataClient.getCreature(sanitizedName) - - val dreamcourtCheck: Boolean = if (List("plagueroot","malofur mangrinder","maxxenius","alptramun","izcandar the banished").contains(sanitizedName.toLowerCase)) true else false + val dreamcourtCheck: Boolean = com.tibiabot.domain.time.DreamScarCycle.isDreamCourtBoss(sanitizedName) val creatureCheck: Boolean = if (Config.creaturesList.contains(sanitizedName.toLowerCase)) true else false val monsterType = if (isBoostedBoss) "boss" else if (creatureCheck) "creature" else "all" if (dreamcourtCheck){ - embedMessage = s"${Config.noEmoji} dreamcourt bosses arn't supported yet." + embedMessage = s"${Config.noEmoji} Dream Court bosses aren't supported yet." } else { if (monsterType == "all") { - val groupedAndSorted = existingNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - val listMessage = if (groupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." + val groupedAndSorted = renderBoostedEntries(existingNames) + val listMessage = if (groupedAndSorted.trim != "") filterListMessage(groupedAndSorted) else emptyListMessage val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not a valid `boss` or `creature`." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } + embedMessage = EmbedText.fit(listMessage, commandMessage) } else { val query = "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" val preparedStatement = conn.prepareStatement(query) @@ -178,37 +159,11 @@ final class BoostedService( preparedStatement.close() val newNames = existingNames :+ BoostedStamp(userId, monsterType, sanitizedName) - val groupedAndSorted = newNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - val listMessage = if (groupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" else s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." + val groupedAndSorted = renderBoostedEntries(newNames) + val listMessage = if (groupedAndSorted.trim != "") filterListMessage(groupedAndSorted) else allBoostedMessage val commandMessage = s"${Config.yesEmoji} **$sanitizedName** was added." //WIP - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } + embedMessage = EmbedText.fit(listMessage, commandMessage) } } } @@ -218,124 +173,54 @@ final class BoostedService( val isBoostedBoss = boostedBosses().exists(_.equalsIgnoreCase(sanitizedName)) // Check if sanitizedName is a valid creature - /** - val boostedCreature: Future[Either[String, RaceResponse]] = tibiaDataClient.getCreature(sanitizedName) - val creatureCheck: Future[Boolean] = boostedCreature.map { - case Right(raceResponse) => - raceResponse.creature.isDefined - case Left(errorMessage) => false - } - **/ val creatureCheck: Boolean = if (Config.creaturesList.contains(sanitizedName.toLowerCase)) true else false val monsterType = if (isBoostedBoss) "boss" else if (creatureCheck) "creature" else "all" val listSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") val newNames = existingNames :+ BoostedStamp(userId, monsterType, boostedName) - val groupedAndSorted = newNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - names.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") - val listMessage = if (listSetting) s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." else s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$groupedAndSorted" + val groupedAndSorted = renderBoostedEntries(newNames) + val listMessage = if (listSetting) allBoostedMessage else filterListMessage(groupedAndSorted) val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not a valid `boss` or `creature`." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } + embedMessage = EmbedText.fit(listMessage, commandMessage) } } else if (boostedOption == "remove"){ - val filteredGroupedAndSorted = existingNames - .groupBy(_.boostedType) - .mapValues(_.sortBy(_.boostedName.toLowerCase)) // Sort within each group by name - .toSeq - .sortBy(_._1) // Sort groups by type - .flatMap { case (group, names) => - val filteredNames = names.filterNot(bs => bs.boostedName.toLowerCase == sanitizedName) - - filteredNames.map { boosted => - val emoji = - if (group == "boss") Config.bossEmoji - else if (group == "creature") Config.creatureEmoji - else Config.indentEmoji - - val nameWithLink = - if (group == "boss" || group == "creature") s"**[${capitalizeAllWords(boosted.boostedName)}](${creatureWikiUrl(capitalizeAllWords(boosted.boostedName))})**" - else s"**${capitalizeAllWords(boosted.boostedName)}**" - - s"$emoji $nameWithLink" - } - }.mkString("\n") + val filteredGroupedAndSorted = renderBoostedEntries(existingNames.filterNot(_.boostedName.toLowerCase == sanitizedName)) if (sanitizedName == "all") { - var query = "DELETE FROM boosted_notifications WHERE userid = ?" + val query = "DELETE FROM boosted_notifications WHERE userid = ?" val preparedStatement = conn.prepareStatement(query) preparedStatement.setString(1, userId) preparedStatement.executeUpdate() preparedStatement.close() embedMessage = s"${Config.yesEmoji} you have disabled notifications for **all** bosses and creatures." - } else if (existingNames.exists(_.boostedName.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase == sanitizedName)) { - var query = "DELETE FROM boosted_notifications WHERE userid = ? AND LOWER(name) = LOWER(?)" + } else if (existingNames.exists(bs => BoostedName.sanitize(bs.boostedName) == sanitizedName)) { + val query = "DELETE FROM boosted_notifications WHERE userid = ? AND LOWER(name) = LOWER(?)" val preparedStatement = conn.prepareStatement(query) preparedStatement.setString(1, userId) preparedStatement.setString(2, sanitizedName) preparedStatement.executeUpdate() preparedStatement.close() - val listMessage = if (filteredGroupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$filteredGroupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." + val listMessage = if (filteredGroupedAndSorted.trim != "") filterListMessage(filteredGroupedAndSorted) else emptyListMessage val commandMessage = s"${Config.yesEmoji} you removed **$sanitizedName** from the list." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } + embedMessage = EmbedText.fit(listMessage, commandMessage) } else { - val listMessage = if (filteredGroupedAndSorted.trim != "") s"${Config.letterEmoji} You will be messaged if any of the following **booses** or **creatures** are boosted:\n\n$filteredGroupedAndSorted" else s"${Config.letterEmoji} Your notification list is *empty*." + val listMessage = if (filteredGroupedAndSorted.trim != "") filterListMessage(filteredGroupedAndSorted) else emptyListMessage val commandMessage = s"${Config.noEmoji} **$sanitizedName** is not on your list." - val combinedMessage = listMessage + s"\n\n$commandMessage" - if (combinedMessage.size >= 4096) { - val substituteText = "\n\n*`...cannot display any more results`*" - val lastLineIndex = listMessage.lastIndexOf('\n', (4090 - (substituteText.size + commandMessage.size))) - val truncatedMessage = listMessage.substring(0, lastLineIndex) - embedMessage = truncatedMessage + substituteText + s"\n\n$commandMessage" - } else { - embedMessage = combinedMessage - } + embedMessage = EmbedText.fit(listMessage, commandMessage) } // } else if (boostedOption == "toggle"){ val existingSetting = existingNames.exists(bs => bs.user == userId && bs.boostedName.toLowerCase == "all") if (existingSetting) { - var query = "DELETE FROM boosted_notifications WHERE userid = ?" + val query = "DELETE FROM boosted_notifications WHERE userid = ?" val preparedStatement = conn.prepareStatement(query) preparedStatement.setString(1, userId) preparedStatement.executeUpdate() preparedStatement.close() // WIP Message - embedMessage = s"${Config.letterEmoji} Your notification list is *empty*." + embedMessage = emptyListMessage } else { val query = "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING" val preparedStatement = conn.prepareStatement(query) @@ -344,11 +229,11 @@ final class BoostedService( preparedStatement.setString(3, "all") preparedStatement.executeUpdate() preparedStatement.close() - embedMessage = s"${Config.letterEmoji} You will be notified for **all** boosted **bosses** and **creatures** at *server save*." + embedMessage = allBoostedMessage } // } else if (boostedOption == "disable") { - var query = "DELETE FROM boosted_notifications WHERE userid = ?" + val query = "DELETE FROM boosted_notifications WHERE userid = ?" val preparedStatement = conn.prepareStatement(query) preparedStatement.setString(1, userId) preparedStatement.executeUpdate() @@ -357,7 +242,9 @@ final class BoostedService( embedMessage = s"${Config.yesEmoji} you have **disabled** notifications for **all** bosses and creatures." } - conn.close() replyEmbed.setDescription(embedMessage).build() + } finally { + conn.close() // always release the connection, even if a query above threw + } } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala index 696a177..fcbc6d9 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/Permissions.scala @@ -2,6 +2,7 @@ package com.tibiabot.commands import net.dv8tion.jda.api.Permission import net.dv8tion.jda.api.entities.Member +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent /** Centralized command authorization checks. */ object Permissions { @@ -13,4 +14,10 @@ object Permissions { /** True if the member may run server-management commands. */ def hasManageServer(member: Member): Boolean = member != null && member.hasPermission(Permission.MANAGE_SERVER) + + /** True if the user who triggered `event` may run server-management commands. + * Resolves the caller's Member (a blocking retrieve) then defers to + * [[hasManageServer]]. */ + def callerHasManageServer(event: SlashCommandInteractionEvent): Boolean = + hasManageServer(event.getGuild.retrieveMember(event.getUser).complete()) } diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/SlashRouting.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/SlashRouting.scala new file mode 100644 index 0000000..e000674 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/SlashRouting.scala @@ -0,0 +1,32 @@ +package com.tibiabot.commands + +import com.tibiabot.commands.handlers._ +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +/** The slash-command dispatch table: command name -> handler. Kept separate from + * BotListener so it is unit-testable (SlashRoutingSpec checks it stays in step + * with the registered command schemas) and so adding a command is a one-line + * edit here next to the other routing config. + * + * Building this map only initialises the (stateless) handler objects, never + * BotApp, so it is cheap to reference from a test. */ +object SlashRouting { + + val handlers: Map[String, SlashCommandInteractionEvent => Unit] = Map( + "setup" -> (ChannelCommands.setup _), + "remove" -> (ChannelCommands.remove _), + "hunted" -> (HuntedCommands.handle _), + "allies" -> (AlliesCommands.handle _), + "neutral" -> (NeutralCommands.handle _), + "fullbless" -> (FullblessCommands.handle _), + "filter" -> (FilterCommands.handle _), + "admin" -> (AdminCommands.handle _), + "exiva" -> (ExivaCommands.handle _), + "help" -> (HelpCommands.handle _), + "repair" -> (ChannelCommands.repair _), + "galthen" -> (GalthenCommands.handle _), + "online" -> (OnlineListCommands.handle _), + "boosted" -> (BoostedCommands.handle _), + "leaderboards" -> (LeaderboardCommands.handle _) + ) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala index 74b30aa..a45a562 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/AlliesCommands.scala @@ -17,29 +17,23 @@ object AlliesCommands { val reasonOption: String = options.getOrElse("reason", "none") val worldOption: String = options.getOrElse("world", "") - var authed = false - val user = event.getUser // Get the user who ran the command - val guild = event.getGuild - val member = guild.retrieveMember(user).complete() - if (Permissions.hasManageServer(member)) { - authed = true - } + val authed = Permissions.callerHasManageServer(event) subCommand match { case "player" => if (authed) { if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.addAlly(event, "player", nameOption, reasonOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.removeAlly(event, "player", nameOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } @@ -50,17 +44,17 @@ object AlliesCommands { case "guild" => if (authed) { if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.addAlly(event, "guild", nameOption, reasonOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.removeAlly(event, "guild", nameOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala index 07b0e8b..32951bb 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/BoostedCommands.scala @@ -31,7 +31,7 @@ object BoostedCommands { } } else { val embed = new EmbedBuilder() - .setDescription(s"${Config.noEmoji} Invalid option for `/boosted`.").setColor(3092790).build() + .setDescription(s"${Config.noEmoji} Invalid option for `/boosted`.").setColor(com.tibiabot.presentation.Embeds.BrandColor).build() event.getHook.sendMessageEmbeds(embed).queue() } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala index b805da7..671d1f5 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/GalthenCommands.scala @@ -2,6 +2,7 @@ package com.tibiabot.commands.handlers import com.tibiabot.BotApp import com.tibiabot.BotApp.SatchelStamp +import com.tibiabot.domain.time.SatchelCooldown import com.tibiabot.presentation.GalthenEmbeds import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent @@ -29,13 +30,13 @@ object GalthenCommands { case Some(satchelTimeList) => val tagList = satchelTimeList.collect { case satchel if tagOption.equalsIgnoreCase(satchel.tag) => - val when = satchel.when.plusDays(30).toEpochSecond.toString() + val when = SatchelCooldown.expiresAtEpoch(satchel.when) s"<:satchel:1030348072577945651> can be collected by **`${satchel.tag}`** " } val fullList = satchelTimeList.collect { case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() + val when = SatchelCooldown.expiresAtEpoch(satchel.when) val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" s"<:satchel:1030348072577945651> can be collected by $displayTag " } diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala index 1811f44..9be56d0 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/HuntedCommands.scala @@ -17,29 +17,23 @@ object HuntedCommands { val nameOption: String = options.getOrElse("name", "") val reasonOption: String = options.getOrElse("reason", "none") - var authed = false - val user = event.getUser // Get the user who ran the command - val guild = event.getGuild - val member = guild.retrieveMember(user).complete() - if (Permissions.hasManageServer(member)) { - authed = true - } + val authed = Permissions.callerHasManageServer(event) subCommand match { case "player" => if (authed) { if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.addHunted(event, "player", nameOption, reasonOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.removeHunted(event, "player", nameOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } @@ -50,17 +44,17 @@ object HuntedCommands { case "guild" => if (authed) { if (toggleOption == "add") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.addHunted(event, "guild", nameOption, reasonOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } else if (toggleOption == "remove") { - BotApp.activityCommandBlocker += (event.getGuild.getId -> true) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> true)) BotApp.removeHunted(event, "guild", nameOption, embed => { event.getHook.sendMessageEmbeds(embed).queue(_ => { - BotApp.activityCommandBlocker += (event.getGuild.getId -> false) + BotApp.modifyActivityCommandBlocker(_ + (event.getGuild.getId -> false)) }) }) } diff --git a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala index 9620391..29e4f9e 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/commands/handlers/NeutralCommands.scala @@ -1,6 +1,7 @@ package com.tibiabot.commands.handlers import com.tibiabot.{BotApp, Config} +import com.tibiabot.presentation.Embeds.BrandColor import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent @@ -34,7 +35,7 @@ object NeutralCommands { val labelOption: String = sanitizeLabel(options.getOrElse("label", "")) val emojiOption: String = options.getOrElse("emoji", "").trim if (labelOption == "" || emojiOption == ""){ - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You must supply a **label** and **emoji** when tagging a guild or player.").setColor(3092790).build() + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} You must supply a **label** and **emoji** when tagging a guild or player.").setColor(BrandColor).build() event.getHook.sendMessageEmbeds(embed).queue() } else { if (isValidEmoji(emojiOption)) { @@ -42,7 +43,7 @@ object NeutralCommands { event.getHook.sendMessageEmbeds(embed).queue() }) } else { - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} The provided emoji is invalid - use a standard discord emoji.\n:warning: Custom emojis are not supported.").setColor(3092790).build() + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} The provided emoji is invalid - use a standard discord emoji.\n:warning: Custom emojis are not supported.").setColor(BrandColor).build() event.getHook.sendMessageEmbeds(embed).queue() } } @@ -62,7 +63,7 @@ object NeutralCommands { } } case other => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommandGroup '$other' for `/neutral`.").setColor(3092790).build() + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommandGroup '$other' for `/neutral`.").setColor(BrandColor).build() event.getHook.sendMessageEmbeds(embed).queue() } } else { @@ -84,7 +85,7 @@ object NeutralCommands { event.getHook.sendMessageEmbeds(embed).queue() } case other => - val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/neutral`.").setColor(3092790).build() + val embed = new EmbedBuilder().setDescription(s"${Config.noEmoji} Invalid subcommand '$other' for `/neutral`.").setColor(BrandColor).build() event.getHook.sendMessageEmbeds(embed).queue() } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/BoostedName.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/BoostedName.scala new file mode 100644 index 0000000..af13cf8 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/BoostedName.scala @@ -0,0 +1,10 @@ +package com.tibiabot.domain + +/** Normalises a player-typed boss/creature name for matching against stored + * boosted subscriptions. Keeps letters, apostrophes, hyphens and whitespace (so + * names like "Yselda's" or "Mega-Magmaoid" survive), drops everything else + * (digits, punctuation), then trims and lowercases. Pure; see BoostedNameSpec. */ +object BoostedName { + def sanitize(raw: String): String = + raw.replaceAll("[^a-zA-Z'\\-\\s]", "").trim.toLowerCase +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/BossAliases.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/BossAliases.scala new file mode 100644 index 0000000..c2f85cc --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/BossAliases.scala @@ -0,0 +1,103 @@ +package com.tibiabot.domain + +/** Maps the shorthand / alternative boss names players type in the `/boosted add` + * modal to the canonical boss name the boosted filters and API use. A pure lookup, + * built once (it was previously rebuilt inline on every modal submission). */ +object BossAliases { + + private val aliases: Map[String, String] = Map( + "oberon" -> "grand master oberon", + "scarlett" -> "scarlett etzel", + "scarlet" -> "scarlett etzel", + "timira" -> "timira the many-headed", + "timira the many headed" -> "timira the many-headed", + "timira many headed" -> "timira the many-headed", + "timira many-headed" -> "timira the many-headed", + "magma" -> "magma bubble", + "rotten final" -> "bakragore", + "yselda" -> "megasylvan yselda", + "zelos" -> "king zelos", + "despor" -> "dragon pack", + "dragon hoard" -> "dragon pack", + "vengar" -> "dragon pack", + "maliz" -> "dragon pack", + "bruton" -> "dragon pack", + "greedok" -> "dragon pack", + "vilear" -> "dragon pack", + "crultor" -> "dragon pack", + "dragon boss" -> "dragon pack", + "dragon bosses" -> "dragon pack", + "thorn knight" -> "the enraged thorn knight", + "the thorn knight" -> "the enraged thorn knight", + "shielded thorn knight" -> "the enraged thorn knight", + "the shielded thorn knight" -> "the enraged thorn knight", + "mounted thorn knight" -> "the enraged thorn knight", + "the mounted thorn knight" -> "the enraged thorn knight", + "paleworm" -> "the paleworm", + "unwelcome" -> "the unwelcome", + "yirkas" -> "yirkas blue scales", + "vok" -> "vok the feakish", + "irgix" -> "irgix the flimsy", + "unaz" -> "unaz the mean", + "utua" -> "utua stone sting", + "katex" -> "katex blood tongue", + "voidborn" -> "the unarmored voidborn", + "the voidborn" -> "the unarmored voidborn", + "unarmored voidborn" -> "the unarmored voidborn", + "urmahlullu" -> "urmahlullu the weakened", + "winter bloom" -> "the winter bloom", + "time guardian" -> "the time guardian", + "souldespoiler" -> "the souldespoiler", + "scourge of oblivion" -> "the scourge of oblivion", + "lib final" -> "the scourge of oblivion", + "lb final" -> "the scourge of oblivion", + "sandking" -> "the sandking", + "nightmare beast" -> "the nightmare beast", + "moonlight aster" -> "the moonlight aster", + "monster" -> "the monster", + "ingol boss" -> "the monster", + "ingol final" -> "the monster", + "mega magmaoid" -> "the mega magmaoid", + "lily of night" -> "the lily of night", + "flaming orchid" -> "the flaming orchid", + "fear feaster" -> "the fear feaster", + "false god" -> "the false god", + "enraged thorn knight" -> "the enraged thorn knight", + "dread maiden" -> "the dread maiden", + "diamond blossom" -> "the diamond blossom", + "brainstealer" -> "the brainstealer", + "blazing rose" -> "the blazing rose", + "srezz" -> "srezz yellow eyes", + "werelion serpent spawn" -> "srezz yellow eyes", + "werelions serpent spawn" -> "srezz yellow eyes", + "werelion goanna" -> "yirkas blue scales", + "werelions goanna" -> "yirkas blue scales", + "werelion scorpion" -> "utua stone sting", + "werelions scorpion" -> "utua stone sting", + "werelion hyena" -> "katex blood tongue", + "werelions hyena" -> "katex blood tongue", + "werelion hyaena" -> "katex blood tongue", + "werelions hyaena" -> "katex blood tongue", + "werelion werehyena" -> "katex blood tongue", + "werelions werehyena" -> "katex blood tongue", + "werelion werehyaena" -> "katex blood tongue", + "werelions werehyaena" -> "katex blood tongue", + "dragon king" -> "soul of dragonking zyrtarch", + "zyrtarch" -> "soul of dragonking zyrtarch", + "dragonking zyrtarch" -> "soul of dragonking zyrtarch", + "dragon king zyrtarch" -> "soul of dragonking zyrtarch", + "dragonking zyrtarch" -> "soul of dragonking zyrtarch", + "dragonking" -> "soul of dragonking zyrtarch", + "tenebris" -> "lady tenebris", + "ratmiral" -> "ratmiral blackwhiskers", + "plague seal" -> "plagirath", + "pumin seal" -> "tarbaz", + "jugg seal" -> "razzagorn", + "vexclaw seal" -> "shulgrax", + "undead seal" -> "ragiaz" + ) + + /** The canonical boss name for `name` (an already-lowercased player input), or + * `name` unchanged when it is not a known alias. */ + def canonical(name: String): String = aliases.getOrElse(name, name) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala new file mode 100644 index 0000000..bea4426 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala @@ -0,0 +1,46 @@ +package com.tibiabot.domain + +/** Pure interpretation of a Tibia death "killer" entry, used when assembling + * the death-notification text. Extracted from TibiaBot's death block so the + * fiddly edge cases (summon-vs-player detection, English articles) are + * unit-testable; pinned by KillersSpec. */ +object Killers { + + /** Death sources that read as a substance/environment rather than a creature. + * They take NO article ("died by energy", not "died by an energy"), and are the + * killer names that denote an environmental death — `presentation.DeathEffect` + * draws its effect-animation keys from this same vocabulary. */ + val substanceSources: Set[String] = + Set("death", "earth", "energy", "fire", "ice", "holy", "a trap", "agony", "life drain", "drowning") + + /** Indefinite article ("a"/"an") for a name, chosen by its first letter. */ + def article(name: String): String = + name.take(1).toLowerCase match { + case "a" | "e" | "i" | "o" | "u" => "an" + case _ => "a" + } + + /** Article for an all-lowercase death source, WITH a trailing space, or "" + * for the substance-like sources above. The caller only uses this for + * uncapitalised names (capitalised names are bosses and take no article). */ + def sourceArticle(name: String): String = + if (substanceSources.contains(name)) "" else s"${article(name)} " + + /** A killer entry like "fire elemental of Violent Beams" is a *summon*: the + * creature part before " of " is lowercase. "Knight of Flame" is a player + * whose name merely contains " of " (the leading part is capitalised), so it + * is NOT a summon. Returns Some((creature, summoner)) for a summon, else None. */ + def parseSummon(name: String): Option[(String, String)] = { + val parts = name.split(" of ", 2) + if (parts.length > 1 && !parts(0).exists(_.isUpper)) Some((parts(0), parts(1))) + else None + } + + /** Join killer entries into one phrase: "a", "a and b", "a, b and c". Empty + * for no killers (the caller renders that as a suicide). */ + def joinNatural(parts: Seq[String]): String = parts match { + case Seq() => "" + case Seq(one) => one + case _ => parts.init.mkString(", ") + " and " + parts.last + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Vocations.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Vocations.scala new file mode 100644 index 0000000..b31399e --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Vocations.scala @@ -0,0 +1,11 @@ +package com.tibiabot.domain + +/** Canonical display order of Tibia vocations — druids first, "none" (unknown / + * no vocation) last. Players are grouped and sorted by this order across the + * online list, the `/allies`|`/hunted` list and the world list. Single source of + * truth: a new vocation (as `monk` once was) is added here in one place instead + * of in every grouping site. */ +object Vocations { + val displayOrder: List[String] = + List("druid", "knight", "paladin", "sorcerer", "monk", "none") +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/WorldName.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/WorldName.scala new file mode 100644 index 0000000..353fbe5 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/WorldName.scala @@ -0,0 +1,9 @@ +package com.tibiabot.domain + +/** Canonical ("formal") Tibia world name: lower-cased then first-letter + * upper-cased, so any casing of a single-word world resolves to the same form + * ("antica"/"ANTICA" -> "Antica"). This was the `world.toLowerCase.capitalize` + * idiom repeated across BotApp and the world-config repository. */ +object WorldName { + def formal(world: String): String = world.toLowerCase.capitalize +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala index 8be06c4..b6c80ff 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/time/DreamScarCycle.scala @@ -14,6 +14,11 @@ object DreamScarCycle { val indexOfBoss: Map[String, Int] = bossCycle.zipWithIndex.toMap + /** True if `name` is one of the Dream Courts boss-of-the-day bosses + * (case-insensitive). Single source of truth for "is this a Dream Court boss". */ + def isDreamCourtBoss(name: String): Boolean = + bossCycle.exists(_.equalsIgnoreCase(name)) + /** Shift each world's boss to the next in the cycle; unknown bosses are kept * unchanged. Extracted verbatim from `BotApp.shiftAllBossesUp`. */ def shiftAllBossesUp(current: Map[String, String]): Map[String, String] = diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/time/SatchelCooldown.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/time/SatchelCooldown.scala new file mode 100644 index 0000000..6f16a9a --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/time/SatchelCooldown.scala @@ -0,0 +1,16 @@ +package com.tibiabot.domain.time + +import java.time.ZonedDateTime + +/** Galthen's Satchel has a fixed 30-day cooldown. Single source of that duration + * (and the expiry computation) shared by the /galthen command, its button and + * its modal — previously the 30 and the `plusDays(30).toEpochSecond` were + * duplicated across all three. */ +object SatchelCooldown { + val durationDays: Long = 30 + + /** Epoch-second (as a string, for Discord's ``) at which a cooldown + * that started at `when` expires. */ + def expiresAtEpoch(when: ZonedDateTime): String = + when.plusDays(durationDays).toEpochSecond.toString +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala b/tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala index 0cf7d24..0aa96fa 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/galthen/GalthenService.scala @@ -2,6 +2,7 @@ package com.tibiabot.galthen import com.tibiabot.discord.DiscordGateway import com.tibiabot.domain.SatchelStamp +import com.tibiabot.domain.time.SatchelCooldown import com.tibiabot.persistence.{ConnectionProvider, GalthenRepository} import com.typesafe.scalalogging.StrictLogging import net.dv8tion.jda.api.EmbedBuilder @@ -31,10 +32,11 @@ final class GalthenService( /** DM each user whose 30-day satchel cooldown has expired, then delete those rows. */ def cleanExpired(): Unit = { val conn = connectionProvider.cache() + try { // Retrieve the data before deletion val selectStatement = conn.prepareStatement("SELECT userid,time,tag FROM satchel WHERE time < ?;") - selectStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(30, ChronoUnit.DAYS).toInstant)) + selectStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(SatchelCooldown.durationDays, ChronoUnit.DAYS).toInstant)) val resultSet = selectStatement.executeQuery() // Retrieve the data from the result set @@ -43,7 +45,7 @@ final class GalthenService( val tagId = Option(resultSet.getString("tag")).getOrElse("") val user: User = discordGateway.retrieveUser(userId) val userTimeStamp = resultSet.getTimestamp("time").toInstant() - val cooldown = userTimeStamp.plus(30, ChronoUnit.DAYS).getEpochSecond.toString() + val cooldown = userTimeStamp.plus(SatchelCooldown.durationDays, ChronoUnit.DAYS).getEpochSecond.toString() if (user != null) { try { @@ -69,10 +71,11 @@ final class GalthenService( // Now you have the list of userids and time before deletion, you can proceed with deletion val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE time < ?;") - deleteStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(30, ChronoUnit.DAYS).toInstant)) + deleteStatement.setTimestamp(1, Timestamp.from(ZonedDateTime.now().minus(SatchelCooldown.durationDays, ChronoUnit.DAYS).toInstant)) deleteStatement.executeUpdate() deleteStatement.close() - - conn.close() + } finally { + conn.close() // always release the connection, even if a query above threw + } } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala b/tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala index 7dd1277..b494262 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/interactions/ButtonHandler.scala @@ -3,12 +3,12 @@ package com.tibiabot.interactions import com.tibiabot.{BotApp, Config, presentation} import com.tibiabot.BotApp.worldsData import com.tibiabot.domain.{PendingScreenshot, SatchelStamp} +import com.tibiabot.domain.time.SatchelCooldown import com.typesafe.scalalogging.StrictLogging import java.time.ZonedDateTime import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.emoji.Emoji -import net.dv8tion.jda.api.entities.{Guild, Member, Role} import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent import net.dv8tion.jda.api.interactions.components.ActionRow import net.dv8tion.jda.api.interactions.components.buttons.Button @@ -16,7 +16,6 @@ import net.dv8tion.jda.api.interactions.components.text.{TextInput, TextInputSty import net.dv8tion.jda.api.interactions.modals.Modal import scala.collection.mutable -import scala.jdk.CollectionConverters._ /** Handles all button-click interactions (galthen, boosted, screenshot nav, * role toggles). Moved verbatim from BotListener.onButtonInteraction; the @@ -28,47 +27,14 @@ object ButtonHandler extends StrictLogging { val button = event.getComponentId val guild = event.getGuild val user = event.getUser - var responseText = s"${Config.noEmoji} An unknown error occured, please try again." + var responseText = s"${Config.noEmoji} An unknown error occurred, please try again." val footer = if (!embed.isEmpty) Option(embed.get(0).getFooter) else None val tagId = footer.map(_.getText.replace("Tag: ", "")).getOrElse("") - /** - if (button == "galthen board") { - event.deferReply(true).queue() - //WIP - val satchelTimeOption: Option[List[SatchelStamp]] = BotApp.galthenService.getStamps(event.getUser.getId) - satchelTimeOption match { - case Some(satchelTimeList) => - val fullList = satchelTimeList.collect { - case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() - val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" - s"<:satchel:1030348072577945651> can be collected by $displayTag " - } else { - embed.setColor(178877) - embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you: ```when the 30 day cooldown expires```") - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected"), - Button.danger("galthenRemove", "Clear").asDisabled - ).queue() - } - // /HERE - case None => - embed.setColor(178877) - embed.setDescription("This is a **[Galthen's Satchel](https://www.tibiawiki.com.br/wiki/Galthen's_Satchel)** cooldown tracker.\nMark the <:satchel:1030348072577945651> as **Collected** and I will message you: ```when the 30 day cooldown expires```") - embed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Galthen's_Satchel.gif") - event.getHook.sendMessageEmbeds(embed.build()).addActionRow( - Button.success("galthenSet", "Collected"), - Button.danger("galthenRemove", "Clear").asDisabled - ).queue() - } - } else - **/ if (button == "galthenSet") { event.deferEdit().queue(); - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() + val when = SatchelCooldown.expiresAtEpoch(ZonedDateTime.now()) BotApp.galthenService.add(user.getId, ZonedDateTime.now(), tagId) val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" responseText = s"${Config.satchelEmoji} can be collected by $tagDisplay " @@ -105,7 +71,7 @@ object ButtonHandler extends StrictLogging { )).queue(); } else if (button == "galthenRemind") { // WIP event.deferEdit().queue() - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() + val when = SatchelCooldown.expiresAtEpoch(ZonedDateTime.now()) BotApp.galthenService.add(user.getId, ZonedDateTime.now(), tagId) val tagDisplay = if (tagId == "") s"<@${event.getUser.getId}>" else s"**`$tagId`**" responseText = s"${Config.satchelEmoji} can be collected by $tagDisplay " @@ -127,13 +93,6 @@ object ButtonHandler extends StrictLogging { .build() val modal = Modal.create("rem galthen", "Remove a Galthen Satchel cooldown").addComponents(ActionRow.of(inputWindow)).build() event.replyModal(modal).queue() - } else if (button == "boosted") { - event.deferReply(true).queue() - val replyEmbed = new EmbedBuilder() - replyEmbed.setTitle(s"Receiving boosted boss & creature notifications:") - responseText = s"Use the `/boosted` command to filter specific `bosses` & `creatures`." - replyEmbed.setDescription(responseText) - event.getHook.sendMessageEmbeds(replyEmbed.build()).queue() } else if (button == "boosted add") { val inputWindow = TextInput.create("boosted add", "Boss or Creature name", TextInputStyle.SHORT) .setPlaceholder("Grand Master Oberon") @@ -190,7 +149,7 @@ object ButtonHandler extends StrictLogging { satchelTimeOption match { // case Some(satchelTimeList) if satchelTimeList.isEmpty => - embed.setColor(3092790) + embed.setColor(presentation.Embeds.BrandColor) embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") event.getHook.sendMessageEmbeds(embed.build()).addActionRow( Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) @@ -199,14 +158,14 @@ object ButtonHandler extends StrictLogging { case Some(satchelTimeList) => val fullList = satchelTimeList.collect { case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() + val when = SatchelCooldown.expiresAtEpoch(satchel.when) val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" s"${Config.satchelEmoji} can be collected by $displayTag " } if (fullList.nonEmpty) { embed.setTitle("Existing Cooldowns:") embed.setDescription(presentation.GalthenEmbeds.truncate(fullList)) - embed.setColor(3092790) + embed.setColor(presentation.Embeds.BrandColor) if (fullList.size == 1){ event.getHook.sendMessageEmbeds(embed.build()).addActionRow( Button.success("galthenAdd", "Add Cooldown").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)), //WIP @@ -220,7 +179,7 @@ object ButtonHandler extends StrictLogging { ).queue() } } else { - embed.setColor(3092790) + embed.setColor(presentation.Embeds.BrandColor) embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") event.getHook.sendMessageEmbeds(embed.build()).addActionRow( Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) @@ -228,7 +187,7 @@ object ButtonHandler extends StrictLogging { } // /HERE case None => - embed.setColor(3092790) + embed.setColor(presentation.Embeds.BrandColor) embed.setDescription(s"Mark the ${Config.satchelEmoji} as **Collected** and I will message you when the 30 day cooldown expires.") event.getHook.sendMessageEmbeds(embed.build()).addActionRow( Button.success("galthenSet", "Collected").withEmoji(Emoji.fromFormatted(Config.satchelEmoji)) @@ -359,7 +318,7 @@ object ButtonHandler extends StrictLogging { // Send DM to user event.getUser.openPrivateChannel().queue(privateChannel => { val embed = new EmbedBuilder() - .setColor(3092790) + .setColor(presentation.Embeds.BrandColor) .setTitle(s"Upload Screenshot for ${charName}") .setDescription(s"Please upload an image file (PNG, JPG, GIF, Webp) to this DM within the next 5 minutes.\n\n" + s"The screenshot will be added to the death message for **[${charName}](${BotApp.charUrl(charName)})** in **${guild.getName}**.") @@ -527,198 +486,12 @@ object ButtonHandler extends StrictLogging { event.getHook.sendMessage(s"${Config.noEmoji} Invalid button format.").setEphemeral(true).queue() } } else { + // Any component not matched above is from a superseded message layout; + // acknowledge it gracefully instead of leaving the interaction to time out. event.deferReply(true).queue() - if (title != "") { - val roleType = if (title.contains(":crossed_swords:")) "fullbless" else if (title.contains(s"${Config.nemesisEmoji}")) "nemesis" else if (title.contains(s"${Config.hazardEmoji}")) "allypk" else "" - if (roleType == "fullbless") { - val world = title.replace(":crossed_swords:", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("fullbless_role")) - if (role != null) { - if (button == "add") { - // get role add user to it - try { - guild.addRoleToMember(user, role).queue() - responseText = s":gear: You have been added to the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } else if (button == "remove") { - // remove role - try { - guild.removeRoleFromMember(user, role).queue() - responseText = s":gear: You have been removed from the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to remove user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } - } else { - // role doesn't exist - responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." - } - } else if (roleType == "nemesis") { - val world = title.replace(s"${Config.nemesisEmoji}", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("nemesis_role")) - if (role != null) { - if (button == "add") { - // get role add user to it - try { - guild.addRoleToMember(user, role).queue() - responseText = s":gear: You have been added to the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } else if (button == "remove") { - // remove role - try { - guild.removeRoleFromMember(user, role).queue() - responseText = s":gear: You have been removed from the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to remove user $commandPlayer from the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } - } else { - // role doesn't exist - responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." - } - } else if (roleType == "allypk") { - val world = title.replace(s"${Config.hazardEmoji}", "").trim() - val worldConfigData = BotApp.worldRetrieveConfig(guild, world) - val role = guild.getRoleById(worldConfigData("allypk_role")) - if (role != null) { - if (button == "add") { - // get role add user to it - try { - guild.addRoleToMember(user, role).queue() - responseText = s":gear: You have been added to the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to add you to the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to add user $commandPlayer to the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } else if (button == "remove") { - // remove role - try { - guild.removeRoleFromMember(user, role).queue() - responseText = s":gear: You have been removed from the <@&${role.getId}> role." - } catch { - case _: Throwable => - responseText = s"${Config.noEmoji} Failed to remove you from the <@&${role.getId}> role." - val discordInfo = BotApp.discordRetrieveConfig(guild) - val adminChannelId = if (discordInfo.nonEmpty) discordInfo("admin_channel") else "0" - val adminTextChannel = guild.getTextChannelById(adminChannelId) - if (adminTextChannel != null) { - val commandPlayer = s"<@${user.getId}>" - val adminEmbed = new EmbedBuilder() - adminEmbed.setTitle(s"${Config.noEmoji} a player interaction has failed:") - adminEmbed.setDescription(s"Failed to remove user $commandPlayer from the <@&${role.getId}> role.\n\n:speech_balloon: *Ensure the role <@&${role.getId}> is `below` <@${BotApp.botUser}> on the roles list, or the bot cannot interact with it.*") - adminEmbed.setThumbnail("https://www.tibiawiki.com.br/wiki/Special:Redirect/file/Warning_Sign.gif") - adminEmbed.setColor(3092790) // orange for bot auto command - try { - adminTextChannel.sendMessageEmbeds(adminEmbed.build()).queue() - } catch { - case ex: Exception => logger.error(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) - case _: Throwable => logger.info(s"Failed to send message to 'command-log' channel for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'") - } - } - } - } - } else { - // role doesn't exist - responseText = s"${Config.noEmoji} The role you are trying to add/remove yourself from has been deleted, please notify a discord mod for this server." - } - } - } - val replyEmbed = new EmbedBuilder().setDescription(responseText).build() + val replyEmbed = new EmbedBuilder() + .setDescription(s"${Config.noEmoji} This button is no longer supported. Please re-run the command that created it.") + .build() event.getHook.sendMessageEmbeds(replyEmbed).queue() } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala b/tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala index 2ad1200..35ef1a4 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/interactions/ModalHandler.scala @@ -1,14 +1,11 @@ package com.tibiabot.interactions -import com.tibiabot.{BotApp, Config, presentation} +import com.tibiabot.{BotApp, Config, domain, presentation} import com.tibiabot.domain.SatchelStamp import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.emoji.Emoji import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent import net.dv8tion.jda.api.interactions.components.buttons.Button -import net.dv8tion.jda.api.interactions.components.text.{TextInput, TextInputStyle} -import net.dv8tion.jda.api.interactions.components.ActionRow -import net.dv8tion.jda.api.interactions.modals.Modal import scala.jdk.CollectionConverters._ import java.time.ZonedDateTime @@ -22,101 +19,7 @@ object ModalHandler { val modalValues = event.getValues.asScala.toList modalValues.map { element => val id = element.getId - var inputName = element.getAsString.trim.toLowerCase - val shortName = Map( - "oberon" -> "grand master oberon", - "scarlett" -> "scarlett etzel", - "scarlet" -> "scarlett etzel", - "timira" -> "timira the many-headed", - "timira the many headed" -> "timira the many-headed", - "timira many headed" -> "timira the many-headed", - "timira many-headed" -> "timira the many-headed", - "magma" -> "magma bubble", - "rotten final" -> "bakragore", - "yselda" -> "megasylvan yselda", - "zelos" -> "king zelos", - "despor" -> "dragon pack", - "dragon hoard" -> "dragon pack", - "vengar" -> "dragon pack", - "maliz" -> "dragon pack", - "bruton" -> "dragon pack", - "greedok" -> "dragon pack", - "vilear" -> "dragon pack", - "crultor" -> "dragon pack", - "dragon boss" -> "dragon pack", - "dragon bosses" -> "dragon pack", - "thorn knight" -> "the enraged thorn knight", - "the thorn knight" -> "the enraged thorn knight", - "shielded thorn knight" -> "the enraged thorn knight", - "the shielded thorn knight" -> "the enraged thorn knight", - "mounted thorn knight" -> "the enraged thorn knight", - "the mounted thorn knight" -> "the enraged thorn knight", - "paleworm" -> "the paleworm", - "unwelcome" -> "the unwelcome", - "yirkas" -> "yirkas blue scales", - "vok" -> "vok the feakish", - "irgix" -> "irgix the flimsy", - "unaz" -> "unaz the mean", - "utua" -> "utua stone sting", - "katex" -> "katex blood tongue", - "voidborn" -> "the unarmored voidborn", - "the voidborn" -> "the unarmored voidborn", - "unarmored voidborn" -> "the unarmored voidborn", - "urmahlullu" -> "urmahlullu the weakened", - "winter bloom" -> "the winter bloom", - "time guardian" -> "the time guardian", - "souldespoiler" -> "the souldespoiler", - "scourge of oblivion" -> "the scourge of oblivion", - "lib final" -> "the scourge of oblivion", - "lb final" -> "the scourge of oblivion", - "sandking" -> "the sandking", - "nightmare beast" -> "the nightmare beast", - "moonlight aster" -> "the moonlight aster", - "monster" -> "the monster", - "ingol boss" -> "the monster", - "ingol final" -> "the monster", - "mega magmaoid" -> "the mega magmaoid", - "lily of night" -> "the lily of night", - "flaming orchid" -> "the flaming orchid", - "fear feaster" -> "the fear feaster", - "false god" -> "the false god", - "enraged thorn knight" -> "the enraged thorn knight", - "dread maiden" -> "the dread maiden", - "diamond blossom" -> "the diamond blossom", - "brainstealer" -> "the brainstealer", - "blazing rose" -> "the blazing rose", - "srezz" -> "srezz yellow eyes", - "werelion serpent spawn" -> "srezz yellow eyes", - "werelions serpent spawn" -> "srezz yellow eyes", - "werelion goanna" -> "yirkas blue scales", - "werelions goanna" -> "yirkas blue scales", - "werelion scorpion" -> "utua stone sting", - "werelions scorpion" -> "utua stone sting", - "werelion hyena" -> "katex blood tongue", - "werelions hyena" -> "katex blood tongue", - "werelion hyaena" -> "katex blood tongue", - "werelions hyaena" -> "katex blood tongue", - "werelion werehyena" -> "katex blood tongue", - "werelions werehyena" -> "katex blood tongue", - "werelion werehyaena" -> "katex blood tongue", - "werelions werehyaena" -> "katex blood tongue", - "dragon king" -> "soul of dragonking zyrtarch", - "zyrtarch" -> "soul of dragonking zyrtarch", - "dragonking zyrtarch" -> "soul of dragonking zyrtarch", - "dragon king zyrtarch" -> "soul of dragonking zyrtarch", - "dragonking zyrtarch" -> "soul of dragonking zyrtarch", - "dragonking" -> "soul of dragonking zyrtarch", - "tenebris" -> "lady tenebris", - "ratmiral" -> "ratmiral blackwhiskers", - "plague seal" -> "plagirath", - "pumin seal" -> "tarbaz", - "jugg seal" -> "razzagorn", - "vexclaw seal" -> "shulgrax", - "undead seal" -> "ragiaz" - ) - if (shortName.contains(inputName)) { - inputName = shortName(inputName) - } + val inputName = domain.BossAliases.canonical(element.getAsString.trim.toLowerCase) if (id == "boosted add") { val newEmbed = BotApp.boostedService.boosted(user.getId, "add", inputName) event.getHook().editOriginalEmbeds(newEmbed).setActionRow( @@ -134,9 +37,8 @@ object ModalHandler { } else if (id == "galthen add") { val newEmbed = new EmbedBuilder() - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() val tagDisplay = element.getAsString.trim.toLowerCase - newEmbed.setColor(3092790) + newEmbed.setColor(presentation.Embeds.BrandColor) if (tagDisplay.toLowerCase == user.getName.toLowerCase) { BotApp.galthenService.add(user.getId, ZonedDateTime.now(), "") } else { @@ -149,7 +51,7 @@ object ModalHandler { case Some(satchelTimeList) => val fullList = satchelTimeList.collect { case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() + val when = domain.time.SatchelCooldown.expiresAtEpoch(satchel.when) val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" s"${Config.satchelEmoji} can be collected by $displayTag " } @@ -180,9 +82,8 @@ object ModalHandler { } } else if (id == "galthen rem") { val newEmbed = new EmbedBuilder() - val when = ZonedDateTime.now().plusDays(30).toEpochSecond.toString() val tagDisplay = element.getAsString.trim.toLowerCase - newEmbed.setColor(3092790) + newEmbed.setColor(presentation.Embeds.BrandColor) if (tagDisplay.toLowerCase == user.getName.toLowerCase) { BotApp.galthenService.del(user.getId, "") } else { @@ -195,7 +96,7 @@ object ModalHandler { case Some(satchelTimeList) => val fullList = satchelTimeList.collect { case satchel => - val when = satchel.when.plusDays(30).toEpochSecond.toString() + val when = domain.time.SatchelCooldown.expiresAtEpoch(satchel.when) val displayTag = if (satchel.tag == "") s"<@${event.getUser.getId}>" else s"**`${satchel.tag}`**" s"${Config.satchelEmoji} can be collected by $displayTag " } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala index 009a17f..9a169fe 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/ConnectionProvider.scala @@ -13,6 +13,8 @@ trait ConnectionProvider { def cache(): Connection /** Maintenance connection to the default `postgres` database. */ def admin(): Connection - /** Connection to the `premium` database. */ + /** Connection to the `premium` database. PLANNED — only used by + * SchemaInitializer.initPremium (the not-yet-wired Patreon/premium tier); + * kept intentionally, not dead code. */ def premium(): Connection } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala index 45e3366..0c8e3b1 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/JdbcUrls.scala @@ -15,6 +15,7 @@ object JdbcUrls { /** Maintenance connection (the default `postgres` database). */ def admin(host: String): String = s"${base(host)}/postgres" - /** Premium database. */ + /** Premium database (PLANNED Patreon/premium tier — see + * SchemaInitializer.initPremium; not wired into runtime yet, kept on purpose). */ def premium(host: String): String = s"${base(host)}/premium" } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala index 2d07391..0a81768 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala @@ -1,253 +1,273 @@ package com.tibiabot.persistence +import com.tibiabot.persistence.jdbc.JdbcSupport import com.typesafe.scalalogging.StrictLogging /** Creates the bot's databases and tables at startup / on guild join. Bodies * moved verbatim from BotApp's checkConfigDatabase/createPremiumDatabase/ * createCacheDatabase/createConfigDatabase, with the Guild parameter reduced to * guildId/guildName. Behaviour preserved exactly (including the pre-existing - * quirk that initPremium creates 'bot_cache'). */ + * quirk that initPremium creates 'bot_cache'). Connections are released via + * JdbcSupport.withConnection so a failed CREATE can't leak them; the admin + * connection is still closed before the per-database connection is opened. */ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends StrictLogging { - def guildDatabaseExists(guildId: String): Boolean = { - val conn = connectionProvider.admin() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") - val exist = result.next() + // A guild's Postgres database name. Guild IDs are Discord snowflakes (digits + // only); validate that before interpolating, since a database name can't be a + // bound parameter — this keeps the CREATE/DROP DATABASE DDL injection-proof. + private def guildDbName(guildId: String): String = { + require(guildId.nonEmpty && guildId.forall(_.isDigit), s"refusing unsafe guild database name: '$guildId'") + s"_$guildId" + } - statement.close() - conn.close() + def guildDatabaseExists(guildId: String): Boolean = + JdbcSupport.withConnection(connectionProvider.admin) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '${guildDbName(guildId)}'") + val exist = result.next() + statement.close() + exist + } - // check if database for discord exists - if (exist) { - true - } else { - false + /** Drop a guild's database when the bot leaves it (moved verbatim from BotApp's + * removeConfigDatabase). No-op if it doesn't exist. */ + def dropGuild(guildId: String): Unit = + JdbcSupport.withConnection(connectionProvider.admin) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '${guildDbName(guildId)}'") + val exist = result.next() + if (exist) { + statement.executeUpdate(s"DROP DATABASE ${guildDbName(guildId)};") + logger.info(s"Database '$guildId' removed successfully") + } else { + logger.info(s"Database '$guildId' was not removed as it doesn't exist") + } + statement.close() } - } + /** PLANNED FEATURE — intentionally not wired yet (do not delete as "dead code"). + * Scaffolding for the Patreon/premium tier: creates the `payments` database/ + * table. No caller hooks this into startup today, so the premium DB is never + * created at runtime; wire a call to this in (and add the premium read path) + * when the premium feature is built out. NOTE: carries a pre-existing quirk — + * it checks for a 'premium' database but creates 'bot_cache'; fix when wiring. */ def initPremium(): Unit = { - val conn = connectionProvider.admin() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'premium'") - val exist = result.next() - - // if bot_configuration doesn't exist - if (!exist) { - statement.executeUpdate(s"CREATE DATABASE bot_cache;") - logger.info(s"Database 'bot_cache' created successfully") + val needsTables = JdbcSupport.withConnection(connectionProvider.admin) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'premium'") + val exist = result.next() + if (!exist) { + statement.executeUpdate(s"CREATE DATABASE bot_cache;") + logger.info(s"Database 'bot_cache' created successfully") + } statement.close() - conn.close() + !exist + } - val newConn = connectionProvider.premium() - val newStatement = newConn.createStatement() - // create the tables in bot_configuration - val createPaymentsTable = - s"""CREATE TABLE payments ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |discord_id VARCHAR(255) NOT NULL, - |discord_name VARCHAR(255) NOT NULL, - |user_id VARCHAR(255) NOT NULL, - |user_name VARCHAR(255) NOT NULL, - |expiry VARCHAR(255) NOT NULL - |);""".stripMargin + if (needsTables) { + JdbcSupport.withConnection(connectionProvider.premium) { newConn => + val newStatement = newConn.createStatement() + // create the tables in bot_configuration + val createPaymentsTable = + s"""CREATE TABLE payments ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |discord_id VARCHAR(255) NOT NULL, + |discord_name VARCHAR(255) NOT NULL, + |user_id VARCHAR(255) NOT NULL, + |user_name VARCHAR(255) NOT NULL, + |expiry VARCHAR(255) NOT NULL + |);""".stripMargin - newStatement.executeUpdate(createPaymentsTable) - logger.info("Table 'payments' created successfully") - newStatement.close() - newConn.close() - } else { - statement.close() - conn.close() + newStatement.executeUpdate(createPaymentsTable) + logger.info("Table 'payments' created successfully") + newStatement.close() + } } } def initCache(): Unit = { - val conn = connectionProvider.admin() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'bot_cache'") - val exist = result.next() - - // if bot_configuration doesn't exist - if (!exist) { - statement.executeUpdate(s"CREATE DATABASE bot_cache;") - logger.info(s"Database 'bot_cache' created successfully") + val needsTables = JdbcSupport.withConnection(connectionProvider.admin) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'bot_cache'") + val exist = result.next() + if (!exist) { + statement.executeUpdate(s"CREATE DATABASE bot_cache;") + logger.info(s"Database 'bot_cache' created successfully") + } statement.close() - conn.close() + !exist + } - val newConn = connectionProvider.cache() - val newStatement = newConn.createStatement() - // create the tables in bot_configuration - val createDeathsTable = - s"""CREATE TABLE deaths ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin + if (needsTables) { + JdbcSupport.withConnection(connectionProvider.cache) { newConn => + val newStatement = newConn.createStatement() + // create the tables in bot_configuration + val createDeathsTable = + s"""CREATE TABLE deaths ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createLevelsTable = + s"""CREATE TABLE levels ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |level VARCHAR(255) NOT NULL, + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin - val createLevelsTable = - s"""CREATE TABLE levels ( + val createListTable = + s"""CREATE TABLE list ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |former_worlds VARCHAR(255), + |name VARCHAR(255) NOT NULL, + |former_names VARCHAR(1000), + |level VARCHAR(255) NOT NULL, + |guild_name VARCHAR(255), + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createGalthenTable = + s"""CREATE TABLE satchel ( |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |level VARCHAR(255) NOT NULL, - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL + |userid VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL, + |tag VARCHAR(255) |);""".stripMargin - val createListTable = - s"""CREATE TABLE list ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |former_worlds VARCHAR(255), - |name VARCHAR(255) NOT NULL, - |former_names VARCHAR(1000), - |level VARCHAR(255) NOT NULL, - |guild_name VARCHAR(255), - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createGalthenTable = - s"""CREATE TABLE satchel ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL, - |tag VARCHAR(255) - |);""".stripMargin - - newStatement.executeUpdate(createDeathsTable) - logger.info("Table 'deaths' created successfully") - newStatement.executeUpdate(createLevelsTable) - logger.info("Table 'levels' created successfully") - newStatement.executeUpdate(createListTable) - logger.info("Table 'list' created successfully") - newStatement.executeUpdate(createGalthenTable) - logger.info("Table 'galthen' created successfully") - newStatement.close() - newConn.close() - } else { - statement.close() - conn.close() + newStatement.executeUpdate(createDeathsTable) + logger.info("Table 'deaths' created successfully") + newStatement.executeUpdate(createLevelsTable) + logger.info("Table 'levels' created successfully") + newStatement.executeUpdate(createListTable) + logger.info("Table 'list' created successfully") + newStatement.executeUpdate(createGalthenTable) + logger.info("Table 'galthen' created successfully") + newStatement.close() + } } } def initGuild(guildId: String, guildName: String): Unit = { - val conn = connectionProvider.admin() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '_$guildId'") - val exist = result.next() - - // if bot_configuration doesn't exist - if (!exist) { - statement.executeUpdate(s"CREATE DATABASE _$guildId;") - logger.info(s"Database '$guildId' for discord '$guildName' created successfully") + val needsTables = JdbcSupport.withConnection(connectionProvider.admin) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = '${guildDbName(guildId)}'") + val exist = result.next() + if (!exist) { + statement.executeUpdate(s"CREATE DATABASE ${guildDbName(guildId)};") + logger.info(s"Database '$guildId' for discord '$guildName' created successfully") + } else { + logger.info(s"Database '$guildId' already exists") + } statement.close() - conn.close() - - val newConn = connectionProvider.guild(guildId) - val newStatement = newConn.createStatement() - // create the tables in bot_configuration - val createDiscordInfoTable = - s"""CREATE TABLE discord_info ( - |guild_name VARCHAR(255) NOT NULL, - |guild_owner VARCHAR(255) NOT NULL, - |admin_category VARCHAR(255) NOT NULL, - |admin_channel VARCHAR(255) NOT NULL, - |boosted_channel VARCHAR(255) NOT NULL, - |boosted_messageid VARCHAR(255) NOT NULL, - |flags VARCHAR(255) NOT NULL, - |created TIMESTAMP NOT NULL, - |PRIMARY KEY (guild_name) - |);""".stripMargin + !exist + } - val createHuntedPlayersTable = - s"""CREATE TABLE hunted_players ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin + if (needsTables) { + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { newConn => + val newStatement = newConn.createStatement() + // create the tables in bot_configuration + val createDiscordInfoTable = + s"""CREATE TABLE discord_info ( + |guild_name VARCHAR(255) NOT NULL, + |guild_owner VARCHAR(255) NOT NULL, + |admin_category VARCHAR(255) NOT NULL, + |admin_channel VARCHAR(255) NOT NULL, + |boosted_channel VARCHAR(255) NOT NULL, + |boosted_messageid VARCHAR(255) NOT NULL, + |flags VARCHAR(255) NOT NULL, + |created TIMESTAMP NOT NULL, + |PRIMARY KEY (guild_name) + |);""".stripMargin - val createHuntedGuildsTable = - s"""CREATE TABLE hunted_guilds ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin + val createHuntedPlayersTable = + s"""CREATE TABLE hunted_players ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin - val createAlliedPlayersTable = - s"""CREATE TABLE allied_players ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin + val createHuntedGuildsTable = + s"""CREATE TABLE hunted_guilds ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin - val createAlliedGuildsTable = - s"""CREATE TABLE allied_guilds ( - |name VARCHAR(255) NOT NULL, - |reason VARCHAR(255) NOT NULL, - |reason_text VARCHAR(255) NOT NULL, - |added_by VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin + val createAlliedPlayersTable = + s"""CREATE TABLE allied_players ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin - val createWorldsTable = - s"""CREATE TABLE worlds ( - |name VARCHAR(255) NOT NULL, - |allies_channel VARCHAR(255) NOT NULL, - |enemies_channel VARCHAR(255) NOT NULL, - |neutrals_channel VARCHAR(255) NOT NULL, - |levels_channel VARCHAR(255) NOT NULL, - |deaths_channel VARCHAR(255) NOT NULL, - |category VARCHAR(255) NOT NULL, - |fullbless_role VARCHAR(255) NOT NULL, - |nemesis_role VARCHAR(255) NOT NULL, - |allypk_role VARCHAR(255) NOT NULL, - |masslog_role VARCHAR(255) NOT NULL, - |fullbless_channel VARCHAR(255) NOT NULL, - |nemesis_channel VARCHAR(255) NOT NULL, - |fullbless_level INT NOT NULL, - |show_neutral_levels VARCHAR(255) NOT NULL, - |show_neutral_deaths VARCHAR(255) NOT NULL, - |show_allies_levels VARCHAR(255) NOT NULL, - |show_allies_deaths VARCHAR(255) NOT NULL, - |show_enemies_levels VARCHAR(255) NOT NULL, - |show_enemies_deaths VARCHAR(255) NOT NULL, - |detect_hunteds VARCHAR(255) NOT NULL, - |levels_min INT NOT NULL, - |deaths_min INT NOT NULL, - |exiva_list VARCHAR(255) NOT NULL, - |online_combined VARCHAR(255) NOT NULL, - |PRIMARY KEY (name) - |);""".stripMargin + val createAlliedGuildsTable = + s"""CREATE TABLE allied_guilds ( + |name VARCHAR(255) NOT NULL, + |reason VARCHAR(255) NOT NULL, + |reason_text VARCHAR(255) NOT NULL, + |added_by VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin - newStatement.executeUpdate(createDiscordInfoTable) - logger.info("Table 'discord_info' created successfully") - newStatement.executeUpdate(createHuntedPlayersTable) - logger.info("Table 'hunted_players' created successfully") - newStatement.executeUpdate(createHuntedGuildsTable) - logger.info("Table 'hunted_guilds' created successfully") - newStatement.executeUpdate(createAlliedPlayersTable) - logger.info("Table 'allied_players' created successfully") - newStatement.executeUpdate(createAlliedGuildsTable) - logger.info("Table 'allied_guilds' created successfully") - newStatement.executeUpdate(createWorldsTable) - logger.info("Table 'worlds' created successfully") - newStatement.close() - newConn.close() - } else { - logger.info(s"Database '$guildId' already exists") - statement.close() - conn.close() + val createWorldsTable = + s"""CREATE TABLE worlds ( + |name VARCHAR(255) NOT NULL, + |allies_channel VARCHAR(255) NOT NULL, + |enemies_channel VARCHAR(255) NOT NULL, + |neutrals_channel VARCHAR(255) NOT NULL, + |levels_channel VARCHAR(255) NOT NULL, + |deaths_channel VARCHAR(255) NOT NULL, + |category VARCHAR(255) NOT NULL, + |fullbless_role VARCHAR(255) NOT NULL, + |nemesis_role VARCHAR(255) NOT NULL, + |allypk_role VARCHAR(255) NOT NULL, + |masslog_role VARCHAR(255) NOT NULL, + |fullbless_channel VARCHAR(255) NOT NULL, + |nemesis_channel VARCHAR(255) NOT NULL, + |fullbless_level INT NOT NULL, + |show_neutral_levels VARCHAR(255) NOT NULL, + |show_neutral_deaths VARCHAR(255) NOT NULL, + |show_allies_levels VARCHAR(255) NOT NULL, + |show_allies_deaths VARCHAR(255) NOT NULL, + |show_enemies_levels VARCHAR(255) NOT NULL, + |show_enemies_deaths VARCHAR(255) NOT NULL, + |detect_hunteds VARCHAR(255) NOT NULL, + |levels_min INT NOT NULL, + |deaths_min INT NOT NULL, + |exiva_list VARCHAR(255) NOT NULL, + |online_combined VARCHAR(255) NOT NULL, + |PRIMARY KEY (name) + |);""".stripMargin + + newStatement.executeUpdate(createDiscordInfoTable) + logger.info("Table 'discord_info' created successfully") + newStatement.executeUpdate(createHuntedPlayersTable) + logger.info("Table 'hunted_players' created successfully") + newStatement.executeUpdate(createHuntedGuildsTable) + logger.info("Table 'hunted_guilds' created successfully") + newStatement.executeUpdate(createAlliedPlayersTable) + logger.info("Table 'allied_players' created successfully") + newStatement.executeUpdate(createAlliedGuildsTable) + logger.info("Table 'allied_guilds' created successfully") + newStatement.executeUpdate(createWorldsTable) + logger.info("Table 'worlds' created successfully") + newStatement.close() + } } } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala index d9f42f9..ee540a7 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcActivityRepository.scala @@ -14,8 +14,8 @@ import scala.collection.mutable.ListBuffer * Guild parameter reduced to guildId. */ final class JdbcActivityRepository(connectionProvider: ConnectionProvider) extends ActivityRepository { - def getActivity(guildId: String): List[PlayerCache] = { - val conn = connectionProvider.guild(guildId) + def getActivity(guildId: String): List[PlayerCache] = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.createStatement() // Check if the table already exists in bot_configuration @@ -52,12 +52,11 @@ final class JdbcActivityRepository(connectionProvider: ConnectionProvider) exten } statement.close() - conn.close() results.toList } - def add(guildId: String, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime): Unit = { - val conn = connectionProvider.guild(guildId) + def add(guildId: String, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement( s""" |INSERT INTO tracked_activity(name, former_names, guild_name, updated) @@ -76,7 +75,6 @@ final class JdbcActivityRepository(connectionProvider: ConnectionProvider) exten statement.executeUpdate() statement.close() - conn.close() } def update(guildId: String, name: String, formerNames: List[String], guildName: String, updatedTime: ZonedDateTime, newName: String): Unit = { @@ -112,24 +110,21 @@ final class JdbcActivityRepository(connectionProvider: ConnectionProvider) exten } } - def removeByGuild(guildId: String, guildName: String): Unit = { - val conn = connectionProvider.guild(guildId) - + def removeByGuild(guildId: String, guildName: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement(s"DELETE FROM tracked_activity WHERE LOWER(guild_name) = LOWER(?);") statement.setString(1, guildName) statement.executeUpdate() statement.close() - conn.close() } - def removeByName(guildId: String, name: String): Unit = { - val conn = connectionProvider.guild(guildId) + def removeByName(guildId: String, name: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement(s"DELETE FROM tracked_activity WHERE LOWER(name) = LOWER(?);") statement.setString(1, name) statement.executeUpdate() statement.close() - conn.close() } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala index 6a97ebc..f5d6759 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcBoostedRepository.scala @@ -8,7 +8,8 @@ import scala.collection.mutable.ListBuffer /** JDBC implementation of BoostedRepository. Read bodies moved verbatim from * BotApp's boostedAll/boostedList; the subscribe/unsubscribe SQL matches the * inline statements in BotApp.boosted. The table is created on first use, as - * the originals did. */ + * the originals did. Every method goes through JdbcSupport.withConnection so + * the connection is released even if a statement throws. */ final class JdbcBoostedRepository(connectionProvider: ConnectionProvider) extends BoostedRepository { private def ensureTable(statement: java.sql.Statement): Unit = { @@ -29,80 +30,75 @@ final class JdbcBoostedRepository(connectionProvider: ConnectionProvider) extend } } - def all(): List[BoostedStamp] = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - ensureTable(statement) + def all(): List[BoostedStamp] = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + ensureTable(statement) - val result = statement.executeQuery(s"SELECT userid,name,type FROM boosted_notifications;") - val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() + val result = statement.executeQuery(s"SELECT userid,name,type FROM boosted_notifications;") + val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() - while (result.next()) { - val boostedUserSql = Option(result.getString("userid")).getOrElse("") - val boostedNameSql = Option(result.getString("name")).getOrElse("") - val boostedTypeSql = Option(result.getString("type")).getOrElse("") + while (result.next()) { + val boostedUserSql = Option(result.getString("userid")).getOrElse("") + val boostedNameSql = Option(result.getString("name")).getOrElse("") + val boostedTypeSql = Option(result.getString("type")).getOrElse("") - val boostedStamp = BoostedStamp(boostedUserSql, boostedTypeSql, boostedNameSql) - boostedStampList += boostedStamp + val boostedStamp = BoostedStamp(boostedUserSql, boostedTypeSql, boostedNameSql) + boostedStampList += boostedStamp + } + + statement.close() + boostedStampList.toList } - statement.close() - conn.close() - boostedStampList.toList - } + def forUser(userId: String): List[BoostedStamp] = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + ensureTable(statement) - def forUser(userId: String): List[BoostedStamp] = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - ensureTable(statement) + val result = statement.executeQuery(s"SELECT name,type FROM boosted_notifications WHERE userid = '$userId';") + val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() - val result = statement.executeQuery(s"SELECT name,type FROM boosted_notifications WHERE userid = '$userId';") - val boostedStampList: ListBuffer[BoostedStamp] = ListBuffer() + while (result.next()) { + val boostedNameSql = Option(result.getString("name")).getOrElse("") + val boostedTypeSql = Option(result.getString("type")).getOrElse("") - while (result.next()) { - val boostedNameSql = Option(result.getString("name")).getOrElse("") - val boostedTypeSql = Option(result.getString("type")).getOrElse("") + val boostedStamp = BoostedStamp(userId, boostedTypeSql, boostedNameSql) + boostedStampList += boostedStamp + } - val boostedStamp = BoostedStamp(userId, boostedTypeSql, boostedNameSql) - boostedStampList += boostedStamp + statement.close() + boostedStampList.toList } - statement.close() - conn.close() - boostedStampList.toList - } - - def subscribe(userId: String, name: String, boostedType: String): Unit = { - val conn = connectionProvider.cache() - val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() - val statement = conn.prepareStatement( - "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING") - statement.setString(1, userId) - statement.setString(2, name) - statement.setString(3, boostedType) - statement.executeUpdate() - statement.close() - conn.close() - } + def subscribe(userId: String, name: String, boostedType: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() + val statement = conn.prepareStatement( + "INSERT INTO boosted_notifications (userid, name, type) VALUES (?, ?, ?) ON CONFLICT (userid, name) DO NOTHING") + statement.setString(1, userId) + statement.setString(2, name) + statement.setString(3, boostedType) + statement.executeUpdate() + statement.close() + } - def unsubscribe(userId: String, name: String): Unit = { - val conn = connectionProvider.cache() - val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() - val statement = conn.prepareStatement("DELETE FROM boosted_notifications WHERE userid = ? AND LOWER(name) = LOWER(?)") - statement.setString(1, userId) - statement.setString(2, name) - statement.executeUpdate() - statement.close() - conn.close() - } + def unsubscribe(userId: String, name: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() + val statement = conn.prepareStatement("DELETE FROM boosted_notifications WHERE userid = ? AND LOWER(name) = LOWER(?)") + statement.setString(1, userId) + statement.setString(2, name) + statement.executeUpdate() + statement.close() + } - def unsubscribeAll(userId: String): Unit = { - val conn = connectionProvider.cache() - val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() - val statement = conn.prepareStatement("DELETE FROM boosted_notifications WHERE userid = ?") - statement.setString(1, userId) - statement.executeUpdate() - statement.close() - conn.close() - } + def unsubscribeAll(userId: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val ensure = conn.createStatement(); ensureTable(ensure); ensure.close() + val statement = conn.prepareStatement("DELETE FROM boosted_notifications WHERE userid = ?") + statement.setString(1, userId) + statement.executeUpdate() + statement.close() + } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala index 1093c13..baf06cb 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCacheRepository.scala @@ -10,356 +10,344 @@ import scala.collection.mutable.ListBuffer /** JDBC implementation of CacheRepository (deaths + levels). Bodies moved * verbatim from BotApp's getDeathsCache/addDeathsCache/removeDeathsCache and - * the levels equivalents — no behaviour change. */ + * the levels equivalents, then wrapped in JdbcSupport.withConnection so the + * connection is always released (no leak on the SQL error path). */ final class JdbcCacheRepository(connectionProvider: ConnectionProvider) extends CacheRepository { - def getDeaths(world: String): List[DeathsCache] = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT world,name,time FROM deaths WHERE world = '$world';") - - val results = new ListBuffer[DeathsCache]() - while (result.next()) { - val world = Option(result.getString("world")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val time = Option(result.getString("time")).getOrElse("") - results += DeathsCache(world, name, time) - } - - statement.close() - conn.close() - results.toList - } - - def addDeath(world: String, name: String, time: String): Unit = { - val conn = connectionProvider.cache() - val statement = conn.prepareStatement("INSERT INTO deaths(world,name,time) VALUES (?, ?, ?);") - statement.setString(1, world) - statement.setString(2, name) - statement.setString(3, time) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def removeExpiredDeaths(now: ZonedDateTime): Unit = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT id,time from deaths;") - val results = new ListBuffer[Long]() - while (result.next()) { - val id = Option(result.getLong("id")).getOrElse(0L) - val timeDb = Option(result.getString("time")).getOrElse("") - val timeToDate = ZonedDateTime.parse(timeDb) - if (now.isAfter(timeToDate.plusMinutes(30)) && id != 0L) { - results += id + def getDeaths(world: String): List[DeathsCache] = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT world,name,time FROM deaths WHERE world = '$world';") + + val results = new ListBuffer[DeathsCache]() + while (result.next()) { + val world = Option(result.getString("world")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val time = Option(result.getString("time")).getOrElse("") + results += DeathsCache(world, name, time) } + + statement.close() + results.toList } - results.foreach { uid => - statement.executeUpdate(s"DELETE from deaths where id = $uid;") - } - statement.close() - conn.close() - } - - def getLevels(world: String): List[LevelsCache] = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT world,name,level,vocation,last_login,time FROM levels WHERE world = '$world';") - - val results = new ListBuffer[LevelsCache]() - while (result.next()) { - val world = Option(result.getString("world")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val level = Option(result.getString("level")).getOrElse("") - val vocation = Option(result.getString("vocation")).getOrElse("") - val lastLogin = Option(result.getString("last_login")).getOrElse("") - val time = Option(result.getString("time")).getOrElse("") - results += LevelsCache(world, name, level, vocation, lastLogin, time) + + def addDeath(world: String, name: String, time: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.prepareStatement("INSERT INTO deaths(world,name,time) VALUES (?, ?, ?);") + statement.setString(1, world) + statement.setString(2, name) + statement.setString(3, time) + statement.executeUpdate() + + statement.close() } - statement.close() - conn.close() - results.toList - } - - def addLevel(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String): Unit = { - val conn = connectionProvider.cache() - val statement = conn.prepareStatement("INSERT INTO levels(world,name,level,vocation,last_login,time) VALUES (?, ?, ?, ?, ?, ?);") - statement.setString(1, world) - statement.setString(2, name) - statement.setString(3, level) - statement.setString(4, vocation) - statement.setString(5, lastLogin) - statement.setString(6, time) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def removeExpiredLevels(now: ZonedDateTime): Unit = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT id,time from levels;") - val results = new ListBuffer[Long]() - while (result.next()) { - val id = Option(result.getLong("id")).getOrElse(0L) - val timeDb = Option(result.getString("time")).getOrElse("") - val timeToDate = ZonedDateTime.parse(timeDb) - if (now.isAfter(timeToDate.plusHours(25)) && id != 0L) { - results += id + def removeExpiredDeaths(now: ZonedDateTime): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT id,time from deaths;") + val results = new ListBuffer[Long]() + while (result.next()) { + val id = Option(result.getLong("id")).getOrElse(0L) + val timeDb = Option(result.getString("time")).getOrElse("") + val timeToDate = ZonedDateTime.parse(timeDb) + if (now.isAfter(timeToDate.plusMinutes(30)) && id != 0L) { + results += id + } } + results.foreach { uid => + statement.executeUpdate(s"DELETE from deaths where id = $uid;") + } + statement.close() } - results.foreach { uid => - statement.executeUpdate(s"DELETE from levels where id = $uid;") - } - statement.close() - conn.close() - } - - def getList(world: String): List[ListCache] = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'list'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE list ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |former_worlds VARCHAR(255), - |name VARCHAR(255) NOT NULL, - |former_names VARCHAR(1000), - |level VARCHAR(255) NOT NULL, - |guild_name VARCHAR(255), - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - statement.executeUpdate(createListTable) - } - val result = statement.executeQuery(s"SELECT name,former_names,world,former_worlds,guild_name,level,vocation,last_login,time FROM list WHERE world = '$world';") - - val results = new ListBuffer[ListCache]() - while (result.next()) { - - val guildName = Option(result.getString("guild_name")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val formerNames = Option(result.getString("former_names")).getOrElse("") - val formerNamesList = formerNames.split(",").toList - val world = Option(result.getString("world")).getOrElse("") - val formerWorlds = Option(result.getString("former_worlds")).getOrElse("") - val formerWorldsList = formerWorlds.split(",").toList - val level = Option(result.getString("level")).getOrElse("") - val vocation = Option(result.getString("vocation")).getOrElse("") - val lastLogin = Option(result.getString("last_login")).getOrElse("") - val updatedTimeTemporal = Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z")) - val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) - - results += ListCache(name, formerNamesList, world, formerWorldsList, guildName, level, vocation, lastLogin, updatedTime) + def getLevels(world: String): List[LevelsCache] = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT world,name,level,vocation,last_login,time FROM levels WHERE world = '$world';") + + val results = new ListBuffer[LevelsCache]() + while (result.next()) { + val world = Option(result.getString("world")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val level = Option(result.getString("level")).getOrElse("") + val vocation = Option(result.getString("vocation")).getOrElse("") + val lastLogin = Option(result.getString("last_login")).getOrElse("") + val time = Option(result.getString("time")).getOrElse("") + results += LevelsCache(world, name, level, vocation, lastLogin, time) + } + + statement.close() + results.toList } - statement.close() - conn.close() - results.toList - } + def addLevel(world: String, name: String, level: String, vocation: String, lastLogin: String, time: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.prepareStatement("INSERT INTO levels(world,name,level,vocation,last_login,time) VALUES (?, ?, ?, ?, ?, ?);") + statement.setString(1, world) + statement.setString(2, name) + statement.setString(3, level) + statement.setString(4, vocation) + statement.setString(5, lastLogin) + statement.setString(6, time) + statement.executeUpdate() - def addToList(name: String, formerNames: List[String], world: String, formerWorlds: List[String], - guild: String, level: String, vocation: String, lastLogin: String, updatedTime: ZonedDateTime): Unit = { - val conn = connectionProvider.cache() - val selectStatement = conn.prepareStatement("SELECT name FROM list WHERE LOWER(name) = LOWER(?);") - selectStatement.setString(1, name) - val resultSet = selectStatement.executeQuery() - - if (resultSet.next()) { - // Update existing row - val updateStatement = conn.prepareStatement( - s""" - |UPDATE list - |SET former_names = ?, world = ?, former_worlds = ?, guild_name = ?, level = ?, vocation = ?, last_login = ?, time = ? - |WHERE LOWER(name) = LOWER(?); - |""".stripMargin - ) - updateStatement.setString(1, formerNames.mkString(",")) - updateStatement.setString(2, world.capitalize) - updateStatement.setString(3, formerWorlds.mkString(",")) - updateStatement.setString(4, guild) - updateStatement.setString(5, level) - updateStatement.setString(6, vocation) - updateStatement.setString(7, lastLogin) - updateStatement.setTimestamp(8, Timestamp.from(updatedTime.toInstant)) - updateStatement.setString(9, name) - updateStatement.executeUpdate() - updateStatement.close() - } else { - // Insert new row - val insertStatement = conn.prepareStatement( - s""" - |INSERT INTO list(name, former_names, world, former_worlds, guild_name, level, vocation, last_login, time) - |VALUES (?,?,?,?,?,?,?,?,?); - |""".stripMargin - ) - insertStatement.setString(1, name) - insertStatement.setString(2, formerNames.mkString(",")) - insertStatement.setString(3, world.capitalize) - insertStatement.setString(4, formerWorlds.mkString(",")) - insertStatement.setString(5, guild) - insertStatement.setString(6, level) - insertStatement.setString(7, vocation) - insertStatement.setString(8, lastLogin) - insertStatement.setTimestamp(9, Timestamp.from(updatedTime.toInstant)) - insertStatement.executeUpdate() - insertStatement.close() + statement.close() } - selectStatement.close() - conn.close() - } - - def removeExpiredList(now: ZonedDateTime): Unit = { - val conn = connectionProvider.cache() - - // Modify the DELETE statement to include a WHERE clause with the condition for time - val deleteStatement = conn.prepareStatement("DELETE FROM list WHERE time < ?;") - deleteStatement.setTimestamp(1, Timestamp.from(now.minus(7, ChronoUnit.DAYS).toInstant)) - deleteStatement.executeUpdate() - deleteStatement.close() - conn.close() - } - - def getBoosted(): List[BoostedCache] = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - - val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_info'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE boosted_info ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |boss VARCHAR(255) NOT NULL, - |bosschanged VARCHAR(255) NOT NULL, - |creature VARCHAR(255) NOT NULL, - |creaturechanged VARCHAR(255) NOT NULL - );""".stripMargin - - statement.executeUpdate(createListTable) + def removeExpiredLevels(now: ZonedDateTime): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + val result = statement.executeQuery(s"SELECT id,time from levels;") + val results = new ListBuffer[Long]() + while (result.next()) { + val id = Option(result.getLong("id")).getOrElse(0L) + val timeDb = Option(result.getString("time")).getOrElse("") + val timeToDate = ZonedDateTime.parse(timeDb) + if (now.isAfter(timeToDate.plusHours(25)) && id != 0L) { + results += id + } + } + results.foreach { uid => + statement.executeUpdate(s"DELETE from levels where id = $uid;") + } + statement.close() } - // Check if the column already exists in the table - val bossChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'bosschanged'") - val bossChangedExists = bossChangedExistsQuery.next() - bossChangedExistsQuery.close() + def getList(world: String): List[ListCache] = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'list'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createListTable = + s"""CREATE TABLE list ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |former_worlds VARCHAR(255), + |name VARCHAR(255) NOT NULL, + |former_names VARCHAR(1000), + |level VARCHAR(255) NOT NULL, + |guild_name VARCHAR(255), + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + statement.executeUpdate(createListTable) + } - // Check if the column already exists in the table - val creatureChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'creaturechanged'") - val creatureChangedExists = creatureChangedExistsQuery.next() - creatureChangedExistsQuery.close() + val result = statement.executeQuery(s"SELECT name,former_names,world,former_worlds,guild_name,level,vocation,last_login,time FROM list WHERE world = '$world';") + + val results = new ListBuffer[ListCache]() + while (result.next()) { + + val guildName = Option(result.getString("guild_name")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val formerNames = Option(result.getString("former_names")).getOrElse("") + val formerNamesList = formerNames.split(",").toList + val world = Option(result.getString("world")).getOrElse("") + val formerWorlds = Option(result.getString("former_worlds")).getOrElse("") + val formerWorldsList = formerWorlds.split(",").toList + val level = Option(result.getString("level")).getOrElse("") + val vocation = Option(result.getString("vocation")).getOrElse("") + val lastLogin = Option(result.getString("last_login")).getOrElse("") + val updatedTimeTemporal = Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z")) + val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) + + results += ListCache(name, formerNamesList, world, formerWorldsList, guildName, level, vocation, lastLogin, updatedTime) + } - // Add the column if it doesn't exist - if (!bossChangedExists) { - statement.execute("ALTER TABLE boosted_info ADD COLUMN bosschanged VARCHAR(255) DEFAULT '0'") + statement.close() + results.toList } - // Add the column if it doesn't exist - if (!creatureChangedExists) { - statement.execute("ALTER TABLE boosted_info ADD COLUMN creaturechanged VARCHAR(255) DEFAULT '0'") - } + def addToList(name: String, formerNames: List[String], world: String, formerWorlds: List[String], + guild: String, level: String, vocation: String, lastLogin: String, updatedTime: ZonedDateTime): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val selectStatement = conn.prepareStatement("SELECT name FROM list WHERE LOWER(name) = LOWER(?);") + selectStatement.setString(1, name) + val resultSet = selectStatement.executeQuery() + + if (resultSet.next()) { + // Update existing row + val updateStatement = conn.prepareStatement( + s""" + |UPDATE list + |SET former_names = ?, world = ?, former_worlds = ?, guild_name = ?, level = ?, vocation = ?, last_login = ?, time = ? + |WHERE LOWER(name) = LOWER(?); + |""".stripMargin + ) + updateStatement.setString(1, formerNames.mkString(",")) + updateStatement.setString(2, world.capitalize) + updateStatement.setString(3, formerWorlds.mkString(",")) + updateStatement.setString(4, guild) + updateStatement.setString(5, level) + updateStatement.setString(6, vocation) + updateStatement.setString(7, lastLogin) + updateStatement.setTimestamp(8, Timestamp.from(updatedTime.toInstant)) + updateStatement.setString(9, name) + updateStatement.executeUpdate() + updateStatement.close() + } else { + // Insert new row + val insertStatement = conn.prepareStatement( + s""" + |INSERT INTO list(name, former_names, world, former_worlds, guild_name, level, vocation, last_login, time) + |VALUES (?,?,?,?,?,?,?,?,?); + |""".stripMargin + ) + insertStatement.setString(1, name) + insertStatement.setString(2, formerNames.mkString(",")) + insertStatement.setString(3, world.capitalize) + insertStatement.setString(4, formerWorlds.mkString(",")) + insertStatement.setString(5, guild) + insertStatement.setString(6, level) + insertStatement.setString(7, vocation) + insertStatement.setString(8, lastLogin) + insertStatement.setTimestamp(9, Timestamp.from(updatedTime.toInstant)) + insertStatement.executeUpdate() + insertStatement.close() + } - val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") - val results = new ListBuffer[BoostedCache]() - while (result.next()) { - val boss = Option(result.getString("boss")).getOrElse("None") - val creature = Option(result.getString("creature")).getOrElse("None") - val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") - val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") - results += BoostedCache(boss, creature, bossChanged, creatureChanged) + selectStatement.close() } - if (results.isEmpty) { - // If the result list is empty, insert default values - val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") - insertStatement.setString(1, "None") // Default value for boss - insertStatement.setString(2, "None") // Default value for creature - insertStatement.setString(3, "0") - insertStatement.setString(4, "0") - insertStatement.executeUpdate() - insertStatement.close() - - results += BoostedCache("None", "None", "0", "0") + def removeExpiredList(now: ZonedDateTime): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + // Modify the DELETE statement to include a WHERE clause with the condition for time + val deleteStatement = conn.prepareStatement("DELETE FROM list WHERE time < ?;") + deleteStatement.setTimestamp(1, Timestamp.from(now.minus(7, ChronoUnit.DAYS).toInstant)) + deleteStatement.executeUpdate() + deleteStatement.close() } - statement.close() - conn.close() - results.toList - } + def getBoosted(): List[BoostedCache] = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + + val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'boosted_info'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createListTable = + s"""CREATE TABLE boosted_info ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |boss VARCHAR(255) NOT NULL, + |bosschanged VARCHAR(255) NOT NULL, + |creature VARCHAR(255) NOT NULL, + |creaturechanged VARCHAR(255) NOT NULL + );""".stripMargin + + statement.executeUpdate(createListTable) + } + + // Check if the column already exists in the table + val bossChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'bosschanged'") + val bossChangedExists = bossChangedExistsQuery.next() + bossChangedExistsQuery.close() - def updateBoosted(boss: String, creature: String, bossChanged: String, creatureChanged: String): Unit = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() + // Check if the column already exists in the table + val creatureChangedExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'boosted_info' AND COLUMN_NAME = 'creaturechanged'") + val creatureChangedExists = creatureChangedExistsQuery.next() + creatureChangedExistsQuery.close() - val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") + // Add the column if it doesn't exist + if (!bossChangedExists) { + statement.execute("ALTER TABLE boosted_info ADD COLUMN bosschanged VARCHAR(255) DEFAULT '0'") + } - val results = new ListBuffer[BoostedCache]() - while (result.next()) { - val boss = Option(result.getString("boss")).getOrElse("None") - val creature = Option(result.getString("creature")).getOrElse("None") - val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") - val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") + // Add the column if it doesn't exist + if (!creatureChangedExists) { + statement.execute("ALTER TABLE boosted_info ADD COLUMN creaturechanged VARCHAR(255) DEFAULT '0'") + } - results += BoostedCache(boss, creature, bossChanged, creatureChanged) - } - statement.close() - - if (results.isEmpty) { - // If the result list is empty, insert default values - val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") - insertStatement.setString(1, "None") // Default value for boss - insertStatement.setString(2, "None") // Default value for creature - insertStatement.setString(3, "0") - insertStatement.setString(4, "0") - insertStatement.executeUpdate() - insertStatement.close() - } + val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") + val results = new ListBuffer[BoostedCache]() + while (result.next()) { + val boss = Option(result.getString("boss")).getOrElse("None") + val creature = Option(result.getString("creature")).getOrElse("None") + val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") + val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") + results += BoostedCache(boss, creature, bossChanged, creatureChanged) + } + + if (results.isEmpty) { + // If the result list is empty, insert default values + val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") + insertStatement.setString(1, "None") // Default value for boss + insertStatement.setString(2, "None") // Default value for creature + insertStatement.setString(3, "0") + insertStatement.setString(4, "0") + insertStatement.executeUpdate() + insertStatement.close() + + results += BoostedCache("None", "None", "0", "0") + } - // update category if exists - if (boss != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET boss = ?;") - statement.setString(1, boss) - statement.executeUpdate() - statement.close() - } - if (creature != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET creature = ?;") - statement.setString(1, creature) - statement.executeUpdate() - statement.close() - } - if (bossChanged != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET bosschanged = ?;") - statement.setString(1, bossChanged) - statement.executeUpdate() statement.close() + results.toList } - if (creatureChanged != "") { - val statement = conn.prepareStatement("UPDATE boosted_info SET creaturechanged = ?;") - statement.setString(1, creatureChanged) - statement.executeUpdate() + + def updateBoosted(boss: String, creature: String, bossChanged: String, creatureChanged: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + + val result = statement.executeQuery(s"SELECT boss,creature,bosschanged,creaturechanged FROM boosted_info;") + + val results = new ListBuffer[BoostedCache]() + while (result.next()) { + val boss = Option(result.getString("boss")).getOrElse("None") + val creature = Option(result.getString("creature")).getOrElse("None") + val bossChanged = Option(result.getString("bosschanged")).getOrElse("0") + val creatureChanged = Option(result.getString("creaturechanged")).getOrElse("0") + + results += BoostedCache(boss, creature, bossChanged, creatureChanged) + } statement.close() - } - conn.close() - } + if (results.isEmpty) { + // If the result list is empty, insert default values + val insertStatement = conn.prepareStatement("INSERT INTO boosted_info (boss, creature, bosschanged, creaturechanged) VALUES (?, ?, ?, ?);") + insertStatement.setString(1, "None") // Default value for boss + insertStatement.setString(2, "None") // Default value for creature + insertStatement.setString(3, "0") + insertStatement.setString(4, "0") + insertStatement.executeUpdate() + insertStatement.close() + } + + // update category if exists + if (boss != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET boss = ?;") + statement.setString(1, boss) + statement.executeUpdate() + statement.close() + } + if (creature != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET creature = ?;") + statement.setString(1, creature) + statement.executeUpdate() + statement.close() + } + if (bossChanged != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET bosschanged = ?;") + statement.setString(1, bossChanged) + statement.executeUpdate() + statement.close() + } + if (creatureChanged != "") { + val statement = conn.prepareStatement("UPDATE boosted_info SET creaturechanged = ?;") + statement.setString(1, creatureChanged) + statement.executeUpdate() + statement.close() + } + } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala index 1cc8dbc..55e2db5 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcCustomSortRepository.scala @@ -8,83 +8,80 @@ import scala.collection.mutable.ListBuffer /** JDBC implementation of CustomSortRepository. Bodies moved verbatim from * BotApp's customSortConfig and the *OnlineListCategory*ToDatabase methods, - * with the Guild parameter reduced to guildId. */ + * with the Guild parameter reduced to guildId and routed through + * JdbcSupport.withConnection so the connection is always released. */ final class JdbcCustomSortRepository(connectionProvider: ConnectionProvider) extends CustomSortRepository { - def getAll(guildId: String): List[CustomSort] = { - val conn = connectionProvider.guild(guildId) - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'online_list_categories'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createCustomSortTable = - s"""CREATE TABLE online_list_categories ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |entity VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |label VARCHAR(255) NOT NULL, - |emoji VARCHAR(255) NOT NULL, - |added VARCHAR(255) NOT NULL - |);""".stripMargin - - statement.executeUpdate(createCustomSortTable) + def getAll(guildId: String): List[CustomSort] = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'online_list_categories'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createCustomSortTable = + s"""CREATE TABLE online_list_categories ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |entity VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |label VARCHAR(255) NOT NULL, + |emoji VARCHAR(255) NOT NULL, + |added VARCHAR(255) NOT NULL + |);""".stripMargin + + statement.executeUpdate(createCustomSortTable) + } + + val result = statement.executeQuery(s"SELECT entity,name,label,emoji FROM online_list_categories") + + val results = new ListBuffer[CustomSort]() + while (result.next()) { + val entity = Option(result.getString("entity")).getOrElse("") + val name = Option(result.getString("name")).getOrElse("") + val label = Option(result.getString("label")).getOrElse("") + val emoji = Option(result.getString("emoji")).getOrElse("") + + results += CustomSort(entity, name, label, emoji) + } + + statement.close() + results.toList } - val result = statement.executeQuery(s"SELECT entity,name,label,emoji FROM online_list_categories") + def add(guildId: String, entity: String, name: String, label: String, emoji: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => + val query = "INSERT INTO online_list_categories(entity, name, label, emoji, added) VALUES (?, ?, ?, ?, ?);" + val statement = conn.prepareStatement(query) + statement.setString(1, entity) + statement.setString(2, name) + statement.setString(3, label) + statement.setString(4, emoji) + statement.setString(5, ZonedDateTime.now().toEpochSecond().toString) + statement.executeUpdate() + + statement.close() + } - val results = new ListBuffer[CustomSort]() - while (result.next()) { - val entity = Option(result.getString("entity")).getOrElse("") - val name = Option(result.getString("name")).getOrElse("") - val label = Option(result.getString("label")).getOrElse("") - val emoji = Option(result.getString("emoji")).getOrElse("") + def removeByNameEntity(guildId: String, entity: String, name: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => + val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE name = ? AND entity = ?;") + statement.setString(1, name) + statement.setString(2, entity) + statement.executeUpdate() - results += CustomSort(entity, name, label, emoji) + statement.close() } - statement.close() - conn.close() - results.toList - } - - def add(guildId: String, entity: String, name: String, label: String, emoji: String): Unit = { - val conn = connectionProvider.guild(guildId) - val query = "INSERT INTO online_list_categories(entity, name, label, emoji, added) VALUES (?, ?, ?, ?, ?);" - val statement = conn.prepareStatement(query) - statement.setString(1, entity) - statement.setString(2, name) - statement.setString(3, label) - statement.setString(4, emoji) - statement.setString(5, ZonedDateTime.now().toEpochSecond().toString) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def removeByNameEntity(guildId: String, entity: String, name: String): Unit = { - val conn = connectionProvider.guild(guildId) - val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE name = ? AND entity = ?;") - statement.setString(1, name) - statement.setString(2, entity) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def removeByLabel(guildId: String, label: String): Unit = { - val conn = connectionProvider.guild(guildId) - val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE LOWER(label) = LOWER(?);") - statement.setString(1, label) - statement.executeUpdate() - - statement.close() - conn.close() - } + def removeByLabel(guildId: String, label: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => + val statement = conn.prepareStatement(s"DELETE FROM online_list_categories WHERE LOWER(label) = LOWER(?);") + statement.setString(1, label) + statement.executeUpdate() + + statement.close() + } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala index 16fa717..2f299f8 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcDiscordConfigRepository.scala @@ -7,119 +7,116 @@ import java.time.ZonedDateTime /** JDBC implementation of DiscordConfigRepository. Bodies moved verbatim from * BotApp's discordRetrieveConfig/discordCreateConfig/discordUpdateConfig, with - * the Guild parameter reduced to guildId. */ + * the Guild parameter reduced to guildId and routed through + * JdbcSupport.withConnection so the connection is always released. */ final class JdbcDiscordConfigRepository(connectionProvider: ConnectionProvider) extends DiscordConfigRepository { - def getConfig(guildId: String): Map[String, String] = { - val conn = connectionProvider.guild(guildId) - val statement = conn.createStatement() + def getConfig(guildId: String): Map[String, String] = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => + val statement = conn.createStatement() + + val channelExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_channel'") + val channelExists = channelExistsQuery.next() + channelExistsQuery.close() + + // Add the column if it doesn't exist + if (!channelExists) { + statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_channel VARCHAR(255) DEFAULT '0'") + } + + val lastWorldExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'last_world'") + val lastWorldExists = lastWorldExistsQuery.next() + lastWorldExistsQuery.close() + + // Add the column if it doesn't exist + if (!lastWorldExists) { + statement.execute("ALTER TABLE discord_info ADD COLUMN last_world VARCHAR(255) DEFAULT '0'") + } + + val messageExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_messageid'") + val messageExists = messageExistsQuery.next() + messageExistsQuery.close() + + // Add the column if it doesn't exist + if (!messageExists) { + statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_messageid VARCHAR(255) DEFAULT '0'") + } + + val result = statement.executeQuery(s"SELECT * FROM discord_info") + var configMap = Map[String, String]() + while (result.next()) { + configMap += ("guild_name" -> result.getString("guild_name")) + configMap += ("guild_owner" -> result.getString("guild_owner")) + configMap += ("admin_category" -> result.getString("admin_category")) + configMap += ("admin_channel" -> result.getString("admin_channel")) + configMap += ("boosted_channel" -> result.getString("boosted_channel")) + configMap += ("boosted_messageid" -> result.getString("boosted_messageid")) + configMap += ("last_world" -> result.getString("last_world")) + configMap += ("flags" -> result.getString("flags")) + configMap += ("created" -> result.getString("created")) + } - val channelExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_channel'") - val channelExists = channelExistsQuery.next() - channelExistsQuery.close() - - // Add the column if it doesn't exist - if (!channelExists) { - statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_channel VARCHAR(255) DEFAULT '0'") - } - - val lastWorldExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'last_world'") - val lastWorldExists = lastWorldExistsQuery.next() - lastWorldExistsQuery.close() - - // Add the column if it doesn't exist - if (!lastWorldExists) { - statement.execute("ALTER TABLE discord_info ADD COLUMN last_world VARCHAR(255) DEFAULT '0'") - } - - val messageExistsQuery = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'discord_info' AND COLUMN_NAME = 'boosted_messageid'") - val messageExists = messageExistsQuery.next() - messageExistsQuery.close() - - // Add the column if it doesn't exist - if (!messageExists) { - statement.execute("ALTER TABLE discord_info ADD COLUMN boosted_messageid VARCHAR(255) DEFAULT '0'") - } - - val result = statement.executeQuery(s"SELECT * FROM discord_info") - var configMap = Map[String, String]() - while (result.next()) { - configMap += ("guild_name" -> result.getString("guild_name")) - configMap += ("guild_owner" -> result.getString("guild_owner")) - configMap += ("admin_category" -> result.getString("admin_category")) - configMap += ("admin_channel" -> result.getString("admin_channel")) - configMap += ("boosted_channel" -> result.getString("boosted_channel")) - configMap += ("boosted_messageid" -> result.getString("boosted_messageid")) - configMap += ("last_world" -> result.getString("last_world")) - configMap += ("flags" -> result.getString("flags")) - configMap += ("created" -> result.getString("created")) - } - - statement.close() - conn.close() - configMap - } - - def create(guildId: String, guildName: String, guildOwner: String, adminCategory: String, - adminChannel: String, boostedChannel: String, boostedMessageId: String, created: ZonedDateTime): Unit = { - val conn = connectionProvider.guild(guildId) - val statement = conn.prepareStatement("INSERT INTO discord_info(guild_name, guild_owner, admin_category, admin_channel, boosted_channel, boosted_messageid, flags, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(guild_name) DO UPDATE SET guild_owner = EXCLUDED.guild_owner, admin_category = EXCLUDED.admin_category, admin_channel = EXCLUDED.admin_channel, boosted_channel = EXCLUDED.boosted_channel, boosted_messageid = EXCLUDED.boosted_messageid, flags = EXCLUDED.flags, created = EXCLUDED.created;") - statement.setString(1, guildName) - statement.setString(2, guildOwner) - statement.setString(3, adminCategory) - statement.setString(4, adminChannel) - statement.setString(5, boostedChannel) - statement.setString(6, boostedMessageId) - statement.setString(7, "none") - statement.setTimestamp(8, Timestamp.from(created.toInstant)) - statement.executeUpdate() - - statement.close() - conn.close() - } - - def update(guildId: String, adminCategory: String, adminChannel: String, boostedChannel: String, - boostedMessage: String, lastWorld: String): Unit = { - val conn = connectionProvider.guild(guildId) - // update category if exists - if (adminCategory != "") { - val statement = conn.prepareStatement("UPDATE discord_info SET admin_category = ?;") - statement.setString(1, adminCategory) - statement.executeUpdate() - statement.close() - } - if (adminChannel != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET admin_channel = ?;") - statement.setString(1, adminChannel) - statement.executeUpdate() statement.close() + configMap } - if (boostedChannel != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET boosted_channel = ?;") - statement.setString(1, boostedChannel) + def create(guildId: String, guildName: String, guildOwner: String, adminCategory: String, + adminChannel: String, boostedChannel: String, boostedMessageId: String, created: ZonedDateTime): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => + val statement = conn.prepareStatement("INSERT INTO discord_info(guild_name, guild_owner, admin_category, admin_channel, boosted_channel, boosted_messageid, flags, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(guild_name) DO UPDATE SET guild_owner = EXCLUDED.guild_owner, admin_category = EXCLUDED.admin_category, admin_channel = EXCLUDED.admin_channel, boosted_channel = EXCLUDED.boosted_channel, boosted_messageid = EXCLUDED.boosted_messageid, flags = EXCLUDED.flags, created = EXCLUDED.created;") + statement.setString(1, guildName) + statement.setString(2, guildOwner) + statement.setString(3, adminCategory) + statement.setString(4, adminChannel) + statement.setString(5, boostedChannel) + statement.setString(6, boostedMessageId) + statement.setString(7, "none") + statement.setTimestamp(8, Timestamp.from(created.toInstant)) statement.executeUpdate() - statement.close() - } - if (boostedMessage != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET boosted_messageid = ?;") - statement.setString(1, boostedMessage) - statement.executeUpdate() statement.close() } - if (lastWorld != "") { - // update channel - val statement = conn.prepareStatement("UPDATE discord_info SET last_world = ?;") - statement.setString(1, lastWorld) - statement.executeUpdate() - statement.close() + def update(guildId: String, adminCategory: String, adminChannel: String, boostedChannel: String, + boostedMessage: String, lastWorld: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => + // update category if exists + if (adminCategory != "") { + val statement = conn.prepareStatement("UPDATE discord_info SET admin_category = ?;") + statement.setString(1, adminCategory) + statement.executeUpdate() + statement.close() + } + if (adminChannel != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET admin_channel = ?;") + statement.setString(1, adminChannel) + statement.executeUpdate() + statement.close() + } + + if (boostedChannel != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET boosted_channel = ?;") + statement.setString(1, boostedChannel) + statement.executeUpdate() + statement.close() + } + + if (boostedMessage != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET boosted_messageid = ?;") + statement.setString(1, boostedMessage) + statement.executeUpdate() + statement.close() + } + + if (lastWorld != "") { + // update channel + val statement = conn.prepareStatement("UPDATE discord_info SET last_world = ?;") + statement.setString(1, lastWorld) + statement.executeUpdate() + statement.close() + } } - - conn.close() - } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala index 9324de0..c95cfff 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcGalthenRepository.scala @@ -9,112 +9,107 @@ import scala.collection.mutable.ListBuffer import scala.util.Try /** JDBC implementation of GalthenRepository. Bodies moved verbatim from BotApp's - * getGalthenTable/addGalthen/delGalthen/delAllGalthen — no behaviour change. */ + * getGalthenTable/addGalthen/delGalthen/delAllGalthen, routed through + * JdbcSupport.withConnection so the connection is always released. */ final class JdbcGalthenRepository(connectionProvider: ConnectionProvider) extends GalthenRepository { - def getStamps(userId: String): Option[List[SatchelStamp]] = { - val conn = connectionProvider.cache() - val statement = conn.createStatement() - - // Check if the table already exists in bot_configuration - val tableExistsQuery = - statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'satchel'") - val tableExists = tableExistsQuery.next() - tableExistsQuery.close() - - // Create the table if it doesn't exist - if (!tableExists) { - val createListTable = - s"""CREATE TABLE satchel ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL, - |tag VARCHAR(255) - |);""".stripMargin - - statement.executeUpdate(createListTable) + def getStamps(userId: String): Option[List[SatchelStamp]] = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val statement = conn.createStatement() + + // Check if the table already exists in bot_configuration + val tableExistsQuery = + statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'satchel'") + val tableExists = tableExistsQuery.next() + tableExistsQuery.close() + + // Create the table if it doesn't exist + if (!tableExists) { + val createListTable = + s"""CREATE TABLE satchel ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |userid VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL, + |tag VARCHAR(255) + |);""".stripMargin + + statement.executeUpdate(createListTable) + } + + val result = statement.executeQuery(s"SELECT time,tag FROM satchel WHERE userid = '$userId';") + + val satchelStampList: ListBuffer[SatchelStamp] = ListBuffer() + + while (result.next()) { + val updatedTimeTemporal = + Try(Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z"))) + .getOrElse(Instant.parse("2022-01-01T01:00:00Z")) + val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) + val tag = Option(result.getString("tag")).getOrElse("") + + val satchelStamp = SatchelStamp(userId, updatedTime, tag) + satchelStampList += satchelStamp + } + + statement.close() + Some(satchelStampList.toList) } - val result = statement.executeQuery(s"SELECT time,tag FROM satchel WHERE userid = '$userId';") + def del(user: String, tag: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ? AND COALESCE(tag, '') = ?;") + deleteStatement.setString(1, user) + deleteStatement.setString(2, tag) + deleteStatement.executeUpdate() - val satchelStampList: ListBuffer[SatchelStamp] = ListBuffer() + deleteStatement.close() + } - while (result.next()) { - val updatedTimeTemporal = - Try(Option(result.getTimestamp("time").toInstant).getOrElse(Instant.parse("2022-01-01T01:00:00Z"))) - .getOrElse(Instant.parse("2022-01-01T01:00:00Z")) - val updatedTime = updatedTimeTemporal.atZone(ZoneOffset.UTC) - val tag = Option(result.getString("tag")).getOrElse("") + def delAll(user: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ?;") + deleteStatement.setString(1, user) + deleteStatement.executeUpdate() - val satchelStamp = SatchelStamp(userId, updatedTime, tag) - satchelStampList += satchelStamp + deleteStatement.close() } - statement.close() - conn.close() - Some(satchelStampList.toList) - } - - def del(user: String, tag: String): Unit = { - val conn = connectionProvider.cache() - - val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ? AND COALESCE(tag, '') = ?;") - deleteStatement.setString(1, user) - deleteStatement.setString(2, tag) - deleteStatement.executeUpdate() - - deleteStatement.close() - conn.close() - } - - def delAll(user: String): Unit = { - val conn = connectionProvider.cache() - - val deleteStatement = conn.prepareStatement("DELETE FROM satchel WHERE userid = ?;") - deleteStatement.setString(1, user) - deleteStatement.executeUpdate() - - deleteStatement.close() - conn.close() - } - - def add(user: String, when: ZonedDateTime, tag: String): Unit = { - val conn = connectionProvider.cache() - val selectStatement = conn.prepareStatement("SELECT time FROM satchel WHERE userid = ? AND tag = ?;") - selectStatement.setString(1, user) - selectStatement.setString(2, tag) - val resultSet = selectStatement.executeQuery() - - if (resultSet.next()) { - // Update existing row - val updateStatement = conn.prepareStatement( - s""" - |UPDATE satchel - |SET time = ? - |WHERE userid = ? AND tag = ?; - |""".stripMargin - ) - updateStatement.setTimestamp(1, Timestamp.from(when.toInstant)) - updateStatement.setString(2, user) - updateStatement.setString(3, tag) - updateStatement.executeUpdate() - updateStatement.close() - } else { - // Insert new row - val insertStatement = conn.prepareStatement( - s""" - |INSERT INTO satchel(userid, time, tag) - |VALUES (?,?,?); - |""".stripMargin - ) - insertStatement.setString(1, user) - insertStatement.setTimestamp(2, Timestamp.from(when.toInstant)) - insertStatement.setString(3, tag) - insertStatement.executeUpdate() - insertStatement.close() + def add(user: String, when: ZonedDateTime, tag: String): Unit = + JdbcSupport.withConnection(connectionProvider.cache) { conn => + val selectStatement = conn.prepareStatement("SELECT time FROM satchel WHERE userid = ? AND tag = ?;") + selectStatement.setString(1, user) + selectStatement.setString(2, tag) + val resultSet = selectStatement.executeQuery() + + if (resultSet.next()) { + // Update existing row + val updateStatement = conn.prepareStatement( + s""" + |UPDATE satchel + |SET time = ? + |WHERE userid = ? AND tag = ?; + |""".stripMargin + ) + updateStatement.setTimestamp(1, Timestamp.from(when.toInstant)) + updateStatement.setString(2, user) + updateStatement.setString(3, tag) + updateStatement.executeUpdate() + updateStatement.close() + } else { + // Insert new row + val insertStatement = conn.prepareStatement( + s""" + |INSERT INTO satchel(userid, time, tag) + |VALUES (?,?,?); + |""".stripMargin + ) + insertStatement.setString(1, user) + insertStatement.setTimestamp(2, Timestamp.from(when.toInstant)) + insertStatement.setString(3, tag) + insertStatement.executeUpdate() + insertStatement.close() + } + + selectStatement.close() } - - selectStatement.close() - conn.close() - } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala index bc4a9b0..f913b18 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcHuntedAlliedRepository.scala @@ -13,8 +13,8 @@ import scala.collection.mutable.ListBuffer * same option logic as before (kept verbatim; not user input). */ final class JdbcHuntedAlliedRepository(connectionProvider: ConnectionProvider) extends HuntedAlliedRepository { - def getPlayers(guildId: String, query: String): List[Players] = { - val conn = connectionProvider.guild(guildId) + def getPlayers(guildId: String, query: String): List[Players] = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.createStatement() val result = statement.executeQuery(s"SELECT name,reason,reason_text,added_by FROM $query") @@ -28,12 +28,11 @@ final class JdbcHuntedAlliedRepository(connectionProvider: ConnectionProvider) e } statement.close() - conn.close() results.toList } - def getGuilds(guildId: String, query: String): List[Guilds] = { - val conn = connectionProvider.guild(guildId) + def getGuilds(guildId: String, query: String): List[Guilds] = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.createStatement() val result = statement.executeQuery(s"SELECT name,reason,reason_text,added_by FROM $query") @@ -47,12 +46,11 @@ final class JdbcHuntedAlliedRepository(connectionProvider: ConnectionProvider) e } statement.close() - conn.close() results.toList } - def addHunted(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = { - val conn = connectionProvider.guild(guildId) + def addHunted(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val table = (if (option == "guild") "hunted_guilds" else if (option == "player") "hunted_players").toString val statement = conn.prepareStatement(s"INSERT INTO $table(name, reason, reason_text, added_by) VALUES (?,?,?,?) ON CONFLICT (name) DO NOTHING;") statement.setString(1, name) @@ -62,11 +60,10 @@ final class JdbcHuntedAlliedRepository(connectionProvider: ConnectionProvider) e statement.executeUpdate() statement.close() - conn.close() } - def addAllied(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = { - val conn = connectionProvider.guild(guildId) + def addAllied(guildId: String, option: String, name: String, reason: String, reasonText: String, addedBy: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val table = (if (option == "guild") "allied_guilds" else if (option == "player") "allied_players").toString val statement = conn.prepareStatement(s"INSERT INTO $table(name, reason, reason_text, added_by) VALUES (?,?,?,?) ON CONFLICT (name) DO NOTHING;") statement.setString(1, name) @@ -76,29 +73,26 @@ final class JdbcHuntedAlliedRepository(connectionProvider: ConnectionProvider) e statement.executeUpdate() statement.close() - conn.close() } - def removeHunted(guildId: String, option: String, name: String): Unit = { - val conn = connectionProvider.guild(guildId) + def removeHunted(guildId: String, option: String, name: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val table = (if (option == "guild") "hunted_guilds" else if (option == "player") "hunted_players").toString val statement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") statement.setString(1, name) statement.executeUpdate() statement.close() - conn.close() } - def removeAllied(guildId: String, option: String, name: String): Unit = { - val conn = connectionProvider.guild(guildId) + def removeAllied(guildId: String, option: String, name: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val table = (if (option == "guild") "allied_guilds" else if (option == "player") "allied_players").toString val statement = conn.prepareStatement(s"DELETE FROM $table WHERE LOWER(name) = LOWER(?);") statement.setString(1, name) statement.executeUpdate() statement.close() - conn.close() } def rename(guildId: String, option: String, oldName: String, newName: String): Unit = { diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcSupport.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcSupport.scala new file mode 100644 index 0000000..82ac21d --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcSupport.scala @@ -0,0 +1,19 @@ +package com.tibiabot.persistence.jdbc + +import java.sql.Connection + +/** Shared JDBC resource helpers for the repositories. + * + * The repos previously closed their connection only on the happy path, so any + * SQL exception leaked the connection — under concurrent multi-guild load that + * exhausts Postgres' connection limit. `withConnection` guarantees the + * connection is closed (which also closes its statements/result sets) whether + * the body returns or throws. + */ +private[persistence] object JdbcSupport { + def withConnection[A](connect: () => Connection)(use: Connection => A): A = { + val conn = connect() + try use(conn) + finally conn.close() + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala index 510dba8..59f67da 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/jdbc/JdbcWorldConfigRepository.scala @@ -1,6 +1,6 @@ package com.tibiabot.persistence.jdbc -import com.tibiabot.domain.Worlds +import com.tibiabot.domain.{Worlds, WorldName} import com.tibiabot.persistence.{ConnectionProvider, WorldConfigRepository} import scala.collection.mutable.ListBuffer @@ -13,8 +13,8 @@ import scala.util.{Failure, Success, Try} * loading and testable. */ final class JdbcWorldConfigRepository(connectionProvider: ConnectionProvider, mergedWorlds: List[String]) extends WorldConfigRepository { - def listWorlds(guildId: String): List[Worlds] = { - val conn = connectionProvider.guild(guildId) + def listWorlds(guildId: String): List[Worlds] = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.createStatement() // Check if the column already exists in the table @@ -105,17 +105,16 @@ final class JdbcWorldConfigRepository(connectionProvider: ConnectionProvider, me } statement.close() - conn.close() results.toList } def createWorld(guildId: String, world: String, alliesChannel: String, enemiesChannel: String, neutralsChannels: String, levelsChannel: String, deathsChannel: String, category: String, fullblessRole: String, nemesisRole: String, allyPkRole: String, masslogRole: String, - fullblessChannel: String, nemesisChannel: String, activityChannel: String): Unit = { - val conn = connectionProvider.guild(guildId) + fullblessChannel: String, nemesisChannel: String, activityChannel: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement("INSERT INTO worlds(name, allies_channel, enemies_channel, neutrals_channel, levels_channel, deaths_channel, category, fullbless_role, nemesis_role, allypk_role, masslog_role, fullbless_channel, nemesis_channel, fullbless_level, show_neutral_levels, show_neutral_deaths, show_allies_levels, show_allies_deaths, show_enemies_levels, show_enemies_deaths, detect_hunteds, levels_min, deaths_min, exiva_list, activity_channel, online_combined) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO UPDATE SET allies_channel = ?, enemies_channel = ?, neutrals_channel = ?, levels_channel = ?, deaths_channel = ?, category = ?, fullbless_role = ?, nemesis_role = ?, allypk_role = ?, masslog_role = ?, fullbless_channel = ?, nemesis_channel = ?, fullbless_level = ?, show_neutral_levels = ?, show_neutral_deaths = ?, show_allies_levels = ?, show_allies_deaths = ?, show_enemies_levels = ?, show_enemies_deaths = ?, detect_hunteds = ?, levels_min = ?, deaths_min = ?, exiva_list = ?, activity_channel = ?, online_combined = ?;") - val formalQuery = world.toLowerCase().capitalize + val formalQuery = WorldName.formal(world) statement.setString(1, formalQuery) statement.setString(2, alliesChannel) statement.setString(3, enemiesChannel) @@ -170,13 +169,12 @@ final class JdbcWorldConfigRepository(connectionProvider: ConnectionProvider, me statement.executeUpdate() statement.close() - conn.close() } - def retrieveWorld(guildId: String, world: String): Map[String, String] = { - val conn = connectionProvider.guild(guildId) + def retrieveWorld(guildId: String, world: String): Map[String, String] = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement("SELECT * FROM worlds WHERE name = ?;") - val formalWorld = world.toLowerCase().capitalize + val formalWorld = WorldName.formal(world) statement.setString(1, formalWorld) val result = statement.executeQuery() @@ -215,40 +213,36 @@ final class JdbcWorldConfigRepository(connectionProvider: ConnectionProvider, me configMap += ("combined_online" -> combinedOnlineValue) } statement.close() - conn.close() configMap } - def removeWorld(guildId: String, world: String): Unit = { - val conn = connectionProvider.guild(guildId) + def removeWorld(guildId: String, world: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement("DELETE FROM worlds WHERE name = ?") - val formalName = world.toLowerCase().capitalize + val formalName = WorldName.formal(world) statement.setString(1, formalName) statement.executeUpdate() statement.close() - conn.close() } - def updateWorldString(guildId: String, world: String, column: String, value: String): Unit = { - val conn = connectionProvider.guild(guildId) + def updateWorldString(guildId: String, world: String, column: String, value: String): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement(s"UPDATE worlds SET $column = ? WHERE name = ?;") statement.setString(1, value) statement.setString(2, world) statement.executeUpdate() statement.close() - conn.close() } - def updateWorldInt(guildId: String, world: String, column: String, value: Int): Unit = { - val conn = connectionProvider.guild(guildId) + def updateWorldInt(guildId: String, world: String, column: String, value: Int): Unit = + JdbcSupport.withConnection(() => connectionProvider.guild(guildId)) { conn => val statement = conn.prepareStatement(s"UPDATE worlds SET $column = ? WHERE name = ?;") statement.setInt(1, value) statement.setString(2, world) statement.executeUpdate() statement.close() - conn.close() } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala index 5d7e634..8fcda9c 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/BoostedEmbeds.scala @@ -3,15 +3,13 @@ package com.tibiabot.presentation import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.MessageEmbed -/** Pure builder for the boosted-boss/creature embed. Moved verbatim from - * BotApp.createBoostedEmbed (callers still pass Config emoji strings as args; - * this function itself is Config-free). */ +/** Pure builder for the boosted-boss/creature embed: a thumbnail, the fixed + * brand colour and a description. The embed has no title by design. */ object BoostedEmbeds { - def create(name: String, emoji: String, wikiUrl: String, thumbnail: String, embedText: String): MessageEmbed = { + def create(thumbnail: String, embedText: String): MessageEmbed = { val embed = new EmbedBuilder() - //embed.setTitle(s"$emoji $name $emoji", wikiUrl) embed.setThumbnail(thumbnail) - embed.setColor(3092790) + embed.setColor(Embeds.BrandColor) embed.setDescription(embedText) embed.build() } diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/BossEmoji.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/BossEmoji.scala new file mode 100644 index 0000000..5e1148c --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/BossEmoji.scala @@ -0,0 +1,47 @@ +package com.tibiabot.presentation + +import com.tibiabot.Config + +/** Maps a creature/boss name to its boss-tier emoji prefix (nemesis, archfoe, + * bane, cube, ...) when building death notifications. + * + * Extracted from TibiaBot's death block, where the whole category table was + * rebuilt as a Map on every killer of every death; here it is built once as a + * lazy val and matched in declared order (deterministic, unlike Map iteration). + * The matcher takes the categories as a parameter so it is unit-testable + * without loading Config; pinned by BossEmojiSpec. + */ +object BossEmoji { + + /** The first category whose name list contains `name.toLowerCase` wins (the + * lists are stored lower-cased); returns that category's emoji followed by a + * space, or "" if none match. */ + def categoryEmoji(name: String, categories: Seq[(Seq[String], String)]): String = + categories + .collectFirst { case (names, emoji) if names.contains(name.toLowerCase) => s"$emoji " } + .getOrElse("") + + /** Built once from Config — the death path previously rebuilt this per killer. */ + private lazy val categories: Seq[(Seq[String], String)] = Seq( + Config.nemesisCreatures -> Config.nemesisEmoji, + Config.archfoeCreatures -> Config.archfoeEmoji, + Config.baneCreatures -> Config.baneEmoji, + Config.bossSummons -> Config.summonEmoji, + Config.cubeBosses -> Config.cubeEmoji, + Config.mkBosses -> Config.mkEmoji, + Config.svarGreenBosses -> Config.svarGreenEmoji, + Config.svarScrapperBosses -> Config.svarScrapperEmoji, + Config.svarWarlordBosses -> Config.svarWarlordEmoji, + Config.zelosBosses -> Config.zelosEmoji, + Config.libBosses -> Config.libEmoji, + Config.hodBosses -> Config.hodEmoji, + Config.feruBosses -> Config.feruEmoji, + Config.inqBosses -> Config.inqEmoji, + Config.kilmareshBosses -> Config.kilmareshEmoji, + Config.primalCreatures -> Config.primalEmoji, + Config.hazardCreatures -> Config.hazardEmoji + ) + + /** The configured boss-tier emoji prefix for `name`, or "" if it is none. */ + def of(name: String): String = categoryEmoji(name, categories) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala new file mode 100644 index 0000000..4194608 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala @@ -0,0 +1,46 @@ +package com.tibiabot.presentation + +/** Death-notification thumbnails for deaths that have no sensible creature image. + * + * Tibia environmental/field deaths arrive from the API with the damage type as + * the killer name (e.g. `drowning`, `death`); for those we show an effect + * animation instead of looking up a (nonexistent) creature image. Player kills + * (`pvp`) and pure suicides are decided by the surrounding death logic, not by a + * killer name, so they are exposed as named constants rather than via [[thumbnail]]. + * + * [[thumbnail]] returns `None` for anything else — a regular creature or a player + * kill, or an environmental type we have no animation for — and the caller then + * falls back to the creature image. So an unmapped killer name is never worse + * than the previous behaviour; it can only upgrade a broken thumbnail to an effect. + */ +object DeathEffect { + + private def resource(file: String): String = + s"https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/$file" + + /** Animation for a player kill — set at the kill site, since "pvp" is a + * classification (killer.player), never a killer name the API sends. */ + val pvp: String = resource("Phantasmal_Ooze.gif") + + /** Animation for a pure suicide / assists-only death (empty killer list). */ + val suicide: String = resource("Ghost_Smoke_Effect.gif") + + /** Environmental damage types we have an animation for, keyed by the lowercased + * killer name the API reports for such deaths. */ + private val byDamageType: Map[String, String] = Map( + "death" -> resource("Death_Effect.gif"), + "ice" -> resource("Ice_Explosion_Effect.gif"), + "drowning" -> resource("Reaper_Effect.gif"), + "life drain" -> resource("Red_Sparkles_Effect.gif") + ) + + /** Damage-type killer names we have an effect animation for. Every one must be a + * recognised substance death source (`domain.Killers.substanceSources`); a + * DeathEffectSpec check guards against a key that could never match a real death. */ + def mappedDamageTypes: Set[String] = byDamageType.keySet + + /** The effect animation for an environmental death by `killerName`, or `None` + * for a creature/player kill (caller falls back to the creature image). */ + def thumbnail(killerName: String): Option[String] = + byDamageType.get(killerName.toLowerCase) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala index 88afca2..ca72e30 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEmbeds.scala @@ -18,4 +18,21 @@ object DeathEmbeds { embed.setColor(color) embed } + + // Death-embed allegiance colours, mirroring the embedColor assignments in the + // death-processing block: neutral covers the default plus its situational + // variants; enemy and ally each have a single colour. + private val neutralDeathColors = Set(3092790, 14869218, 4540237, 14397256) + private val enemyDeathColor = 36941 + private val allyDeathColor = 13773097 + + /** Whether a death embed should be posted, given its allegiance colour and the + * channel's per-category show flags ("false" suppresses that category). + * Colours outside the three allegiance groups (e.g. the purple "notable" + * case) are always shown. */ + def shouldShow(embedColor: Int, showNeutralDeaths: String, showAlliesDeaths: String, showEnemiesDeaths: String): Boolean = + if (neutralDeathColors.contains(embedColor)) showNeutralDeaths != "false" + else if (embedColor == enemyDeathColor) showEnemiesDeaths != "false" + else if (embedColor == allyDeathColor) showAlliesDeaths != "false" + else true } diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/EmbedText.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/EmbedText.scala new file mode 100644 index 0000000..fc43ea6 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/EmbedText.scala @@ -0,0 +1,23 @@ +package com.tibiabot.presentation + +/** Fits message text into Discord's 4096-char embed-description limit. + * + * Used by the /boosted replies, which show a (possibly long) notification list + * optionally followed by a one-line command result. When the whole thing would + * overflow, the list body is cut at a line boundary, an overflow marker is added, + * and the command line is re-appended so it is never lost. Text that already fits + * is returned unchanged. Pure; see EmbedTextSpec. + */ +object EmbedText { + private val overflowMarker = "\n\n*`...cannot display any more results`*" + + def fit(body: String, command: String = ""): String = { + val tail = if (command.isEmpty) "" else s"\n\n$command" + if ((body + tail).length >= 4096) { + val cut = body.lastIndexOf('\n', 4090 - overflowMarker.length - command.length) + body.substring(0, cut) + overflowMarker + tail + } else { + body + tail + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/Embeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/Embeds.scala new file mode 100644 index 0000000..495744f --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/Embeds.scala @@ -0,0 +1,17 @@ +package com.tibiabot.presentation + +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.MessageEmbed + +/** Builders for the bot's plain response embeds. */ +object Embeds { + + /** The standard embed colour used across the bot. */ + val BrandColor: Int = 3092790 + + /** A minimal response embed: the brand colour and a description, nothing else. + * Replaces the repeated `new EmbedBuilder().setColor(3092790) + * .setDescription(...).build()` chain used for simple command replies. */ + def response(description: String): MessageEmbed = + new EmbedBuilder().setColor(BrandColor).setDescription(description).build() +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala index 5ec8d86..f361781 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/Emojis.scala @@ -1,21 +1,14 @@ package com.tibiabot.presentation -/** Pure vocation -> Discord emoji mapping. +/** Pure vocation -> Discord emoji mapping. Matches a vocation to its emoji by + * its last word, so promoted names ("Elite Knight", "Exalted Monk") resolve to + * the base vocation. Used by both the stream (death/online) and command paths. * - * IMPORTANT: two variants exist because the original code diverged, and this - * extraction preserves BOTH behaviours exactly rather than silently unifying - * them (pinned by EmojisSpec): - * - * - `vocEmoji` — TibiaBot's version; includes the `monk` vocation. - * - `vocEmojiWithoutMonk`— BotApp's version; predates monks and omits it, - * so a monk maps to "" there. - * - * Reconciling the two (i.e. adding monk to BotApp's path) would be a behaviour - * change and is intentionally left as a separate, explicit decision. - */ + * Previously a second `vocEmojiWithoutMonk` variant existed because BotApp's + * original mapping predated monks and rendered them blank; that legacy variant + * has been unified away so monk players render consistently everywhere. */ object Emojis { - /** Includes monk. Matches the original `TibiaBot.vocEmoji`. */ def vocEmoji(vocation: String): String = vocation.toLowerCase.split(' ').last match { case "knight" => ":shield:" @@ -26,15 +19,4 @@ object Emojis { case "none" => ":hatching_chick:" case _ => "" } - - /** Omits monk. Matches the original `BotApp.vocEmoji`. */ - def vocEmojiWithoutMonk(vocation: String): String = - vocation.toLowerCase.split(' ').last match { - case "knight" => ":shield:" - case "druid" => ":snowflake:" - case "sorcerer" => ":fire:" - case "paladin" => ":bow_and_arrow:" - case "none" => ":hatching_chick:" - case _ => "" - } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/GuildActivity.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/GuildActivity.scala new file mode 100644 index 0000000..a56ef1e --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/GuildActivity.scala @@ -0,0 +1,16 @@ +package com.tibiabot.presentation + +/** Pure mappings for the guild-tracking activity notifications (a player + * joining / leaving / swapping a hunted or allied guild). Extracted from the + * activity block in TibiaBot, where both were repeated for each activity case. */ +object GuildActivity { + + /** Embed colour for a guild-join/swap activity: a hunted guild is red, an + * allied guild is green, and anything else is yellow. */ + def activityColor(huntedGuild: Boolean, alliedGuild: Boolean): Int = + if (huntedGuild) 13773097 else if (alliedGuild) 36941 else 14397256 + + /** The guild's tracked-status label, used in the activity description. */ + def guildType(huntedGuild: Boolean, alliedGuild: Boolean): String = + if (huntedGuild) "hunted" else if (alliedGuild) "allied" else "neutral" +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/GuildIcons.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/GuildIcons.scala new file mode 100644 index 0000000..c190177 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/GuildIcons.scala @@ -0,0 +1,134 @@ +package com.tibiabot.presentation + +import com.tibiabot.Config + +/** Pure classification of a character's relationship to a guild's allied / + * hunted lists, plus the Discord icon that represents it. + * + * Extracted from the two byte-for-byte identical `guildIcon` matches in + * TibiaBot (the level-notification path and the online-list builder). + * `classify` is Config-free so the priority ordering can be unit-tested + * directly (pinned by GuildIconsSpec); `icon`/`guildIcon` are the thin + * production mapping onto the configured emojis. + */ +object GuildIcons { + + sealed trait Relation + object Relation { + case object AllyGuild extends Relation + case object HuntedGuild extends Relation + case object AllyPlayerNoGuild extends Relation + case object AllyPlayerNeutralGuild extends Relation + case object HuntedPlayerNoGuild extends Relation + case object HuntedPlayerNeutralGuild extends Relation + case object NeutralNoGuild extends Relation + case object NeutralGuild extends Relation + } + + /** Classify with the same priority order as the original match: an allied + * guild wins over everything, then a hunted guild, then a player-level ally, + * then a player-level hunted — and within each player tier the no-guild case + * ("") is distinguished from the neutral-guild case. */ + def classify( + guildName: String, + allyGuild: Boolean, + huntedGuild: Boolean, + allyPlayer: Boolean, + huntedPlayer: Boolean + ): Relation = { + import Relation._ + (guildName, allyGuild, huntedGuild, allyPlayer, huntedPlayer) match { + case (_, true, _, _, _) => AllyGuild + case (_, _, true, _, _) => HuntedGuild + case ("", _, _, true, _) => AllyPlayerNoGuild + case (_, _, _, true, _) => AllyPlayerNeutralGuild + case ("", _, _, _, true) => HuntedPlayerNoGuild + case (_, _, _, _, true) => HuntedPlayerNeutralGuild + case ("", _, _, _, _) => NeutralNoGuild + case _ => NeutralGuild + } + } + + /** The configured Discord icon for a relation. */ + def icon(relation: Relation): String = { + import Relation._ + relation match { + case AllyGuild => Config.allyGuild + case HuntedGuild => Config.enemyGuild + case AllyPlayerNoGuild => Config.ally + case AllyPlayerNeutralGuild => s"${Config.otherGuild}${Config.ally}" + case HuntedPlayerNoGuild => Config.enemy + case HuntedPlayerNeutralGuild => s"${Config.otherGuild}${Config.enemy}" + case NeutralNoGuild => "" + case NeutralGuild => Config.otherGuild + } + } + + /** Classify and map to the configured icon in one call — the form the two + * TibiaBot call sites use. */ + def guildIcon( + guildName: String, + allyGuild: Boolean, + huntedGuild: Boolean, + allyPlayer: Boolean, + huntedPlayer: Boolean + ): String = icon(classify(guildName, allyGuild, huntedGuild, allyPlayer, huntedPlayer)) + + // --------------------------------------------------------------------------- + // List variant: the `/allies list` and `/hunted list` commands classify a row + // differently depending on which list is being rendered (`arg`), so a guild's + // ally/hunted status can cross with the list context. Extracted from the two + // identical matches in BotApp (the cached-row path and the fetched-char path). + // --------------------------------------------------------------------------- + + sealed trait ListRelation + object ListRelation { + case object AlliedGuild extends ListRelation // allies list: their guild is allied + case object AlliedPlayerInHuntedGuild extends ListRelation // allies list: but their guild is hunted + case object HuntedGuild extends ListRelation // hunted list: their guild is hunted + case object HuntedPlayerInAlliedGuild extends ListRelation // hunted list: but their guild is allied + case object HuntedPlayerNoGuild extends ListRelation + case object AlliedPlayerNoGuild extends ListRelation + case object HuntedPlayerNeutralGuild extends ListRelation + case object AlliedPlayerNeutralGuild extends ListRelation + case object Unclassified extends ListRelation // arg is neither "allies" nor "hunted" + } + + /** Classify a list row. `arg` is the list being rendered ("allies"/"hunted"). + * Config-free; preserves the original match order exactly. */ + def classifyList(guildName: String, allyGuild: Boolean, huntedGuild: Boolean, arg: String): ListRelation = { + import ListRelation._ + (guildName, allyGuild, huntedGuild, arg) match { + case (_, true, _, "allies") => AlliedGuild + case (_, _, true, "allies") => AlliedPlayerInHuntedGuild + case (_, _, true, "hunted") => HuntedGuild + case (_, true, _, "hunted") => HuntedPlayerInAlliedGuild + case ("", _, _, "hunted") => HuntedPlayerNoGuild + case ("", _, _, "allies") => AlliedPlayerNoGuild + case (_, _, _, "hunted") => HuntedPlayerNeutralGuild + case (_, _, _, "allies") => AlliedPlayerNeutralGuild + case _ => Unclassified + } + } + + /** The configured Discord icon for a list relation. */ + def listIcon(relation: ListRelation): String = { + import ListRelation._ + relation match { + case AlliedGuild => Config.allyGuild + case AlliedPlayerInHuntedGuild => s"${Config.enemyGuild}${Config.ally}" + case HuntedGuild => Config.enemyGuild + case HuntedPlayerInAlliedGuild => s"${Config.allyGuild}${Config.enemy}" + case HuntedPlayerNoGuild => Config.enemy + case AlliedPlayerNoGuild => Config.ally + case HuntedPlayerNeutralGuild => s"${Config.otherGuild}${Config.enemy}" + case AlliedPlayerNeutralGuild => s"${Config.otherGuild}${Config.ally}" + case Unclassified => "" + } + } + + /** Classify a list row and map to the configured icon in one call — the form + * the two BotApp call sites use. */ + def listGuildIcon(guildName: String, allyGuild: Boolean, huntedGuild: Boolean, arg: String): String = + listIcon(classifyList(guildName, allyGuild, huntedGuild, arg)) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/LevelVisibility.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/LevelVisibility.scala new file mode 100644 index 0000000..8bb094f --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/LevelVisibility.scala @@ -0,0 +1,24 @@ +package com.tibiabot.presentation + +/** Pure decision for whether a level-up should be posted to the levels channel. + * + * Mirrors the death-visibility rule but adds a minimum-level floor. The + * player's allegiance is resolved by the caller (the rendered guild icon is + * matched against the Config emoji lists, which can't live here without + * pulling in Config), so this takes the resolved category as three booleans. + * At most one is true; all false means an unrecognised category, gated by the + * level floor alone. */ +object LevelVisibility { + + /** A category whose show-flag is "false" is suppressed; otherwise the level-up + * shows only when it reaches `minimumLevel`. */ + def shouldPost( + isNeutral: Boolean, isAlly: Boolean, isEnemy: Boolean, + showNeutral: String, showAllies: String, showEnemies: String, + level: Int, minimumLevel: Int + ): Boolean = + if (isNeutral && showNeutral == "false") false + else if (isAlly && showAllies == "false") false + else if (isEnemy && showEnemies == "false") false + else level >= minimumLevel +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/ListEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/ListEmbeds.scala new file mode 100644 index 0000000..f75700f --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/ListEmbeds.scala @@ -0,0 +1,46 @@ +package com.tibiabot.presentation + +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.MessageEmbed + +import scala.collection.mutable.ListBuffer + +/** Paginates already-rendered lines into Discord embeds for the /allies and + * /hunted list output, where the players and guilds sections each ran the same + * pack-into-<=4096-char-embeds loop (only the first embed carrying the section + * thumbnail). Extracted verbatim from BotApp.listAlliesAndHuntedPlayers. */ +object ListEmbeds { + + /** Pack lines (newline-joined) into embeds whose descriptions stay within + * `limit` characters, all sharing `color` with only the first carrying + * `thumbnail`. Always returns at least one embed (an empty input yields a + * single empty-description embed, matching the original). */ + def paginate(values: List[String], thumbnail: String, color: Int, limit: Int = 4096): List[MessageEmbed] = + pack(values, limit).zipWithIndex.map { case (description, index) => + val embed = new EmbedBuilder() + embed.setDescription(description) + embed.setColor(color) + if (index == 0) embed.setThumbnail(thumbnail) + embed.build() + } + + /** Accumulate lines (newline-joined) into description chunks of at most `limit` + * chars. The first chunk keeps the leading newline from the empty seed; each + * subsequent one begins with the line that overflowed the previous. Always + * returns at least one chunk. Shared by [[paginate]] and the /admin guild + * list, which build embeds from the chunks differently. */ + def pack(values: List[String], limit: Int): List[String] = { + val fields = ListBuffer.empty[String] + var field = "" + values.foreach { v => + val currentField = field + "\n" + v + if (currentField.length <= limit) field = currentField + else { + fields += field + field = v + } + } + fields += field + fields.toList + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/Names.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/Names.scala new file mode 100644 index 0000000..e02b363 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/Names.scala @@ -0,0 +1,12 @@ +package com.tibiabot.presentation + +/** Name formatting shared by the command and boosted paths. */ +object Names { + + /** Upper-case the first letter of each space-separated word, leaving the rest + * of each word untouched (so "violent beams" -> "Violent Beams"). This is the + * exact `split(" ").map(_.capitalize).mkString(" ")` idiom that was repeated + * across BotApp and BoostedService. */ + def capitalizeWords(name: String): String = + name.split(" ").map(_.capitalize).mkString(" ") +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala index 3be059b..a702c4f 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListEmbeds.scala @@ -19,4 +19,60 @@ object OnlineListEmbeds { } s"`$durationStr`" } + + // Strips a trailing "-" suffix; the regex always matches, so for any + // real channel name the capture group is what's returned. + private val namePattern = "^(.*?)(?:-[0-9]+)?$".r + + /** Recover a user's custom channel base name by dropping the bot-appended + * "-" suffix (e.g. "ɴᴇᴍᴇsɪs-42" -> "ɴᴇᴍᴇsɪs"). Falls back to `default` + * only in the degenerate case where the pattern fails to match. Moved + * verbatim from TibiaBot.onlineList. */ + def baseName(channelName: String, default: String): String = + namePattern.findFirstMatchIn(channelName).map(_.group(1)).getOrElse(default) + + /** Build the online-list category name from the live ally/enemy counts: + * the world name, then "・🤍💀" with each count omitted when + * zero and the "・" separator dropped entirely when both are zero. The + * mass-log "⚡" suffix is appended separately by the caller (the rename + * guard compares against this icon-free name, matching the original). */ + def categoryName(world: String, alliesCount: Int, enemiesCount: Int): String = { + val allies = if (alliesCount > 0) s"🤍$alliesCount" else "" + val enemies = if (enemiesCount > 0) s"💀$enemiesCount" else "" + val spacer = if (alliesCount > 0 || enemiesCount > 0) "・" else "" + s"$world$spacer$allies$enemies" + } + + /** Pack online-list lines into the descriptions of one or more embeds, since a + * Discord embed description caps near 4096 characters. Lines accumulate + * (newline-joined) into the current embed until: + * - adding the line would reach 4060 chars, or reach 3850 with the line a + * guild header ("### ["), in which case the line starts a fresh embed; or + * - the line is a section header ("### " not followed by "["), which starts + * a fresh embed unless the current one is still empty. + * + * Always returns at least one element (the trailing embed), so an empty input + * yields one empty description — matching the original, which always emitted a + * final embed. Extracted verbatim from TibiaBot.updateMultiFields. */ + def packFields(values: List[String]): List[String] = { + val fields = scala.collection.mutable.ListBuffer.empty[String] + var field = "" + values.foreach { v => + val currentField = field + "\n" + v + if (currentField.length >= 4060 || (currentField.length >= 3850 && v.startsWith("### ["))) { + fields += field + field = v + } else if (v.matches("### [^\\[].*")) { + if (field == "") field = currentField + else { + fields += field + field = v + } + } else { + field = currentField + } + } + fields += field + fields.toList + } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListGrouping.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListGrouping.scala new file mode 100644 index 0000000..2015d26 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/OnlineListGrouping.scala @@ -0,0 +1,70 @@ +package com.tibiabot.presentation + +/** Pure layout logic for the online-list channels (allies / neutrals / enemies). + * Extracted from TibiaBot, where the same group-and-order block was repeated + * for each list with only the row filter differing. */ +object OnlineListGrouping { + + /** Groups online-list rows (each a `guildName -> renderedMessage` pair) by + * guild, ordering the guilds by descending member count and placing the + * guildless bucket (empty guild name) last. + * + * Guilds with equal member counts keep `groupBy`'s unspecified order, which + * reproduces the original behaviour verbatim. */ + def groupByGuild(rows: Iterable[(String, String)]): List[(String, List[String])] = + rows + .groupBy(_._1) + .view.mapValues(_.map(_._2).toList) + .toList + .partition(_._1.isEmpty) match { + case (guildless, withGuilds) => + withGuilds.sortBy { case (_, messages) => -messages.length } ++ guildless + } + + /** Flattens grouped guild buckets into the final markdown line list: each + * guild bucket is prefixed with a header linking its name to its guild page + * with the member count; the guildless bucket ("") is prefixed with + * `guildlessHeader(count)`. The bucket's message lines follow its header. */ + def withHeaders(grouped: List[(String, List[String])], guildlessHeader: Int => String): List[String] = + grouped.flatMap { + case ("", messages) => guildlessHeader(messages.length) :: messages + case (guildName, messages) => s"### [$guildName](${Urls.guildUrl(guildName)}) ${messages.length}" :: messages + } + + /** Assembles the body of the single combined online-list channel from the + * three already-rendered category lists. + * + * Allies and enemies each get a section header, but only when at least one + * other category is also present — a single-category list needs no header. + * When neutrals are the only category present and they carry no guild + * sub-headers (just the "### Others" bucket), that lone header is dropped so + * the list reads as a plain roster rather than a one-section list. + * + * @param neutralsList the raw neutrals roster, consulted only for emptiness + * @param flattenedNeutralsList the neutrals roster already rendered with "### Others"/guild headers + */ + def combinedChannelBody( + alliesList: List[String], + enemiesList: List[String], + neutralsList: List[String], + flattenedNeutralsList: List[String], + allyEmoji: String, + enemyEmoji: String + ): List[String] = { + val modifiedAllies = + if (alliesList.nonEmpty && (neutralsList.nonEmpty || enemiesList.nonEmpty)) + s"### $allyEmoji **Allies** $allyEmoji ${alliesList.size}" :: alliesList + else alliesList + val modifiedEnemies = + if (enemiesList.nonEmpty && (alliesList.nonEmpty || neutralsList.nonEmpty)) + s"### $enemyEmoji **Enemies** $enemyEmoji ${enemiesList.size}" :: enemiesList + else enemiesList + + val headerToRemove = "### Others" + val hasOtherHeaders = flattenedNeutralsList.exists(h => h.startsWith("### ") && !h.startsWith(headerToRemove)) + if (modifiedAllies.isEmpty && modifiedEnemies.isEmpty && !hasOtherHeaders) + flattenedNeutralsList.filterNot(_.startsWith(headerToRemove)) + else + modifiedAllies ++ modifiedEnemies ++ flattenedNeutralsList + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/RecentLogin.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/RecentLogin.scala new file mode 100644 index 0000000..1ad5a14 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/RecentLogin.scala @@ -0,0 +1,22 @@ +package com.tibiabot.presentation + +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import scala.util.Try + +/** Renders a character's last-login time for the `/allies list` and + * `/hunted list` output. Extracted from BotApp.dateStringToEpochSeconds so it + * is testable with a fixed clock and so malformed input can't crash the list + * command. Pinned by RecentLoginSpec. */ +object RecentLogin { + + /** If `dateString` (an ISO-8601 instant from the API) is within 24h of `now`, + * render the Discord "daily" emoji plus a relative timestamp; otherwise "". + * Empty or unparseable input yields "" rather than throwing. */ + def stamp(dateString: String, now: Instant): String = + Try(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(dateString))).toOption + .filter(instant => Math.abs(instant.until(now, ChronoUnit.HOURS)) <= 24) + .map(instant => s"<:daily:1133349016814485584>") + .getOrElse("") +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/WorldList.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/WorldList.scala new file mode 100644 index 0000000..56d7018 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/WorldList.scala @@ -0,0 +1,44 @@ +package com.tibiabot.presentation + +import com.tibiabot.domain.Vocations + +/** Formats a per-world map of already-rendered player lines into a flat list + * with a world header before each world's players. Worlds are ordered + * alphabetically, except the synthetic "Character does not exist" bucket which + * is pushed to the end. Pure; pinned by WorldListSpec. */ +object WorldList { + + /** Group player entries — each `(level, world, renderedLine)`, keyed by + * vocation — into a per-world list of lines. Within a world, players are + * ordered by vocation (druid, knight, paladin, sorcerer, monk, none) then by + * descending level; ties keep input order. Pure; the result feeds [[format]]. + * + * Extracted from listAlliesAndHuntedPlayers, which repeated the per-vocation + * group-and-sort six times then folded them together. */ + def byWorld(vocationEntries: Map[String, Seq[(Int, String, String)]]): Map[String, List[String]] = { + // Fold in reverse display order so each vocation prepends ahead of the + // previous, leaving druids first and "none" last within each world. + val foldOrder = Vocations.displayOrder.reverse + foldOrder.foldLeft(Map.empty[String, List[String]]) { (acc, voc) => + val perWorld = vocationEntries.getOrElse(voc, Seq.empty) + .groupBy(_._2) + .map { case (world, entries) => world -> entries.toList.sortBy(-_._1).map(_._3) } + perWorld.foldLeft(acc) { case (map, (world, lines)) => + map + (world -> (lines ++ map.getOrElse(world, List()))) + } + } + } + + def format(worlds: Map[String, List[String]]): List[String] = { + val sortedWorlds = worlds.toList.sortBy(_._1) + .sortWith((a, b) => { + if (a._1 == "Character does not exist") false + else if (b._1 == "Character does not exist") true + else a._1 < b._1 + }) + sortedWorlds.flatMap { + case (world, players) => + s":globe_with_meridians: **$world** :globe_with_meridians:" :: players + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/setup/ChannelService.scala b/tibia-bot/src/main/scala/com/tibiabot/setup/ChannelService.scala new file mode 100644 index 0000000..739e883 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/setup/ChannelService.scala @@ -0,0 +1,59 @@ +package com.tibiabot.setup + +import com.tibiabot.Config +import com.tibiabot.app.StreamSupervisor +import com.tibiabot.persistence.SchemaInitializer +import com.typesafe.scalalogging.StrictLogging +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.events.guild.{GuildJoinEvent, GuildLeaveEvent} + +/** Per-guild channel/role setup lifecycle, being extracted from BotApp + * incrementally. Currently holds the guild-join/leave handlers; the + * channel-create/repair/remove operations will move here as their BotApp + * dependencies are untangled. + * + * @param forgetGuild drops a guild's in-memory state (worldsData/discordsData) + * @param sharedConfigGuilds guilds whose database is shared with another bot, so it must NOT be dropped on leave + */ +final class ChannelService( + streamSupervisor: StreamSupervisor, + schemaInitializer: SchemaInitializer, + forgetGuild: String => Unit, + sharedConfigGuilds: Set[String] +) extends StrictLogging { + + /** Posts the welcome/help message when the bot joins a new guild. */ + def discordJoin(event: GuildJoinEvent): Unit = { + val guild = event.getGuild + val publicChannel = guild.getTextChannelById(guild.getDefaultChannel.getId) + if (publicChannel != null) { + if (publicChannel.canTalk() || !Config.prod) { + val embedBuilder = new EmbedBuilder() + embedBuilder.setAuthor("Violent Beams", "https://www.tibia.com/community/?subtopic=characters&name=Violent+Beams", "https://github.com/Leo32onGIT.png") + embedBuilder.setDescription(Config.helpText) + embedBuilder.setThumbnail(Config.webHookAvatar) + embedBuilder.setColor(14397256) // orange for bot auto command + try { + publicChannel.sendMessageEmbeds(embedBuilder.build()).queue() + } catch { + case ex: Throwable => logger.error(s"Failed to send 'New Discord Join' message for Guild ID: '${guild.getId}' Guild Name: '${guild.getName}'", ex) + } + } + } + } + + /** Cleans up after the bot is removed from a guild: forgets the guild's + * in-memory state, cancels its world streams, and drops its database — + * unless the guild's config is shared with another bot. */ + def discordLeave(event: GuildLeaveEvent): Unit = { + val guildId = event.getGuild.getId + forgetGuild(guildId) + streamSupervisor.removeGuild(guildId) + logger.info(guildId) + if (sharedConfigGuilds.contains(guildId)) { + logger.info("Config is shared between Pulsera Bot, will use as alpha environment will delete when guild wants it deleted") + } else { + schemaInitializer.dropGuild(guildId) + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala b/tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala index 48164d6..a902768 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/state/StreamState.scala @@ -1,10 +1,13 @@ package com.tibiabot.state -import com.tibiabot.domain.{PlayerCache, Players} +import com.tibiabot.domain.{PlayerCache, Players, Guilds, CustomSort, Discords, Worlds} + +import java.time.ZonedDateTime /** * The per-guild working state mutated by BOTH the per-world streams and command - * threads: activity tracking plus the hunted/allied player lists. + * threads: activity tracking, the hunted/allied player lists, plus the + * character-response freshness cache. * * Reads are lock-free on `@volatile` fields (so a running stream always sees the * latest committed map); every read-modify-write goes through the synchronized @@ -17,10 +20,24 @@ final class StreamState { @volatile private var _activity: Map[String, List[PlayerCache]] = Map.empty @volatile private var _huntedPlayers: Map[String, List[Players]] = Map.empty @volatile private var _alliedPlayers: Map[String, List[Players]] = Map.empty + @volatile private var _huntedGuilds: Map[String, List[Guilds]] = Map.empty + @volatile private var _alliedGuilds: Map[String, List[Guilds]] = Map.empty + @volatile private var _customSort: Map[String, List[CustomSort]] = Map.empty + @volatile private var _discords: Map[String, List[Discords]] = Map.empty + @volatile private var _worlds: Map[String, List[Worlds]] = Map.empty + @volatile private var _activityBlocker: Map[String, Boolean] = Map.empty + @volatile private var _characterCache: Map[String, ZonedDateTime] = Map.empty def activityData: Map[String, List[PlayerCache]] = _activity def huntedPlayersData: Map[String, List[Players]] = _huntedPlayers def alliedPlayersData: Map[String, List[Players]] = _alliedPlayers + def huntedGuildsData: Map[String, List[Guilds]] = _huntedGuilds + def alliedGuildsData: Map[String, List[Guilds]] = _alliedGuilds + def customSortData: Map[String, List[CustomSort]] = _customSort + def discordsData: Map[String, List[Discords]] = _discords + def worldsData: Map[String, List[Worlds]] = _worlds + def activityCommandBlocker: Map[String, Boolean] = _activityBlocker + def characterCache: Map[String, ZonedDateTime] = _characterCache def modifyActivityData(f: Map[String, List[PlayerCache]] => Map[String, List[PlayerCache]]): Unit = lock.synchronized { _activity = f(_activity) } @@ -28,4 +45,18 @@ final class StreamState { lock.synchronized { _huntedPlayers = f(_huntedPlayers) } def modifyAlliedPlayersData(f: Map[String, List[Players]] => Map[String, List[Players]]): Unit = lock.synchronized { _alliedPlayers = f(_alliedPlayers) } + def modifyHuntedGuildsData(f: Map[String, List[Guilds]] => Map[String, List[Guilds]]): Unit = + lock.synchronized { _huntedGuilds = f(_huntedGuilds) } + def modifyAlliedGuildsData(f: Map[String, List[Guilds]] => Map[String, List[Guilds]]): Unit = + lock.synchronized { _alliedGuilds = f(_alliedGuilds) } + def modifyCustomSortData(f: Map[String, List[CustomSort]] => Map[String, List[CustomSort]]): Unit = + lock.synchronized { _customSort = f(_customSort) } + def modifyDiscordsData(f: Map[String, List[Discords]] => Map[String, List[Discords]]): Unit = + lock.synchronized { _discords = f(_discords) } + def modifyWorldsData(f: Map[String, List[Worlds]] => Map[String, List[Worlds]]): Unit = + lock.synchronized { _worlds = f(_worlds) } + def modifyActivityCommandBlocker(f: Map[String, Boolean] => Map[String, Boolean]): Unit = + lock.synchronized { _activityBlocker = f(_activityBlocker) } + def modifyCharacterCache(f: Map[String, ZonedDateTime] => Map[String, ZonedDateTime]): Unit = + lock.synchronized { _characterCache = f(_characterCache) } } diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/JsonSupport.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/JsonSupport.scala index 7dcb28b..7810a73 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/JsonSupport.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/JsonSupport.scala @@ -64,30 +64,14 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol { implicit val boostableBossesFormat: RootJsonFormat[BoostableBosses] = jsonFormat2(BoostableBosses) implicit val boostedResponseFormat: RootJsonFormat[BoostedResponse] = jsonFormat2(BoostedResponse) - implicit val boostedCreatureFormat: RootJsonFormat[BoostedCreature] = jsonFormat4(BoostedCreature) - implicit val creatureListItemFormat: RootJsonFormat[CreatureListItem] = jsonFormat4(CreatureListItem) - implicit val creaturesFormat: RootJsonFormat[Creatures] = jsonFormat2(Creatures) - implicit val creaturesResponseFormat: RootJsonFormat[CreaturesResponse] = jsonFormat2(CreaturesResponse) - implicit val creatureListFormat: RootJsonFormat[CreatureList] = jsonFormat3(CreatureList) implicit val creatureDataFormat: RootJsonFormat[CreatureData] = jsonFormat2(CreatureData) implicit val creatureResponseFormat: RootJsonFormat[CreatureResponse] = jsonFormat2(CreatureResponse) - implicit val raceFormat: RootJsonFormat[Race] = jsonFormat20(Race) - implicit val raceResponseFormat: RootJsonFormat[RaceResponse] = jsonFormat2(RaceResponse) - implicit val highscorePageFormat: RootJsonFormat[HighscoresPage] = jsonFormat3(HighscoresPage) implicit val highscoreListFormat: RootJsonFormat[HighscoresList] = jsonFormat6(HighscoresList) implicit val highscoresFormat: RootJsonFormat[Highscores] = jsonFormat6(Highscores) implicit val highscoresResponseFormat: RootJsonFormat[HighscoresResponse] = jsonFormat2(HighscoresResponse) - - implicit val newsEntryFormat: RootJsonFormat[NewsEntry] = jsonFormat6(NewsEntry) - implicit val newsDataFormat: RootJsonFormat[NewsData] = jsonFormat1(NewsData) - implicit val newsResponseFormat: RootJsonFormat[NewsResponse] = jsonFormat2(NewsResponse) - - implicit val newsTickerEntryFormat: RootJsonFormat[NewsTickerEntry] = jsonFormat2(NewsTickerEntry) - implicit val newsTickerDataFormat: RootJsonFormat[NewsTickerData] = jsonFormat1(NewsTickerData) - implicit val newsTickerResponseFormat: RootJsonFormat[NewsTickerResponse] = jsonFormat2(NewsTickerResponse) } // This is needed because you can't just call json.convertTo[String] inside strFormat above because you get a stack overflow because it calls back on itself diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala index 34a64d7..cb2671b 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaApi.scala @@ -1,6 +1,6 @@ package com.tibiabot.tibiadata -import com.tibiabot.tibiadata.response.{BoostedResponse, CharacterResponse, CreatureResponse, CreaturesResponse, GuildResponse, HighscoresResponse, NewsResponse, NewsTickerResponse, WorldResponse, WorldsResponse} +import com.tibiabot.tibiadata.response.{BoostedResponse, CharacterResponse, CreatureResponse, GuildResponse, HighscoresResponse, WorldResponse, WorldsResponse} import scala.concurrent.Future @@ -10,7 +10,6 @@ import scala.concurrent.Future trait TibiaApi { def getWorld(world: String): Future[Either[String, WorldResponse]] def getWorlds(): Future[Either[String, WorldsResponse]] - def getCreatures(): Future[Either[String, CreaturesResponse]] def getBoostedBoss(): Future[Either[String, BoostedResponse]] def getBoostedCreature(): Future[Either[String, CreatureResponse]] def getHighscores(world: String, page: Int): Future[Either[String, HighscoresResponse]] @@ -20,6 +19,4 @@ trait TibiaApi { def getKillerFallback(name: String): Future[Either[String, CharacterResponse]] def getCharacterV2(input: (String, Int)): Future[Either[String, CharacterResponse]] def getCharacterWithInput(input: (String, String, String)): Future[(Either[String, CharacterResponse], String, String, String)] - def getLatestNews(): Future[Either[String, NewsResponse]] - def getNewsTicker(): Future[Either[String, NewsTickerResponse]] } diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala index 0991015..c1cb884 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala @@ -7,17 +7,14 @@ import akka.http.scaladsl.coding.Coders import akka.http.scaladsl.model.headers.HttpEncodings import akka.http.scaladsl.model.{HttpRequest, HttpResponse} import akka.http.scaladsl.unmarshalling.Unmarshal -import com.tibiabot.tibiadata.response.{CharacterResponse, WorldResponse, WorldsResponse, CreaturesResponse, GuildResponse, BoostedResponse, CreatureResponse, RaceResponse, HighscoresResponse, NewsResponse, NewsTickerResponse} +import com.tibiabot.tibiadata.response.{CharacterResponse, WorldResponse, WorldsResponse, GuildResponse, BoostedResponse, CreatureResponse, HighscoresResponse} import com.typesafe.scalalogging.StrictLogging import spray.json.JsonParser.ParsingException import java.net.URLEncoder -import scala.concurrent.duration._ -import akka.http.scaladsl.model.HttpEntity.Strict import scala.util.Random -import com.tibiabot.BotApp.characterCache +import com.tibiabot.BotApp.{characterCache, modifyCharacterCache} import scala.concurrent.{ExecutionContextExecutor, Future} import spray.json.DeserializationException -import akka.http.scaladsl.model.HttpResponse import akka.http.scaladsl.model.headers.{Date => DateHeader} import java.time.{ZonedDateTime, ZoneId} import java.time.format.DateTimeFormatter @@ -29,159 +26,108 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with private val characterUrl = "https://api.tibiadata.com/v4/character/" private val guildUrl = "https://api.tibiadata.com/v4/guild/" - def getWorld(world: String): Future[Either[String, WorldResponse]] = { - val encodedName = URLEncoder.encode(world, "UTF-8").replaceAll("\\+", "%20") - for { - response <- Http().singleRequest(HttpRequest(uri = s"https://api.tibiadata.com/v4/world/$encodedName")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[WorldResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get world: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse world: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } - } yield unmarshalled - } - - def getWorlds(): Future[Either[String, WorldsResponse]] = { - for { - response <- Http().singleRequest(HttpRequest(uri = s"https://api.tibiadata.com/v4/worlds")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[WorldsResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get worlds with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse worlds response" - logger.warn(errorMessage) - Left(errorMessage) - } - } yield unmarshalled + /** Shared recovery for an Unmarshal failure across every endpoint. On a + * non-JSON response (UnsupportedContentType) the spray-json unmarshaller + * rejects on the content-type check before reading the body, so the entity + * is unconsumed — drain it to free the akka-http pool connection. Parse + * failures already read the body, so they are not drained. Both log the + * friendly message plus the exception detail and yield Left; unmatched + * throwables propagate, exactly as the inline blocks did. */ + private def recoverUnmarshal[T](decoded: HttpResponse, contentTypeMessage: => String, parseMessage: => String): PartialFunction[Throwable, Either[String, T]] = { + case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => + decoded.discardEntityBytes() + val errorMessage = contentTypeMessage + logger.warn(s"$errorMessage: ${e.getMessage}") + Left(errorMessage) + case e @ (_: ParsingException | _: DeserializationException) => + val errorMessage = parseMessage + logger.warn(s"$errorMessage: ${e.getMessage}") + Left(errorMessage) } - def getCreatures(): Future[Either[String, CreaturesResponse]] = { + /** Issue a GET, decode the (possibly gzipped) response and unmarshal its JSON + * body to T, recovering non-JSON / parse failures into a logged Left (draining + * the entity). The request/decode/unmarshal/recover shape shared by the + * parameter-free GET endpoints. `contentTypeMessage` receives the response so + * it can include the status. */ + private def fetch[T](uri: String, contentTypeMessage: HttpResponse => String, parseMessage: => String) + (implicit um: akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller[T]): Future[Either[String, T]] = for { - response <- Http().singleRequest(HttpRequest(uri = s"https://api.tibiadata.com/v4/creatures")) + response <- Http().singleRequest(HttpRequest(uri = uri)) decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[CreaturesResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get creatures with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse creatures response" - logger.warn(errorMessage) - Left(errorMessage) - } + unmarshalled <- Unmarshal(decoded).to[T].map(Right(_)) + .recover(recoverUnmarshal(decoded, contentTypeMessage(response), parseMessage)) } yield unmarshalled - } - def getBoostedBoss(): Future[Either[String, BoostedResponse]] = { - for { - response <- Http().singleRequest(HttpRequest(uri = s"${Config.tibiadataApi}/v4/boostablebosses")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[BoostedResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get boosted boss with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse boosted boss" - logger.warn(e.getMessage) - Left(errorMessage) - } - } yield unmarshalled - } - - def getBoostedCreature(): Future[Either[String, CreatureResponse]] = { - for { - response <- Http().singleRequest(HttpRequest(uri = s"${Config.tibiadataApi}/v4/creatures")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[CreatureResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get boosted creature with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse boosted creature" - logger.warn(e.getMessage) - Left(errorMessage) - } - } yield unmarshalled + def getWorld(world: String): Future[Either[String, WorldResponse]] = { + val encodedName = URLEncoder.encode(world, "UTF-8").replaceAll("\\+", "%20") + fetch[WorldResponse]( + s"https://api.tibiadata.com/v4/world/$encodedName", + resp => s"Failed to get world: '${encodedName.replaceAll("%20", " ")}' with status: '${resp.status}'", + s"Failed to parse world: '${encodedName.replaceAll("%20", " ")}'") } - def getHighscores(world: String, page: Int): Future[Either[String, HighscoresResponse]] = { - val highscoresUri = s"${Config.tibiadataApi}/v4/highscores/${world}/experience/all/${page.toString}" - for { - response <- Http().singleRequest(HttpRequest(uri = highscoresUri)) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[HighscoresResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get highscores with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse highscores" - logger.warn(e.getMessage) - Left(errorMessage) - } - } yield unmarshalled - } + def getWorlds(): Future[Either[String, WorldsResponse]] = + fetch[WorldsResponse]( + s"https://api.tibiadata.com/v4/worlds", + resp => s"Failed to get worlds with status: '${resp.status}'", + s"Failed to parse worlds response") + + def getBoostedBoss(): Future[Either[String, BoostedResponse]] = + fetch[BoostedResponse]( + s"${Config.tibiadataApi}/v4/boostablebosses", + resp => s"Failed to get boosted boss with status: '${resp.status}'", + s"Failed to parse boosted boss") + + def getBoostedCreature(): Future[Either[String, CreatureResponse]] = + fetch[CreatureResponse]( + s"${Config.tibiadataApi}/v4/creatures", + resp => s"Failed to get boosted creature with status: '${resp.status}'", + s"Failed to parse boosted creature") + + def getHighscores(world: String, page: Int): Future[Either[String, HighscoresResponse]] = + fetch[HighscoresResponse]( + s"${Config.tibiadataApi}/v4/highscores/${world}/experience/all/${page.toString}", + resp => s"Failed to get highscores with status: '${resp.status}'", + s"Failed to parse highscores") def getGuild(guild: String): Future[Either[String, GuildResponse]] = { val encodedName = URLEncoder.encode(guild, "UTF-8").replaceAll("\\+", "%20") - for { - response <- Http().singleRequest(HttpRequest(uri = s"$guildUrl$encodedName")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[GuildResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get guild: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse guild: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } - } yield unmarshalled + fetch[GuildResponse]( + s"$guildUrl$encodedName", + resp => s"Failed to get guild: '${encodedName.replaceAll("%20", " ")}' with status: '${resp.status}'", + s"Failed to parse guild: '${encodedName.replaceAll("%20", " ")}'") } def getGuildWithInput(input: (String, String)): Future[(Either[String, GuildResponse], String, String)] = { val guild = input._1 val reason = input._2 val encodedName = URLEncoder.encode(guild, "UTF-8").replaceAll("\\+", "%20") - for { - response <- Http().singleRequest(HttpRequest(uri = s"$guildUrl$encodedName")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[GuildResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get guild: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse guild: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } - } yield (unmarshalled, guild, reason) + fetch[GuildResponse]( + s"$guildUrl$encodedName", + resp => s"Failed to get guild: '${encodedName.replaceAll("%20", " ")}' with status: '${resp.status}'", + s"Failed to parse guild: '${encodedName.replaceAll("%20", " ")}'") + .map(unmarshalled => (unmarshalled, guild, reason)) } - def getCharacter(name: String): Future[Either[String, CharacterResponse]] = { + /** Decode + unmarshal a character response, recovering failures to a logged + * Left (draining on the non-JSON path). Shared by the character endpoints. */ + private def unmarshalCharacter(response: HttpResponse, encodedName: String): Future[Either[String, CharacterResponse]] = { + val decoded = decodeResponse(response) + Unmarshal(decoded).to[CharacterResponse].map(Right(_)).recover(recoverUnmarshal( + decoded, + s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'", + s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'")) + } + + /** The Date-header-gated character cache shared by getCharacter and + * getCharacterV2: when the response carries a Date no newer than the cached + * timestamp for `name`, skip unmarshalling (drain + report a cache hit); + * otherwise record the timestamp and unmarshal. The request URL differs + * between callers (plain vs the level>=250 bypass), so it is built by the + * caller and passed in as `responseFuture`. */ + private def fetchCharacterCached(name: String, responseFuture: Future[HttpResponse]): Future[Either[String, CharacterResponse]] = { val encodedName = URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") - val responseFuture = Http().singleRequest(HttpRequest(uri = s"$characterUrl$encodedName")) responseFuture.flatMap { response => response.header[DateHeader] match { case Some(dateHeader) => @@ -189,34 +135,14 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with val responseDate = ZonedDateTime.parse(dateHeader.date.toString, formatter) characterCache.get(name) match { case Some(existingDate) if responseDate.isAfter(existingDate) => - characterCache += (name -> responseDate) - val decoded = decodeResponse(response) - Unmarshal(decoded).to[CharacterResponse].map(Right(_)).recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } + modifyCharacterCache(_ + (name -> responseDate)) + unmarshalCharacter(response, encodedName) case Some(_) => response.discardEntityBytes() Future.successful(Left("Hit cache")) case None => - characterCache += (name -> responseDate) - val decoded = decodeResponse(response) - Unmarshal(decoded).to[CharacterResponse].map(Right(_)).recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } + modifyCharacterCache(_ + (name -> responseDate)) + unmarshalCharacter(response, encodedName) } case None => response.discardEntityBytes() @@ -225,25 +151,18 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with } } + def getCharacter(name: String): Future[Either[String, CharacterResponse]] = { + val encodedName = URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") + fetchCharacterCached(name, Http().singleRequest(HttpRequest(uri = s"$characterUrl$encodedName"))) + } + def getKillerFallback(name: String): Future[Either[String, CharacterResponse]] = { val encodedName = URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") val responseFuture = Http().singleRequest(HttpRequest(uri = s"$characterUrl$encodedName")) responseFuture.flatMap { response => response.header[DateHeader] match { - case Some(dateHeader) => - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss").withZone(ZoneId.of("GMT")) - val responseDate = ZonedDateTime.parse(dateHeader.date.toString, formatter) - val decoded = decodeResponse(response) - Unmarshal(decoded).to[CharacterResponse].map(Right(_)).recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } + case Some(_) => + unmarshalCharacter(response, encodedName) case None => response.discardEntityBytes() Future.successful(Left("No Date header in response")) @@ -268,48 +187,7 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with } else { encodedName } - val responseFuture = Http().singleRequest(HttpRequest(uri = s"$apiUrl$bypassName")) - responseFuture.flatMap { response => - response.header[DateHeader] match { - case Some(dateHeader) => - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss").withZone(ZoneId.of("GMT")) - val responseDate = ZonedDateTime.parse(dateHeader.date.toString, formatter) - characterCache.get(name) match { - case Some(existingDate) if responseDate.isAfter(existingDate) => - characterCache += (name -> responseDate) - val decoded = decodeResponse(response) - Unmarshal(decoded).to[CharacterResponse].map(Right(_)).recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } - case Some(_) => - response.discardEntityBytes() - Future.successful(Left("Hit cache")) - case None => - characterCache += (name -> responseDate) - val decoded = decodeResponse(response) - Unmarshal(decoded).to[CharacterResponse].map(Right(_)).recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } - } - case None => - response.discardEntityBytes() - Future.successful(Left("No Date header in response")) - } - } + fetchCharacterCached(name, Http().singleRequest(HttpRequest(uri = s"$apiUrl$bypassName"))) } def getCharacterWithInput(input: (String, String, String)): Future[(Either[String, CharacterResponse], String, String, String)] = { @@ -317,57 +195,11 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with val reason = input._2 val reasonText = input._3 val encodedName = URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") - for { - response <- Http().singleRequest(HttpRequest(uri = s"$characterUrl${encodedName}")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[CharacterResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'" - logger.warn(errorMessage) - Left(errorMessage) - } - } yield (unmarshalled, name, reason, reasonText) - } - - def getLatestNews(): Future[Either[String, NewsResponse]] = { - for { - response <- Http().singleRequest(HttpRequest(uri = s"${Config.tibiadataApi}/v4/news/latest")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[NewsResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get latest news with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse latest news" - logger.warn(errorMessage) - Left(errorMessage) - } - } yield unmarshalled - } - - def getNewsTicker(): Future[Either[String, NewsTickerResponse]] = { - for { - response <- Http().singleRequest(HttpRequest(uri = s"${Config.tibiadataApi}/v4/news/newsticker")) - decoded = decodeResponse(response) - unmarshalled <- Unmarshal(decoded).to[NewsTickerResponse].map(Right(_)) - .recover { - case e: akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException => - val errorMessage = s"Failed to get news ticker with status: '${response.status}'" - logger.warn(errorMessage) - Left(errorMessage) - case e @ (_: ParsingException | _: DeserializationException) => - val errorMessage = s"Failed to parse news ticker" - logger.warn(errorMessage) - Left(errorMessage) - } - } yield unmarshalled + fetch[CharacterResponse]( + s"$characterUrl${encodedName}", + resp => s"Failed to get character: '${encodedName.replaceAll("%20", " ")}' with status: '${resp.status}'", + s"Failed to parse character: '${encodedName.replaceAll("%20", " ")}'") + .map(unmarshalled => (unmarshalled, name, reason, reasonText)) } private def decodeResponse(response: HttpResponse): HttpResponse = { diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/CreaturesResponse.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/CreaturesResponse.scala deleted file mode 100644 index 6060368..0000000 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/CreaturesResponse.scala +++ /dev/null @@ -1,22 +0,0 @@ -package com.tibiabot.tibiadata.response - -case class BoostedCreature( - name: String, - race: String, - image_url: String, - featured: Boolean -) - -case class CreatureListItem( - name: String, - race: String, - image_url: String, - featured: Boolean -) - -case class Creatures( - boosted: BoostedCreature, - creature_list: List[CreatureListItem] -) - -case class CreaturesResponse(creatures: Creatures, information: Information) \ No newline at end of file diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/NewsResponse.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/NewsResponse.scala deleted file mode 100644 index 6d2b3bd..0000000 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/NewsResponse.scala +++ /dev/null @@ -1,36 +0,0 @@ -package com.tibiabot.tibiadata.response - -import java.time.LocalDate - -case class NewsEntry( - category: String, - date: String, // Format: "YYYY-MM-DD" - id: Int, - news: String, - title: String, - url: String -) - -case class NewsData( - news: List[NewsEntry] -) - -case class NewsResponse( - information: Information, - news: NewsData -) - -// News ticker structures -case class NewsTickerEntry( - date: String, // Format: "YYYY-MM-DD" - message: String -) - -case class NewsTickerData( - newstickers: List[NewsTickerEntry] -) - -case class NewsTickerResponse( - information: Information, - newstickers: NewsTickerData -) \ No newline at end of file diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/RaceResponse.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/RaceResponse.scala deleted file mode 100644 index e7c670e..0000000 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/response/RaceResponse.scala +++ /dev/null @@ -1,32 +0,0 @@ -package com.tibiabot.tibiadata.response - -/** -case class CreatureList( - featured: Boolean, - image_url: String, - name: String -) -**/ -case class Race( - be_convinced: Boolean, - be_paralysed: Boolean, - be_summoned: Boolean, - behaviour: String, - convinced_mana: Double, - description: String, - experience_points: Double, - featured: Boolean, - healed: Option[List[String]], - hitpoints: Double, - image_url: String, - immune: Option[List[String]], - is_lootable: Boolean, - loot_list: List[String], - name: String, - race: String, - see_invisible: Boolean, - strong: Option[List[String]], - summoned_mana: Double, - weakness: Option[List[String]] -) -case class RaceResponse(creature: Option[Race], information: Information) diff --git a/tibia-bot/src/test/resources/tibiadata/worlds.json b/tibia-bot/src/test/resources/tibiadata/worlds.json new file mode 100644 index 0000000..f96e921 --- /dev/null +++ b/tibia-bot/src/test/resources/tibiadata/worlds.json @@ -0,0 +1 @@ +{"worlds":{"players_online":15473,"record_players":64028,"record_date":"2007-11-28T18:26:00Z","regular_worlds":[{"name":"Aethera","status":"online","players_online":23,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Antica","status":"online","players_online":661,"location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-08-29","game_world_type":"regular","tournament_world_type":""},{"name":"Astera","status":"online","players_online":281,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-12","game_world_type":"regular","tournament_world_type":""},{"name":"Belobra","status":"online","players_online":324,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-06-22","game_world_type":"regular","tournament_world_type":""},{"name":"Blumera","status":"online","players_online":149,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Bona","status":"online","players_online":350,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Bravoria","status":"online","players_online":50,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Calmera","status":"online","players_online":202,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-12","game_world_type":"regular","tournament_world_type":""},{"name":"Cantabra","status":"online","players_online":64,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Celebra","status":"online","players_online":206,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-10-29","game_world_type":"regular","tournament_world_type":""},{"name":"Celesta","status":"online","players_online":330,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-05","game_world_type":"regular","tournament_world_type":""},{"name":"Citra","status":"online","players_online":80,"location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Collabra","status":"online","players_online":172,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Descubra","status":"online","players_online":251,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Dia","status":"online","players_online":229,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Dracobra","status":"online","players_online":91,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Eclipta","status":"online","players_online":30,"location":"Europe","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Epoca","status":"online","players_online":40,"location":"Europe","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Escura","status":"online","players_online":7,"location":"Europe","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Etebra","status":"online","players_online":117,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Ferobra","status":"online","players_online":281,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-07-04","game_world_type":"regular","tournament_world_type":""},{"name":"Firmera","status":"online","players_online":33,"location":"North America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Floribra","status":"online","players_online":381,"location":"South America","pvp_type":"Optional PvP","premium_only":true,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Gentebra","status":"online","players_online":237,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-12-12","game_world_type":"regular","tournament_world_type":""},{"name":"Gladera","status":"online","players_online":124,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Gladibra","status":"online","players_online":26,"location":"South America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Harmonia","status":"online","players_online":262,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-05","game_world_type":"regular","tournament_world_type":""},{"name":"Havera","status":"online","players_online":202,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2021-12-06","game_world_type":"regular","tournament_world_type":""},{"name":"Honbra","status":"online","players_online":210,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Hostera","status":"online","players_online":31,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Idyllia","status":"online","players_online":28,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Ignibra","status":"online","players_online":20,"location":"South America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Ignitera","status":"online","players_online":30,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Inabra","status":"online","players_online":320,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-12-12","game_world_type":"regular","tournament_world_type":""},{"name":"Issobra","status":"online","players_online":106,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Jadebra","status":"online","players_online":188,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Junera","status":"online","players_online":148,"location":"North America","pvp_type":"Optional PvP","premium_only":true,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Kalanta","status":"online","players_online":140,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Kalibra","status":"online","players_online":207,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-12-12","game_world_type":"regular","tournament_world_type":""},{"name":"Kalimera","status":"online","players_online":32,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Kanda","status":"online","players_online":151,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Karmeya","status":"online","players_online":197,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Lobera","status":"online","players_online":263,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Luminera","status":"online","players_online":185,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-05","game_world_type":"regular","tournament_world_type":""},{"name":"Lutabra","status":"online","players_online":14,"location":"South America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-12-12","game_world_type":"regular","tournament_world_type":""},{"name":"Luzibra","status":"online","players_online":78,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Maligna","status":"online","players_online":206,"location":"Europe","pvp_type":"Open PvP","premium_only":true,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Menera","status":"online","players_online":123,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-05","game_world_type":"regular","tournament_world_type":""},{"name":"Monstera","status":"online","players_online":40,"location":"North America","pvp_type":"Retro Hardcore PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Monza","status":"online","players_online":243,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Mystera","status":"online","players_online":4,"location":"North America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Nefera","status":"online","players_online":292,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Nevia","status":"online","players_online":496,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Noctalia","status":"online","players_online":31,"location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Oceanis","status":"online","players_online":15,"location":"Oceania","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2024-04-10","game_world_type":"regular","tournament_world_type":""},{"name":"Ombra","status":"online","players_online":189,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Opulera","status":"online","players_online":83,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Ourobra","status":"online","players_online":180,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Pacera","status":"online","players_online":207,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-12","game_world_type":"regular","tournament_world_type":""},{"name":"Peloria","status":"online","players_online":309,"location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-10-19","game_world_type":"regular","tournament_world_type":""},{"name":"Penumbra","status":"online","players_online":6,"location":"South America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Premia","status":"online","players_online":129,"location":"Europe","pvp_type":"Open PvP","premium_only":true,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-05","game_world_type":"regular","tournament_world_type":""},{"name":"Quelibra","status":"online","players_online":276,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-12-12","game_world_type":"regular","tournament_world_type":""},{"name":"Quidera","status":"online","players_online":184,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Quintera","status":"online","players_online":156,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-10-19","game_world_type":"regular","tournament_world_type":""},{"name":"Rasteibra","status":"online","players_online":213,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Refugia","status":"online","players_online":278,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-12","game_world_type":"regular","tournament_world_type":""},{"name":"Retalia","status":"online","players_online":22,"location":"Europe","pvp_type":"Retro Hardcore PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Secura","status":"online","players_online":680,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-09-05","game_world_type":"regular","tournament_world_type":""},{"name":"Serdebra","status":"online","players_online":220,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-05-17","game_world_type":"regular","tournament_world_type":""},{"name":"Solidera","status":"online","players_online":229,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Sombra","status":"online","players_online":25,"location":"South America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Sonira","status":"online","players_online":50,"location":"Europe","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Stralis","status":"online","players_online":7,"location":"Oceania","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Talera","status":"online","players_online":263,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Tempestera","status":"online","players_online":22,"location":"North America","pvp_type":"Retro Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Terribra","status":"online","players_online":116,"location":"South America","pvp_type":"Retro Hardcore PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Thyria","status":"online","players_online":297,"location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2021-12-06","game_world_type":"regular","tournament_world_type":""},{"name":"Tornabra","status":"online","players_online":135,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Unebra","status":"online","players_online":159,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Ustebra","status":"online","players_online":449,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Venebra","status":"online","players_online":223,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Victoris","status":"online","players_online":12,"location":"Oceania","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Vunira","status":"online","players_online":292,"location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2017-10-19","game_world_type":"regular","tournament_world_type":""},{"name":"Wintera","status":"online","players_online":235,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2018-04-19","game_world_type":"regular","tournament_world_type":""},{"name":"Xybra","status":"online","players_online":41,"location":"South America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"blocked","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Xyla","status":"online","players_online":175,"location":"Europe","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Xymera","status":"online","players_online":77,"location":"North America","pvp_type":"Open PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Yonabra","status":"online","players_online":213,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"2020-05-27","game_world_type":"regular","tournament_world_type":""},{"name":"Yovera","status":"online","players_online":203,"location":"North America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Yubra","status":"online","players_online":89,"location":"South America","pvp_type":"Optional PvP","premium_only":false,"transfer_type":"regular","battleye_protected":true,"battleye_date":"release","game_world_type":"regular","tournament_world_type":""},{"name":"Zuna","status":"online","players_online":19,"location":"Europe","pvp_type":"Hardcore PvP","premium_only":false,"transfer_type":"locked","battleye_protected":true,"battleye_date":"2025-02-18","game_world_type":"experimental","tournament_world_type":""},{"name":"Zunera","status":"online","players_online":9,"location":"North America","pvp_type":"Hardcore PvP","premium_only":false,"transfer_type":"locked","battleye_protected":true,"battleye_date":"2025-02-18","game_world_type":"experimental","tournament_world_type":""}],"tournament_worlds":null},"information":{"api":{"version":4,"release":"4.8.0","commit":"e3963de0848d8ce9d368a9cd54b306ccfddd0d15"},"timestamp":"2026-05-30T18:34:47Z","tibia_urls":["https://www.tibia.com/community/?subtopic=worlds"],"status":{"http_code":200}}} \ No newline at end of file diff --git a/tibia-bot/src/test/scala/com/tibiabot/CachedListSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/CachedListSpec.scala new file mode 100644 index 0000000..d62a5e2 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/CachedListSpec.scala @@ -0,0 +1,69 @@ +package com.tibiabot + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.{Duration, ZonedDateTime} + +/** Pins the TTL/fallback behaviour of CachedList with an injected fetcher and a + * controllable clock — no network or ActorSystem involved. */ +class CachedListSpec extends AnyFunSuite with Matchers { + + private val t0 = ZonedDateTime.parse("2026-05-31T10:00:00Z") + private val ttl = Duration.ofHours(1) + + /** A clock whose "now" can be advanced between get() calls. */ + private class MovableClock(var current: ZonedDateTime) { def now(): ZonedDateTime = current } + + test("first get fetches; a second get within the TTL reuses the cached value") { + var calls = 0 + val clock = new MovableClock(t0) + val cache = new CachedList[String]( + fetch = () => { calls += 1; Right(List("Antica", "Bona")) }, + fallback = Nil, ttl = ttl, now = () => clock.now() + ) + + cache.get() shouldBe List("Antica", "Bona") + clock.current = t0.plusMinutes(59) + cache.get() shouldBe List("Antica", "Bona") + calls shouldBe 1 // not re-fetched within the hour + } + + test("after the TTL expires the next get re-fetches") { + var calls = 0 + val clock = new MovableClock(t0) + val cache = new CachedList[String]( + fetch = () => { calls += 1; Right(List(s"world-$calls")) }, + fallback = Nil, ttl = ttl, now = () => clock.now() + ) + + cache.get() shouldBe List("world-1") + clock.current = t0.plusHours(1).plusSeconds(1) + cache.get() shouldBe List("world-2") + calls shouldBe 2 + } + + test("a failed fetch falls back to the last good value") { + var fail = false + val clock = new MovableClock(t0) + val cache = new CachedList[String]( + fetch = () => if (fail) Left("api down") else Right(List("Antica")), + fallback = List("STATIC"), ttl = ttl, now = () => clock.now() + ) + + cache.get() shouldBe List("Antica") // primes the cache + clock.current = t0.plusHours(2) // force expiry so fetch runs again + fail = true + cache.get() shouldBe List("Antica") // keeps the last good value, not STATIC + } + + test("a failure with no prior value falls back to the provided default") { + val clock = new MovableClock(t0) + val cache = new CachedList[String]( + fetch = () => Left("api down"), + fallback = List("STATIC"), ttl = ttl, now = () => clock.now() + ) + + cache.get() shouldBe List("STATIC") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala index 1269537..a33d70e 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/RealDataBehaviorSpec.scala @@ -3,7 +3,7 @@ package com.tibiabot import com.tibiabot.presentation.{BoostedEmbeds, DeathEmbeds} import com.tibiabot.tibiadata.JsonSupport import com.tibiabot.tibiadata.response._ -import com.tibiabot.tracking.{LevelRecord, LevelTracker} +import com.tibiabot.tracking.{LevelRecord, LevelTracker, OnlineTracker} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import spray.json._ @@ -57,13 +57,132 @@ class RealDataBehaviorSpec extends AnyFunSuite with Matchers with JsonSupport { embed.getDescription should not be empty } + test("real death killers from the API drive the production killer-text logic") { + val deaths = fixture("character.json").parseJson.convertTo[CharacterResponse].character.deaths.getOrElse(Nil) + deaths should not be empty + val killerNames = deaths.flatMap(_.killers.map(_.name)).distinct + killerNames should not be empty + + // these fixture deaths are by plain creatures, not " of " + killerNames.foreach(n => domain.Killers.parseSummon(n) shouldBe None) + + // each renders as a single-entry phrase with the right indefinite article, + // exactly as the death block composes "by ." + killerNames.foreach { n => + val phrase = domain.Killers.joinNatural(Seq(s"${domain.Killers.sourceArticle(n)}$n")) + phrase should (startWith("a ") or startWith("an ")) + phrase should endWith(n) + } + + // a multi-killer death would join with commas + "and" + domain.Killers.joinNatural(killerNames.take(2)) should include(killerNames.head) + } + + test("every real online player's vocation maps to a production emoji") { + val online = fixture("world_antica.json").parseJson.convertTo[WorldResponse].world.online_players.getOrElse(Nil) + online should not be empty + val vocations = online.map(_.vocation).distinct + vocations should contain("Elite Knight") + vocations should contain("Exalted Monk") // monk is included in TibiaBot's vocEmoji variant + + // no real online vocation renders as a blank in the online list + vocations.foreach { v => + withClue(s"vocation '$v' produced no emoji: ") { + presentation.Emojis.vocEmoji(v) should not be empty + } + } + } + + test("every real guild-member vocation maps to a production emoji (incl. monk)") { + val members = fixture("guild.json").parseJson.convertTo[GuildResponse].guild.members.getOrElse(Nil) + members should not be empty + val vocations = members.map(_.vocation).distinct + vocations should contain("Exalted Monk") // monk now resolves on every path + + vocations.foreach { v => + withClue(s"vocEmoji('$v'): ") { presentation.Emojis.vocEmoji(v) should not be empty } + } + } + test("the real boosted boss renders through the production boosted embed") { val boss = fixture("boostablebosses.json").parseJson.convertTo[BoostedResponse].boostable_bosses.boosted.name val text = s"The boosted boss today is: **$boss**" - val embed = BoostedEmbeds.create(boss, ":crossed_swords:", "https://wiki", "https://x/t.gif", text) + val embed = BoostedEmbeds.create("https://x/t.gif", text) embed.getDescription should include(boss) } + test("the real boosted creature renders through the production boosted embed") { + // the creature feed decodes via a different path (CreatureResponse.creatures) + // than the boss feed (BoostedResponse.boostable_bosses), so cover it too. + val creature = fixture("creatures.json").parseJson.convertTo[CreatureResponse].creatures.boosted.name + creature should not be empty + val text = s"The boosted creature today is: **$creature**" + val embed = BoostedEmbeds.create("https://x/t.gif", text) + embed.getDescription should include(creature) + } + + test("every real guild member flows through the production list ordering (WorldList.byWorld)") { + val members = fixture("guild.json").parseJson.convertTo[GuildResponse].guild.members.getOrElse(Nil) + members should not be empty + + // Build the per-vocation (level, world, line) entries the way the list command does. + val entriesByVoc: Map[String, Seq[(Int, String, String)]] = + members.groupBy(_.vocation.toLowerCase.split(' ').last) + .view.mapValues(_.map(m => + (m.level.toInt, "Antica", s"${presentation.Emojis.vocEmoji(m.vocation)} ${m.name}")).toSeq) + .toMap + + val grouped = presentation.WorldList.byWorld(entriesByVoc) + + // single world, and no member dropped (every real vocation maps to a known bucket) + grouped.keySet shouldBe Set("Antica") + grouped("Antica") should have size members.size + + // vocation priority holds on real data: the first druid precedes the first knight + val vocations = members.map(_.vocation.toLowerCase.split(' ').last).toSet + if (vocations("druid") && vocations("knight")) { + val lines = grouped("Antica") + lines.indexWhere(_.startsWith(":snowflake:")) should be < lines.indexWhere(_.startsWith(":shield:")) + } + } + + test("OnlineTracker ingests and re-cycles a real world's online players correctly") { + val online = fixture("world_antica.json").parseJson.convertTo[WorldResponse].world.online_players.getOrElse(Nil) + online should not be empty + val rows = online.map(p => (p.name, p.level.toInt, p.vocation)) + val t0 = ZonedDateTime.parse("2026-01-01T00:00:00Z") + + val tracker = new OnlineTracker + // cycle 1: every real player is tracked (online names are unique); all new -> zero duration + tracker.updateFromOnline(rows, t0) + tracker.size shouldBe online.size + tracker.snapshot.forall(_.duration == 0L) shouldBe true + + // a guild + flag set mid-cycle must carry over to the next cycle + val sample = online.head.name + tracker.setGuild(sample, "Bona Fide") + tracker.setFlag(sample, ":star:") + + // cycle 2, 10 minutes later: only the first half stay online; the rest log off + val stayed = rows.take(rows.size / 2) + tracker.updateFromOnline(stayed, t0.plusMinutes(10)) + tracker.size shouldBe stayed.size // logged-off players dropped, no leak + val carried = tracker.find(sample).getOrElse(fail("a still-online player was dropped")) + carried.guildName shouldBe "Bona Fide" // carried over across the cycle + carried.flag shouldBe ":star:" + carried.duration shouldBe 600L // 10 minutes accumulated + } + + test("a real character's last_login parses through RecentLogin.stamp to the right epoch") { + // Guards against the real API timestamp format silently failing RecentLogin's + // ISO parse (which would render an empty stamp in the /allies|/hunted list). + val lastLogin = character().last_login.getOrElse(fail("fixture character has no last_login")) + val loginInstant = java.time.Instant.parse(lastLogin) + // anchor 'now' one hour after the real login so it falls inside the 24h window + val stamp = presentation.RecentLogin.stamp(lastLogin, loginInstant.plusSeconds(3600)) + stamp should include(s"") + } + test("sanity: the character fixture exposes the fields the bot reads") { val ch = character() ch.name should not be empty diff --git a/tibia-bot/src/test/scala/com/tibiabot/commands/SlashRoutingSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/commands/SlashRoutingSpec.scala new file mode 100644 index 0000000..f220e11 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/commands/SlashRoutingSpec.scala @@ -0,0 +1,29 @@ +package com.tibiabot.commands + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Pins the slash dispatch table against the registered command schemas, so a + * command can never be registered with Discord without a handler (which would + * silently no-op when invoked) and vice versa. */ +class SlashRoutingSpec extends AnyFunSuite with Matchers { + + private val registered: Set[String] = CommandSchemas.adminCommands.map(_.getName).toSet + + test("every registered slash command has a dispatch handler") { + val missing = registered.diff(SlashRouting.handlers.keySet) + withClue(s"registered commands with no handler: $missing") { + missing shouldBe empty + } + } + + test("the only routable command not registered with Discord is the WIP leaderboards") { + // leaderboards is intentionally defined-but-unregistered (see CommandSchemasSpec); + // any OTHER extra handler would be a dead route to flag. + SlashRouting.handlers.keySet.diff(registered) shouldBe Set("leaderboards") + } + + test("no duplicate handler names") { + SlashRouting.handlers.keySet should have size SlashRouting.handlers.size + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/BoostedNameSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/BoostedNameSpec.scala new file mode 100644 index 0000000..e31f0b4 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/BoostedNameSpec.scala @@ -0,0 +1,26 @@ +package com.tibiabot.domain + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class BoostedNameSpec extends AnyFunSuite with Matchers { + + test("trims surrounding space and lowercases") { + BoostedName.sanitize(" Grand Master Oberon ") shouldBe "grand master oberon" + } + + test("keeps apostrophes and hyphens so real boss names survive") { + BoostedName.sanitize("Yselda's") shouldBe "yselda's" + BoostedName.sanitize("Mega-Magmaoid") shouldBe "mega-magmaoid" + } + + test("strips digits and other punctuation") { + BoostedName.sanitize("Oberon123!") shouldBe "oberon" + BoostedName.sanitize("a.b,c") shouldBe "abc" + } + + test("input that is entirely stripped (or empty) yields the empty string") { + BoostedName.sanitize("") shouldBe "" + BoostedName.sanitize("12345") shouldBe "" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/BossAliasesSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/BossAliasesSpec.scala new file mode 100644 index 0000000..4e527df --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/BossAliasesSpec.scala @@ -0,0 +1,34 @@ +package com.tibiabot.domain + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class BossAliasesSpec extends AnyFunSuite with Matchers { + + test("a known shorthand resolves to the canonical boss name") { + BossAliases.canonical("oberon") shouldBe "grand master oberon" + BossAliases.canonical("zyrtarch") shouldBe "soul of dragonking zyrtarch" + BossAliases.canonical("lib final") shouldBe "the scourge of oblivion" + BossAliases.canonical("undead seal") shouldBe "ragiaz" + } + + test("an unknown name (including a canonical name) passes through unchanged") { + // canonical boss names are the alias VALUES, not keys, so they pass through + BossAliases.canonical("grand master oberon") shouldBe "grand master oberon" + BossAliases.canonical("ferumbras") shouldBe "ferumbras" + BossAliases.canonical("") shouldBe "" + } + + test("several aliases collapse onto the same canonical name") { + Seq("despor", "dragon hoard", "vengar", "dragon bosses").foreach { alias => + BossAliases.canonical(alias) shouldBe "dragon pack" + } + } + + test("resolution is single-step — no canonical value is itself an alias key") { + // guards against needing chained lookups: canonical() does one getOrElse, so a + // value that were also a key would resolve inconsistently. + Seq("grand master oberon", "dragon pack", "ragiaz", "the scourge of oblivion") + .foreach(v => BossAliases.canonical(v) shouldBe v) + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/KillersSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/KillersSpec.scala new file mode 100644 index 0000000..7da9abb --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/KillersSpec.scala @@ -0,0 +1,54 @@ +package com.tibiabot.domain + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Pins the killer-name interpretation used to build death notifications. + * Names are taken verbatim from real Tibia death payloads. */ +class KillersSpec extends AnyFunSuite with Matchers { + + test("parseSummon: a lowercase ' of ' is a summon") { + Killers.parseSummon("fire elemental of Violent Beams") shouldBe Some(("fire elemental", "Violent Beams")) + Killers.parseSummon("a war golem of Xyz") shouldBe Some(("a war golem", "Xyz")) + } + + test("parseSummon: a player whose NAME contains ' of ' is not a summon (leading word is capitalised)") { + Killers.parseSummon("Knight of Flame") shouldBe None + Killers.parseSummon("Lord of the Elements") shouldBe None + } + + test("parseSummon: a plain creature or plain player name is not a summon") { + Killers.parseSummon("a dragon lord") shouldBe None + Killers.parseSummon("Bubble") shouldBe None + } + + test("parseSummon: only the first ' of ' splits, so the summoner name is kept whole") { + Killers.parseSummon("energy elemental of Sir of Camelot") shouldBe Some(("energy elemental", "Sir of Camelot")) + } + + test("article: 'an' before a vowel, 'a' otherwise") { + Killers.article("energy elemental") shouldBe "an" + Killers.article("orshabaal") shouldBe "an" + Killers.article("dragon lord") shouldBe "a" + Killers.article("fire elemental") shouldBe "a" + } + + test("sourceArticle: substance-like sources take no article") { + Killers.sourceArticle("energy") shouldBe "" + Killers.sourceArticle("fire") shouldBe "" + Killers.sourceArticle("a trap") shouldBe "" + Killers.sourceArticle("life drain") shouldBe "" + } + + test("sourceArticle: real creatures keep their article and a trailing space") { + Killers.sourceArticle("dragon lord") shouldBe "a " + Killers.sourceArticle("orc berserker") shouldBe "an " + } + + test("joinNatural: no killers, one, two, and many") { + Killers.joinNatural(Nil) shouldBe "" + Killers.joinNatural(Seq("a dragon")) shouldBe "a dragon" + Killers.joinNatural(Seq("a dragon", "a dragon lord")) shouldBe "a dragon and a dragon lord" + Killers.joinNatural(Seq("a", "b", "c")) shouldBe "a, b and c" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/VocationsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/VocationsSpec.scala new file mode 100644 index 0000000..80b9444 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/VocationsSpec.scala @@ -0,0 +1,19 @@ +package com.tibiabot.domain + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class VocationsSpec extends AnyFunSuite with Matchers { + + test("display order is druids-first, none-last, with no duplicates") { + Vocations.displayOrder shouldBe List("druid", "knight", "paladin", "sorcerer", "monk", "none") + Vocations.displayOrder.distinct shouldBe Vocations.displayOrder + Vocations.displayOrder.last shouldBe "none" + } + + test("reverse equals the order WorldList folds in (regression pin)") { + // WorldList.byWorld folds in reverse so druids end up first; this pins the + // exact list that was previously hard-coded there. + Vocations.displayOrder.reverse shouldBe List("none", "monk", "sorcerer", "paladin", "knight", "druid") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/WorldNameSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/WorldNameSpec.scala new file mode 100644 index 0000000..5c2564a --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/WorldNameSpec.scala @@ -0,0 +1,22 @@ +package com.tibiabot.domain + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class WorldNameSpec extends AnyFunSuite with Matchers { + + test("formal normalises any casing of a single-word world to Titlecase") { + WorldName.formal("antica") shouldBe "Antica" + WorldName.formal("ANTICA") shouldBe "Antica" + WorldName.formal("Antica") shouldBe "Antica" + WorldName.formal("aNtIcA") shouldBe "Antica" + } + + test("formal is idempotent") { + WorldName.formal(WorldName.formal("belobra")) shouldBe "Belobra" + } + + test("formal handles an empty string") { + WorldName.formal("") shouldBe "" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala index 5465761..90053c5 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/time/DreamScarCycleSpec.scala @@ -30,4 +30,16 @@ class DreamScarCycleSpec extends AnyFunSuite with Matchers { DreamScarCycle.indexOfBoss("Plagueroot") shouldBe 0 DreamScarCycle.indexOfBoss("Izcandar the Banished") shouldBe 4 } + + test("isDreamCourtBoss recognises every cycle boss, case-insensitively") { + DreamScarCycle.bossCycle.foreach { boss => + DreamScarCycle.isDreamCourtBoss(boss) shouldBe true + DreamScarCycle.isDreamCourtBoss(boss.toLowerCase) shouldBe true + } + } + + test("isDreamCourtBoss is false for non-cycle names") { + DreamScarCycle.isDreamCourtBoss("Ferumbras") shouldBe false + DreamScarCycle.isDreamCourtBoss("") shouldBe false + } } diff --git a/tibia-bot/src/test/scala/com/tibiabot/domain/time/SatchelCooldownSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/domain/time/SatchelCooldownSpec.scala new file mode 100644 index 0000000..e86a026 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/domain/time/SatchelCooldownSpec.scala @@ -0,0 +1,23 @@ +package com.tibiabot.domain.time + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime + +class SatchelCooldownSpec extends AnyFunSuite with Matchers { + + test("the satchel cooldown is 30 days") { + SatchelCooldown.durationDays shouldBe 30L + } + + test("expiresAtEpoch is the epoch-second 30 days after the start") { + val start = ZonedDateTime.parse("2026-01-01T00:00:00Z") + SatchelCooldown.expiresAtEpoch(start) shouldBe start.plusDays(30).toEpochSecond.toString + } + + test("expiry is exactly 30 days of seconds after the start") { + val start = ZonedDateTime.parse("2026-05-31T12:00:00Z") + SatchelCooldown.expiresAtEpoch(start).toLong - start.toEpochSecond shouldBe 30L * 24 * 60 * 60 + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala index 4ea7b0b..30b083d 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/BoostedEmbedsSpec.scala @@ -6,9 +6,7 @@ import org.scalatest.matchers.should.Matchers class BoostedEmbedsSpec extends AnyFunSuite with Matchers { test("builds the boosted embed with thumbnail, fixed colour and description; no title") { - val e = BoostedEmbeds.create( - "Boosted Boss", ":boss:", "https://www.tibia.com/library", - "https://x/thumb.gif", "The boosted boss today is X") + val e = BoostedEmbeds.create("https://x/thumb.gif", "The boosted boss today is X") e.getDescription shouldBe "The boosted boss today is X" e.getThumbnail.getUrl shouldBe "https://x/thumb.gif" (e.getColor.getRGB & 0xFFFFFF) shouldBe 3092790 diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/BossEmojiSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/BossEmojiSpec.scala new file mode 100644 index 0000000..3963596 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/BossEmojiSpec.scala @@ -0,0 +1,38 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Pins the boss-tier emoji matcher. Config-free: categories are injected, so + * this verifies the lookup semantics (first match wins, case-insensitive + * input, trailing space, empty default) without loading Config. */ +class BossEmojiSpec extends AnyFunSuite with Matchers { + + // lists are stored lower-cased, mirroring Config + private val categories: Seq[(Seq[String], String)] = Seq( + List("orshabaal", "ghazbaran") -> ":nemesis:", + List("yeti", "midnight panther") -> ":archfoe:", + List("crustacea gigantica") -> ":bane:" + ) + + test("returns the matching category's emoji with a trailing space") { + BossEmoji.categoryEmoji("Orshabaal", categories) shouldBe ":nemesis: " + BossEmoji.categoryEmoji("midnight panther", categories) shouldBe ":archfoe: " + } + + test("matching is case-insensitive on the input name") { + BossEmoji.categoryEmoji("GHAZBARAN", categories) shouldBe ":nemesis: " + } + + test("a name in no category yields the empty string (no icon)") { + BossEmoji.categoryEmoji("a dragon", categories) shouldBe "" + } + + test("the first matching category wins, in declared order") { + val overlapping: Seq[(Seq[String], String)] = Seq( + List("rat") -> ":first:", + List("rat") -> ":second:" + ) + BossEmoji.categoryEmoji("rat", overlapping) shouldBe ":first: " + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEffectSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEffectSpec.scala new file mode 100644 index 0000000..2d14feb --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEffectSpec.scala @@ -0,0 +1,58 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class DeathEffectSpec extends AnyFunSuite with Matchers { + + test("environmental damage-type killers resolve to an effect animation") { + DeathEffect.thumbnail("drowning") shouldBe defined + DeathEffect.thumbnail("death") shouldBe defined + DeathEffect.thumbnail("ice") shouldBe defined + DeathEffect.thumbnail("life drain") shouldBe defined + } + + test("each mapped damage type returns the matching resource gif") { + DeathEffect.thumbnail("death").get should endWith ("Death_Effect.gif") + DeathEffect.thumbnail("ice").get should endWith ("Ice_Explosion_Effect.gif") + DeathEffect.thumbnail("drowning").get should endWith ("Reaper_Effect.gif") + DeathEffect.thumbnail("life drain").get should endWith ("Red_Sparkles_Effect.gif") + } + + test("matching is case-insensitive on the killer name") { + DeathEffect.thumbnail("Drowning") shouldBe DeathEffect.thumbnail("drowning") + DeathEffect.thumbnail("LIFE DRAIN") shouldBe DeathEffect.thumbnail("life drain") + } + + test("real creature killers from the fixture fall back to the creature image (None)") { + // mammoth / wyrm are the actual killer names in test/resources/tibiadata/character.json + DeathEffect.thumbnail("mammoth") shouldBe None + DeathEffect.thumbnail("wyrm") shouldBe None + } + + test("'pvp' is not a killer-name lookup — player kills are decided at the death site") { + // "pvp" must NOT resolve via thumbnail(); it is a classification, never a killer name. + DeathEffect.thumbnail("pvp") shouldBe None + DeathEffect.pvp should endWith ("Phantasmal_Ooze.gif") + } + + test("suicide animation constant is exposed for the empty-killer death path") { + DeathEffect.suicide should endWith ("Ghost_Smoke_Effect.gif") + } + + test("every mapped damage type is a real substance death source (no never-matching keys)") { + // Killers.substanceSources is the author's canonical set of environmental + // killer names; a DeathEffect key outside it could never match a real death. + DeathEffect.mappedDamageTypes should not be empty + DeathEffect.mappedDamageTypes.subsetOf(com.tibiabot.domain.Killers.substanceSources) shouldBe true + } + + test("all effect resources share the resource base url") { + val base = "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/" + DeathEffect.pvp should startWith (base) + DeathEffect.suicide should startWith (base) + Seq("death", "ice", "drowning", "life drain").foreach { k => + DeathEffect.thumbnail(k).get should startWith (base) + } + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala index 059760b..71c775b 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/DeathEmbedsSpec.scala @@ -18,4 +18,27 @@ class DeathEmbedsSpec extends AnyFunSuite with Matchers { e.getUrl shouldBe "https://www.tibia.com/community/?name=Some+Monk" e.getTitle should startWith(":fist::skin-tone-3:") } + + // --- shouldShow: allegiance colour -> per-category visibility --- + + test("shouldShow gates each neutral colour on showNeutralDeaths") { + for (c <- Seq(3092790, 14869218, 4540237, 14397256)) { + DeathEmbeds.shouldShow(c, "true", "true", "true") shouldBe true + DeathEmbeds.shouldShow(c, "false", "true", "true") shouldBe false + } + } + + test("shouldShow gates the enemy colour only on showEnemiesDeaths") { + DeathEmbeds.shouldShow(36941, "true", "true", "false") shouldBe false + DeathEmbeds.shouldShow(36941, "false", "false", "true") shouldBe true + } + + test("shouldShow gates the ally colour only on showAlliesDeaths") { + DeathEmbeds.shouldShow(13773097, "true", "false", "true") shouldBe false + DeathEmbeds.shouldShow(13773097, "false", "true", "false") shouldBe true + } + + test("shouldShow always shows colours outside the three allegiance groups (e.g. purple notable)") { + DeathEmbeds.shouldShow(11563775, "false", "false", "false") shouldBe true + } } diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedTextSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedTextSpec.scala new file mode 100644 index 0000000..190a646 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedTextSpec.scala @@ -0,0 +1,35 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class EmbedTextSpec extends AnyFunSuite with Matchers { + + private def longBody = (1 to 500).map(i => s"line-$i-xxxxxxxxxx").mkString("\n") + + test("text that fits is returned unchanged when there is no command") { + EmbedText.fit("a\nb\nc") shouldBe "a\nb\nc" + } + + test("text that fits appends the command line after a blank line") { + EmbedText.fit("list", "done") shouldBe "list\n\ndone" + } + + test("overflowing text is cut at a line boundary and gets an overflow marker") { + val body = longBody + body.length should be > 4096 + val out = EmbedText.fit(body) + out.length should be < body.length + out should endWith ("cannot display any more results`*") + out should not include "line-500" // tail content past the cut is dropped + // the kept portion is a genuine prefix of the original (cut on a newline) + val kept = out.substring(0, out.indexOf("\n\n*`...")) + body should startWith (kept) + } + + test("overflowing text keeps the command line after the marker") { + val out = EmbedText.fit(longBody, "added Oberon") + out should include ("cannot display any more results") + out should endWith ("added Oberon") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedsSpec.scala new file mode 100644 index 0000000..1d5ce5b --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmbedsSpec.scala @@ -0,0 +1,19 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class EmbedsSpec extends AnyFunSuite with Matchers { + + test("response builds a brand-coloured embed with only the description") { + val e = Embeds.response("hello world") + e.getDescription shouldBe "hello world" + (e.getColor.getRGB & 0xFFFFFF) shouldBe Embeds.BrandColor + e.getThumbnail shouldBe null + e.getTitle shouldBe null + } + + test("BrandColor is the bot's standard embed colour") { + Embeds.BrandColor shouldBe 3092790 + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala index d6cbb15..3423f0b 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/EmojisSpec.scala @@ -3,8 +3,7 @@ package com.tibiabot.presentation import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers -/** Characterization of the vocation->emoji mapping, including the deliberate - * divergence between the two call sites. */ +/** Characterization of the unified vocation->emoji mapping. */ class EmojisSpec extends AnyFunSuite with Matchers { test("vocEmoji matches the promoted vocation by its last word") { @@ -17,13 +16,17 @@ class EmojisSpec extends AnyFunSuite with Matchers { Emojis.vocEmoji("") shouldBe "" } - test("vocEmojiWithoutMonk matches BotApp's original behaviour") { - Emojis.vocEmojiWithoutMonk("Elite Knight") shouldBe ":shield:" - Emojis.vocEmojiWithoutMonk("None") shouldBe ":hatching_chick:" + test("monk now resolves everywhere (the BotApp-without-monk variant is gone)") { + Emojis.vocEmoji("Monk") shouldBe ":fist::skin-tone-3:" } - test("the divergence is preserved: monk maps to an emoji in one path, '' in the other") { - Emojis.vocEmoji("Monk") shouldBe ":fist::skin-tone-3:" - Emojis.vocEmojiWithoutMonk("Monk") shouldBe "" // BotApp predates monks + test("every grouped vocation has an emoji (guards against the monk-blank desync)") { + // The monk bug was a display-order vocation with no emoji. Pin that every + // vocation we group/sort by (domain.Vocations.displayOrder) renders something. + com.tibiabot.domain.Vocations.displayOrder.foreach { voc => + withClue(s"vocation '$voc' must map to a non-empty emoji: ") { + Emojis.vocEmoji(voc) should not be empty + } + } } } diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/GuildActivitySpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/GuildActivitySpec.scala new file mode 100644 index 0000000..5c871bb --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/GuildActivitySpec.scala @@ -0,0 +1,27 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GuildActivitySpec extends AnyFunSuite with Matchers { + + test("activityColor: hunted is red, allied is green, otherwise yellow") { + GuildActivity.activityColor(huntedGuild = true, alliedGuild = false) shouldBe 13773097 + GuildActivity.activityColor(huntedGuild = false, alliedGuild = true) shouldBe 36941 + GuildActivity.activityColor(huntedGuild = false, alliedGuild = false) shouldBe 14397256 + } + + test("activityColor prefers hunted when both flags are set") { + GuildActivity.activityColor(huntedGuild = true, alliedGuild = true) shouldBe 13773097 + } + + test("guildType: hunted / allied / neutral label") { + GuildActivity.guildType(huntedGuild = true, alliedGuild = false) shouldBe "hunted" + GuildActivity.guildType(huntedGuild = false, alliedGuild = true) shouldBe "allied" + GuildActivity.guildType(huntedGuild = false, alliedGuild = false) shouldBe "neutral" + } + + test("guildType prefers hunted when both flags are set") { + GuildActivity.guildType(huntedGuild = true, alliedGuild = true) shouldBe "hunted" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/GuildIconsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/GuildIconsSpec.scala new file mode 100644 index 0000000..c282eae --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/GuildIconsSpec.scala @@ -0,0 +1,76 @@ +package com.tibiabot.presentation + +import com.tibiabot.presentation.GuildIcons.Relation._ +import com.tibiabot.presentation.GuildIcons.{ListRelation, classifyList} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Pins the priority ordering of the guild-relation classifier extracted from + * the two identical `guildIcon` matches in TibiaBot. (Config-free, so no real + * API payload applies here — this is the decision logic, not a decoder.) */ +class GuildIconsSpec extends AnyFunSuite with Matchers { + + private def classify(guild: String, ag: Boolean, hg: Boolean, ap: Boolean, hp: Boolean) = + GuildIcons.classify(guild, ag, hg, ap, hp) + + test("an allied guild outranks every other signal") { + // even if the same character is also flagged hunted at every level + classify("Some Guild", ag = true, hg = true, ap = true, hp = true) shouldBe AllyGuild + classify("", ag = true, hg = false, ap = false, hp = false) shouldBe AllyGuild + } + + test("a hunted guild outranks player-level flags but not an allied guild") { + classify("Some Guild", ag = false, hg = true, ap = true, hp = true) shouldBe HuntedGuild + classify("Some Guild", ag = true, hg = true, ap = false, hp = false) shouldBe AllyGuild + } + + test("allied player: no-guild and neutral-guild are distinguished") { + classify("", ag = false, hg = false, ap = true, hp = false) shouldBe AllyPlayerNoGuild + classify("Neutral Guild", ag = false, hg = false, ap = true, hp = false) shouldBe AllyPlayerNeutralGuild + } + + test("allied player outranks hunted player at the same tier") { + classify("", ag = false, hg = false, ap = true, hp = true) shouldBe AllyPlayerNoGuild + classify("Neutral Guild", ag = false, hg = false, ap = true, hp = true) shouldBe AllyPlayerNeutralGuild + } + + test("hunted player: no-guild and neutral-guild are distinguished") { + classify("", ag = false, hg = false, ap = false, hp = true) shouldBe HuntedPlayerNoGuild + classify("Neutral Guild", ag = false, hg = false, ap = false, hp = true) shouldBe HuntedPlayerNeutralGuild + } + + test("unaffiliated characters: no-guild vs neutral-guild") { + classify("", ag = false, hg = false, ap = false, hp = false) shouldBe NeutralNoGuild + classify("Neutral Guild", ag = false, hg = false, ap = false, hp = false) shouldBe NeutralGuild + } + + // --- list variant (/allies list, /hunted list): arg-aware classification --- + + test("allies list: guild status determines the icon, crossing with the list context") { + classifyList("Ally Guild", allyGuild = true, huntedGuild = false, "allies") shouldBe ListRelation.AlliedGuild + // listed as an ally but their guild is actually hunted + classifyList("Hunted Guild", allyGuild = false, huntedGuild = true, "allies") shouldBe ListRelation.AlliedPlayerInHuntedGuild + classifyList("", allyGuild = false, huntedGuild = false, "allies") shouldBe ListRelation.AlliedPlayerNoGuild + classifyList("Neutral Guild", allyGuild = false, huntedGuild = false, "allies") shouldBe ListRelation.AlliedPlayerNeutralGuild + } + + test("hunted list: guild status determines the icon, crossing with the list context") { + classifyList("Hunted Guild", allyGuild = false, huntedGuild = true, "hunted") shouldBe ListRelation.HuntedGuild + // listed as hunted but their guild is actually allied + classifyList("Ally Guild", allyGuild = true, huntedGuild = false, "hunted") shouldBe ListRelation.HuntedPlayerInAlliedGuild + classifyList("", allyGuild = false, huntedGuild = false, "hunted") shouldBe ListRelation.HuntedPlayerNoGuild + classifyList("Neutral Guild", allyGuild = false, huntedGuild = false, "hunted") shouldBe ListRelation.HuntedPlayerNeutralGuild + } + + test("hunted-guild precedence differs by list: allies keeps the guild flag, both honour their own list") { + // in the allies list a hunted-guild member is flagged ally-in-enemy-guild; + // in the hunted list the same hunted guild is a plain hunted guild + classifyList("X", allyGuild = false, huntedGuild = true, "allies") shouldBe ListRelation.AlliedPlayerInHuntedGuild + classifyList("X", allyGuild = false, huntedGuild = true, "hunted") shouldBe ListRelation.HuntedGuild + } + + test("an unrecognised list context is unclassified (no icon)") { + classifyList("Ally Guild", allyGuild = true, huntedGuild = false, "boosted") shouldBe ListRelation.Unclassified + classifyList("", allyGuild = false, huntedGuild = false, "") shouldBe ListRelation.Unclassified + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/LevelVisibilitySpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/LevelVisibilitySpec.scala new file mode 100644 index 0000000..ef9f3de --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/LevelVisibilitySpec.scala @@ -0,0 +1,46 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class LevelVisibilitySpec extends AnyFunSuite with Matchers { + + // helper: a neutral, at-or-above-floor level-up with everything enabled + private def post( + isNeutral: Boolean = false, isAlly: Boolean = false, isEnemy: Boolean = false, + showNeutral: String = "true", showAllies: String = "true", showEnemies: String = "true", + level: Int = 100, minimumLevel: Int = 8 + ): Boolean = LevelVisibility.shouldPost( + isNeutral, isAlly, isEnemy, showNeutral, showAllies, showEnemies, level, minimumLevel) + + test("a neutral level-up is suppressed only when showNeutral is off") { + post(isNeutral = true, showNeutral = "false") shouldBe false + post(isNeutral = true, showNeutral = "true") shouldBe true + // an enemy/ally flag being off must not affect a neutral level-up + post(isNeutral = true, showEnemies = "false", showAllies = "false") shouldBe true + } + + test("an ally level-up is suppressed only when showAllies is off") { + post(isAlly = true, showAllies = "false") shouldBe false + post(isAlly = true, showAllies = "true", showNeutral = "false") shouldBe true + } + + test("an enemy level-up is suppressed only when showEnemies is off") { + post(isEnemy = true, showEnemies = "false") shouldBe false + post(isEnemy = true, showEnemies = "true", showAllies = "false") shouldBe true + } + + test("the minimum-level floor applies once the category is allowed") { + post(isNeutral = true, level = 7, minimumLevel = 8) shouldBe false + post(isNeutral = true, level = 8, minimumLevel = 8) shouldBe true + } + + test("a suppressed category is dropped even when above the level floor") { + post(isEnemy = true, showEnemies = "false", level = 999, minimumLevel = 8) shouldBe false + } + + test("an unrecognised category is gated by the level floor alone") { + post(level = 9, minimumLevel = 8) shouldBe true + post(level = 5, minimumLevel = 8) shouldBe false + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/ListEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/ListEmbedsSpec.scala new file mode 100644 index 0000000..3c7dded --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/ListEmbedsSpec.scala @@ -0,0 +1,47 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class ListEmbedsSpec extends AnyFunSuite with Matchers { + + test("a short list yields one embed carrying the thumbnail and colour") { + val embeds = ListEmbeds.paginate(List("aaa"), "https://x/thumb.gif", 3092790) + embeds should have size 1 + embeds.head.getDescription shouldBe "\naaa" + embeds.head.getThumbnail.getUrl shouldBe "https://x/thumb.gif" + (embeds.head.getColor.getRGB & 0xFFFFFF) shouldBe 3092790 + } + + test("lines exceeding the limit split across embeds; only the first has the thumbnail") { + val embeds = ListEmbeds.paginate(List("aaa", "bbb", "ccc"), "https://x/thumb.gif", 42, limit = 10) + embeds should have size 2 + embeds.head.getDescription shouldBe "\naaa\nbbb" + embeds.head.getThumbnail.getUrl shouldBe "https://x/thumb.gif" + embeds(1).getDescription shouldBe "ccc" + embeds(1).getThumbnail shouldBe null + embeds.foreach(e => (e.getColor.getRGB & 0xFFFFFF) shouldBe 42) + } + + test("every embed description stays within the limit") { + val many = (1 to 50).map(i => s"line-$i").toList + val embeds = ListEmbeds.paginate(many, "https://x/t.gif", 1, limit = 20) + embeds.foreach(_.getDescription.length should be <= 20) + // all the content survives across the embeds + embeds.flatMap(_.getDescription.split('\n')).filter(_.nonEmpty) should contain allElementsOf many + } + + test("pack accumulates lines into <=limit chunks, breaking when one would overflow") { + ListEmbeds.pack(List("aaa", "bbb", "ccc"), 10) shouldBe List("\naaa\nbbb", "ccc") + ListEmbeds.pack(List("a", "b"), 100) shouldBe List("\na\nb") + ListEmbeds.pack(Nil, 100) shouldBe List("") + ListEmbeds.pack(List("x", "y", "z"), 100).flatMap(_.split('\n')).filter(_.nonEmpty) shouldBe List("x", "y", "z") + } + + test("an empty list still yields a single embed with the thumbnail (JDA nulls the empty description)") { + val embeds = ListEmbeds.paginate(Nil, "https://x/thumb.gif", 42) + embeds should have size 1 + embeds.head.getDescription shouldBe null + embeds.head.getThumbnail.getUrl shouldBe "https://x/thumb.gif" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/NamesSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/NamesSpec.scala new file mode 100644 index 0000000..8b344d9 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/NamesSpec.scala @@ -0,0 +1,22 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class NamesSpec extends AnyFunSuite with Matchers { + + test("capitalizeWords upper-cases the first letter of each word") { + Names.capitalizeWords("violent beams") shouldBe "Violent Beams" + Names.capitalizeWords("the count") shouldBe "The Count" + } + + test("capitalizeWords leaves the rest of each word untouched (not a full title-case)") { + Names.capitalizeWords("violent BEAMS") shouldBe "Violent BEAMS" + Names.capitalizeWords("mcDonald") shouldBe "McDonald" + } + + test("capitalizeWords handles a single word and an empty string") { + Names.capitalizeWords("morgaroth") shouldBe "Morgaroth" + Names.capitalizeWords("") shouldBe "" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala index f435b0a..c42f0de 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListEmbedsSpec.scala @@ -17,4 +17,63 @@ class OnlineListEmbedsSpec extends AnyFunSuite with Matchers { OnlineListEmbeds.durationString(3660) shouldBe "`1hr 1min`" OnlineListEmbeds.durationString(7320) shouldBe "`2hr 2min`" } + + test("baseName strips the bot-appended '-' suffix") { + OnlineListEmbeds.baseName("online-42", "online") shouldBe "online" + OnlineListEmbeds.baseName("ɴᴇᴍᴇsɪs-5", "enemies") shouldBe "ɴᴇᴍᴇsɪs" + } + + test("baseName keeps a name that has no count suffix") { + OnlineListEmbeds.baseName("allies", "allies") shouldBe "allies" + } + + test("baseName only strips a trailing -digits, preserving internal hyphens and bare dashes") { + OnlineListEmbeds.baseName("my-cool-list-99", "online") shouldBe "my-cool-list" + OnlineListEmbeds.baseName("online-", "online") shouldBe "online-" + } + + test("categoryName shows both counts with the separator when both are positive") { + OnlineListEmbeds.categoryName("Antica", 5, 2) shouldBe "Antica・🤍5💀2" + } + + test("categoryName omits a zero count but keeps the separator while the other is positive") { + OnlineListEmbeds.categoryName("Antica", 5, 0) shouldBe "Antica・🤍5" + OnlineListEmbeds.categoryName("Antica", 0, 3) shouldBe "Antica・💀3" + } + + test("categoryName drops the separator entirely when both counts are zero") { + OnlineListEmbeds.categoryName("Antica", 0, 0) shouldBe "Antica" + } + + // --- packFields --- + + test("packFields always returns at least one (empty) field") { + OnlineListEmbeds.packFields(Nil) shouldBe List("") + } + + test("packFields newline-joins short lines into a single field") { + OnlineListEmbeds.packFields(List("a", "b", "c")) shouldBe List("\na\nb\nc") + } + + test("packFields starts a new field at a section header, but not when the field is still empty") { + OnlineListEmbeds.packFields(List("a", "### Neutrals", "b")) shouldBe List("\na", "### Neutrals\nb") + OnlineListEmbeds.packFields(List("### Neutrals", "b")) shouldBe List("\n### Neutrals\nb") + } + + test("packFields keeps a guild header ('### [') with the preceding lines (no section break)") { + OnlineListEmbeds.packFields(List("a", "### [Guild](u)", "b")) shouldBe List("\na\n### [Guild](u)\nb") + } + + test("packFields breaks to a new field once a line would reach 4060 chars") { + val packed = OnlineListEmbeds.packFields(List("x" * 4000, "y" * 100)) + packed should have size 2 + packed.head shouldBe "\n" + ("x" * 4000) + packed.last shouldBe "y" * 100 + } + + test("packFields breaks earlier (3850) when the incoming line is a guild header") { + val packed = OnlineListEmbeds.packFields(List("x" * 3840, "### [G](u)")) + packed should have size 2 + packed.last shouldBe "### [G](u)" + } } diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListGroupingSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListGroupingSpec.scala new file mode 100644 index 0000000..14e5ebc --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/OnlineListGroupingSpec.scala @@ -0,0 +1,115 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class OnlineListGroupingSpec extends AnyFunSuite with Matchers { + + test("collapses rows into one bucket per guild, keeping each guild's messages") { + val rows = List( + "Bona Fide" -> "msgA", + "Bona Fide" -> "msgB", + "Red Rising" -> "msgC") + val grouped = OnlineListGrouping.groupByGuild(rows).toMap + grouped("Bona Fide") should contain theSameElementsAs List("msgA", "msgB") + grouped("Red Rising") shouldBe List("msgC") + } + + test("orders guilds by descending member count") { + val rows = List( + "Small" -> "s1", + "Big" -> "b1", + "Big" -> "b2", + "Big" -> "b3", + "Mid" -> "m1", + "Mid" -> "m2") + OnlineListGrouping.groupByGuild(rows).map(_._1) shouldBe List("Big", "Mid", "Small") + } + + test("always places the guildless bucket last, even when it is the largest") { + val rows = List( + "" -> "n1", + "" -> "n2", + "" -> "n3", + "Guild" -> "g1") + val ordered = OnlineListGrouping.groupByGuild(rows) + ordered.last._1 shouldBe "" + ordered.last._2 should have size 3 + ordered.head._1 shouldBe "Guild" + } + + test("empty input yields an empty list") { + OnlineListGrouping.groupByGuild(Nil) shouldBe Nil + } + + test("a single guildless group is returned as the only (last) bucket") { + OnlineListGrouping.groupByGuild(List("" -> "x", "" -> "y")) shouldBe List("" -> List("x", "y")) + } + + test("withHeaders prefixes each guild bucket with a linked header and count, lines following") { + val grouped = List("Bona Fide" -> List("m1", "m2")) + OnlineListGrouping.withHeaders(grouped, n => s"### Others $n") shouldBe List( + s"### [Bona Fide](${Urls.guildUrl("Bona Fide")}) 2", "m1", "m2") + } + + test("withHeaders applies the caller's guildless header to the empty-name bucket") { + val grouped = List("" -> List("n1", "n2", "n3")) + OnlineListGrouping.withHeaders(grouped, n => s"### No Guild $n") shouldBe List( + "### No Guild 3", "n1", "n2", "n3") + } + + test("withHeaders preserves bucket order (guilds first, guildless last) end to end") { + val grouped = OnlineListGrouping.groupByGuild( + List("Big" -> "b1", "Big" -> "b2", "" -> "n1")) + OnlineListGrouping.withHeaders(grouped, n => s"### Others $n") shouldBe List( + s"### [Big](${Urls.guildUrl("Big")}) 2", "b1", "b2", "### Others 1", "n1") + } + + // --- combinedChannelBody --- + + test("combinedChannelBody headers allies and enemies when all three categories are present") { + val out = OnlineListGrouping.combinedChannelBody( + alliesList = List("a1"), + enemiesList = List("e1"), + neutralsList = List("n1"), + flattenedNeutralsList = List("### Others 1", "n1"), + allyEmoji = ":ally:", enemyEmoji = ":enemy:") + out shouldBe List( + "### :ally: **Allies** :ally: 1", "a1", + "### :enemy: **Enemies** :enemy: 1", "e1", + "### Others 1", "n1") + } + + test("combinedChannelBody adds no section header when allies are the only category") { + OnlineListGrouping.combinedChannelBody( + alliesList = List("a1", "a2"), + enemiesList = Nil, neutralsList = Nil, flattenedNeutralsList = Nil, + allyEmoji = ":ally:", enemyEmoji = ":enemy:") shouldBe List("a1", "a2") + } + + test("combinedChannelBody drops a lone '### Others' header when neutrals are the only category") { + OnlineListGrouping.combinedChannelBody( + alliesList = Nil, enemiesList = Nil, + neutralsList = List("n1", "n2"), + flattenedNeutralsList = List("### Others 2", "n1", "n2"), + allyEmoji = ":ally:", enemyEmoji = ":enemy:") shouldBe List("n1", "n2") + } + + test("combinedChannelBody keeps neutral guild sub-headers when they are present") { + val flattened = List(s"### [GuildX](${Urls.guildUrl("GuildX")}) 1", "g1", "### Others 1", "n1") + OnlineListGrouping.combinedChannelBody( + alliesList = Nil, enemiesList = Nil, + neutralsList = List("g1", "n1"), + flattenedNeutralsList = flattened, + allyEmoji = ":ally:", enemyEmoji = ":enemy:") shouldBe flattened + } + + test("combinedChannelBody headers both allies and enemies when neutrals are absent") { + OnlineListGrouping.combinedChannelBody( + alliesList = List("a1"), enemiesList = List("e1"), + neutralsList = Nil, flattenedNeutralsList = Nil, + allyEmoji = ":ally:", enemyEmoji = ":enemy:") shouldBe List( + "### :ally: **Allies** :ally: 1", "a1", + "### :enemy: **Enemies** :enemy: 1", "e1") + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/RecentLoginSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/RecentLoginSpec.scala new file mode 100644 index 0000000..b27f678 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/RecentLoginSpec.scala @@ -0,0 +1,40 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.Instant + +/** Pins the last-login stamp logic with a fixed clock; date strings are in the + * ISO form the TibiaData API returns. */ +class RecentLoginSpec extends AnyFunSuite with Matchers { + + private val now = Instant.parse("2026-05-31T12:00:00Z") + + test("a login within 24h renders the daily emoji and a relative timestamp") { + val login = "2026-05-31T02:00:00Z" // 10h ago + val expectedEpoch = Instant.parse(login).getEpochSecond.toString + RecentLogin.stamp(login, now) shouldBe s"<:daily:1133349016814485584>" + } + + test("a login exactly 24h ago is still included (<= 24)") { + RecentLogin.stamp("2026-05-30T12:00:00Z", now) should startWith("<:daily:") + } + + test("a login older than 24h yields no stamp") { + RecentLogin.stamp("2026-05-29T11:00:00Z", now) shouldBe "" + } + + test("a future login within 24h is included (absolute difference)") { + RecentLogin.stamp("2026-05-31T20:00:00Z", now) should startWith("<:daily:") + } + + test("empty input yields no stamp") { + RecentLogin.stamp("", now) shouldBe "" + } + + test("malformed input yields no stamp instead of throwing") { + noException should be thrownBy RecentLogin.stamp("not-a-date", now) + RecentLogin.stamp("not-a-date", now) shouldBe "" + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/presentation/WorldListSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/presentation/WorldListSpec.scala new file mode 100644 index 0000000..5aa74f0 --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/presentation/WorldListSpec.scala @@ -0,0 +1,74 @@ +package com.tibiabot.presentation + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Pins the multi-world list formatting used by the allies/hunted list output. */ +class WorldListSpec extends AnyFunSuite with Matchers { + + test("each world's players are prefixed by a world header") { + val out = WorldList.format(Map("Antica" -> List("a", "b"))) + out shouldBe List(":globe_with_meridians: **Antica** :globe_with_meridians:", "a", "b") + } + + test("worlds are ordered alphabetically") { + val out = WorldList.format(Map("Bona" -> List("b"), "Antica" -> List("a"))) + out.filter(_.contains(":globe_with_meridians:")) shouldBe List( + ":globe_with_meridians: **Antica** :globe_with_meridians:", + ":globe_with_meridians: **Bona** :globe_with_meridians:" + ) + out.indexWhere(_.contains("Antica")) should be < out.indexWhere(_.contains("Bona")) + } + + test("the 'Character does not exist' bucket is pushed to the end") { + val out = WorldList.format(Map( + "Character does not exist" -> List("ghost"), + "Antica" -> List("a"), + "Bona" -> List("b") + )) + out.last shouldBe "ghost" + out.indexWhere(_.contains("Character does not exist")) should be > out.indexWhere(_.contains("Bona")) + } + + test("an empty map yields an empty list") { + WorldList.format(Map.empty) shouldBe Nil + } + + // --- byWorld --- + + test("byWorld sorts a world's players by descending level") { + val out = WorldList.byWorld(Map("knight" -> Seq( + (100, "Antica", "k100"), (200, "Antica", "k200"), (150, "Antica", "k150")))) + out("Antica") shouldBe List("k200", "k150", "k100") + } + + test("byWorld orders vocations druid, knight, paladin, sorcerer, monk, none within a world") { + val out = WorldList.byWorld(Map( + "none" -> Seq((50, "W", "n")), + "monk" -> Seq((50, "W", "m")), + "sorcerer" -> Seq((50, "W", "s")), + "paladin" -> Seq((50, "W", "p")), + "knight" -> Seq((50, "W", "k")), + "druid" -> Seq((50, "W", "d")))) + out("W") shouldBe List("d", "k", "p", "s", "m", "n") + } + + test("byWorld keeps worlds independent") { + val out = WorldList.byWorld(Map("knight" -> Seq( + (100, "Antica", "kA"), (90, "Belobra", "kB")))) + out.keySet shouldBe Set("Antica", "Belobra") + out("Antica") shouldBe List("kA") + out("Belobra") shouldBe List("kB") + } + + test("byWorld keeps input order for equal levels (stable)") { + val out = WorldList.byWorld(Map("knight" -> Seq( + (100, "W", "first"), (100, "W", "second")))) + out("W") shouldBe List("first", "second") + } + + test("byWorld ignores empty/missing vocations and yields an empty map for no entries") { + WorldList.byWorld(Map.empty) shouldBe Map.empty + WorldList.byWorld(Map("knight" -> Seq.empty)) shouldBe Map.empty + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala index 87689e9..9f803b2 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/scheduler/ServerSaveScheduleSpec.scala @@ -15,10 +15,17 @@ class ServerSaveScheduleSpec extends AnyFunSuite with Matchers { ServerSaveSchedule.isServerSaveWindow(LocalTime.of(10, 45)) shouldBe false } - test("rashidLocation maps each weekday to its city") { + test("rashidLocation maps every weekday to its canonical Tibia city") { + // The full fixed rotation, so a typo in any city name is caught. ServerSaveSchedule.rashidLocation(DayOfWeek.MONDAY) shouldBe "Svargrond" + ServerSaveSchedule.rashidLocation(DayOfWeek.TUESDAY) shouldBe "Liberty Bay" + ServerSaveSchedule.rashidLocation(DayOfWeek.WEDNESDAY) shouldBe "Port Hope" ServerSaveSchedule.rashidLocation(DayOfWeek.THURSDAY) shouldBe "Ankrahmun" + ServerSaveSchedule.rashidLocation(DayOfWeek.FRIDAY) shouldBe "Darashia" + ServerSaveSchedule.rashidLocation(DayOfWeek.SATURDAY) shouldBe "Edron" ServerSaveSchedule.rashidLocation(DayOfWeek.SUNDAY) shouldBe "Carlin" + // every weekday is covered (the match is exhaustive, never throws) + DayOfWeek.values.foreach(d => ServerSaveSchedule.rashidLocation(d) should not be empty) } test("shouldShowDrome only when drome is in the future and within 3 days") { diff --git a/tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala b/tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala index 19f91e5..3898a33 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/state/StreamStateConcurrencySpec.scala @@ -1,6 +1,6 @@ package com.tibiabot.state -import com.tibiabot.domain.{PlayerCache, Players} +import com.tibiabot.domain.{PlayerCache, Players, Guilds, CustomSort, Discords, Worlds} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -18,6 +18,9 @@ class StreamStateConcurrencySpec extends AnyFunSuite with Matchers { private val when = ZonedDateTime.parse("2026-01-01T00:00:00Z") private def cache(name: String) = PlayerCache(name, Nil, "guild", when) private def player(name: String) = Players(name, "false", "test", "0") + private def guildEntry(name: String) = Guilds(name, "false", "test", "0") + private def sortEntry(name: String) = CustomSort("guild", name, "label", ":x:") + private def discord(id: String) = Discords(id, "admin", "boosted", "msg") /** Run `body(threadIndex)` on `threads` threads at once, started together. */ private def race(threads: Int)(body: Int => Unit): Unit = { @@ -62,19 +65,110 @@ class StreamStateConcurrencySpec extends AnyFunSuite with Matchers { state.huntedPlayersData(guildId).map(_.name).toSet.size shouldBe threads * perThread // no duplicates/drops } - test("interleaved writes across all three maps stay consistent") { + test("concurrent guild-list appends to the SAME guild keep every entry") { + val state = new StreamState + val threads = 16 + val perThread = 250 + val guildId = "shared-guild" + race(threads) { t => + (0 until perThread).foreach { i => + state.modifyHuntedGuildsData { m => + m.updated(guildId, guildEntry(s"$t-$i") :: m.getOrElse(guildId, Nil)) + } + } + } + state.huntedGuildsData(guildId).size shouldBe threads * perThread + state.alliedGuildsData shouldBe empty // independent map untouched + } + + test("concurrent worldsData inserts never lose an entry") { + val state = new StreamState + val threads = 16 + val perThread = 250 + race(threads) { t => + (0 until perThread).foreach { k => + state.modifyWorldsData(_ + (s"guild-$t-$k" -> List.empty[Worlds])) + } + } + state.worldsData.size shouldBe threads * perThread + } + + test("concurrent discordsData inserts never lose an entry") { + val state = new StreamState + val threads = 16 + val perThread = 250 + race(threads) { t => + (0 until perThread).foreach { k => + state.modifyDiscordsData(_ + (s"world-$t-$k" -> List(discord(s"d$t$k")))) + } + } + state.discordsData.size shouldBe threads * perThread + } + + test("concurrent customSort inserts never lose an entry") { + val state = new StreamState + val threads = 16 + val perThread = 250 + race(threads) { t => + (0 until perThread).foreach { k => + state.modifyCustomSortData(_ + (s"sort-$t-$k" -> List(sortEntry(s"s$t$k")))) + } + } + state.customSortData.size shouldBe threads * perThread + } + + test("concurrent activityCommandBlocker inserts never lose an entry") { + val state = new StreamState + val threads = 16 + val perThread = 250 + race(threads) { t => + (0 until perThread).foreach { k => + state.modifyActivityCommandBlocker(_ + (s"guild-$t-$k" -> true)) + } + } + state.activityCommandBlocker.size shouldBe threads * perThread + } + + test("concurrent characterCache inserts never lose an entry") { + val state = new StreamState + val threads = 16 + val perThread = 250 // 16 * 250 = 4000 distinct character names + race(threads) { t => + (0 until perThread).foreach { k => + state.modifyCharacterCache(_ + (s"char-$t-$k" -> when)) + } + } + state.characterCache.size shouldBe threads * perThread + } + + test("interleaved writes across all ten maps stay consistent") { val state = new StreamState val threads = 12 val perThread = 200 race(threads) { t => (0 until perThread).foreach { i => state.modifyActivityData(_ + (s"a-$t-$i" -> List(cache("x")))) - state.modifyHuntedPlayersData(_ + (s"h-$t-$i" -> List(player("x")))) - state.modifyAlliedPlayersData(_ + (s"y-$t-$i" -> List(player("x")))) + state.modifyHuntedPlayersData(_ + (s"hp-$t-$i" -> List(player("x")))) + state.modifyAlliedPlayersData(_ + (s"ap-$t-$i" -> List(player("x")))) + state.modifyHuntedGuildsData(_ + (s"hg-$t-$i" -> List(guildEntry("x")))) + state.modifyAlliedGuildsData(_ + (s"ag-$t-$i" -> List(guildEntry("x")))) + state.modifyCustomSortData(_ + (s"cs-$t-$i" -> List(sortEntry("x")))) + state.modifyDiscordsData(_ + (s"d-$t-$i" -> List(discord("x")))) + state.modifyWorldsData(_ + (s"w-$t-$i" -> List.empty[Worlds])) + state.modifyActivityCommandBlocker(_ + (s"b-$t-$i" -> true)) + state.modifyCharacterCache(_ + (s"c-$t-$i" -> when)) } } - state.activityData.size shouldBe threads * perThread - state.huntedPlayersData.size shouldBe threads * perThread - state.alliedPlayersData.size shouldBe threads * perThread + val expected = threads * perThread + state.activityData.size shouldBe expected + state.huntedPlayersData.size shouldBe expected + state.alliedPlayersData.size shouldBe expected + state.huntedGuildsData.size shouldBe expected + state.alliedGuildsData.size shouldBe expected + state.customSortData.size shouldBe expected + state.discordsData.size shouldBe expected + state.worldsData.size shouldBe expected + state.activityCommandBlocker.size shouldBe expected + state.characterCache.size shouldBe expected } } diff --git a/tibia-bot/src/test/scala/com/tibiabot/tibiadata/EntityDrainSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/EntityDrainSpec.scala new file mode 100644 index 0000000..62ecb5d --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/EntityDrainSpec.scala @@ -0,0 +1,47 @@ +package com.tibiabot.tibiadata + +import akka.actor.ActorSystem +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpResponse} +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.http.scaladsl.unmarshalling.Unmarshaller.UnsupportedContentTypeException +import akka.stream.scaladsl.Source +import akka.util.ByteString +import com.typesafe.config.ConfigFactory +import com.tibiabot.tibiadata.response.WorldResponse +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** Pins the akka-http behaviour that TibiaDataClient's leak fix relies on: a + * non-JSON response fails unmarshalling on the content-type check WITHOUT + * consuming the entity, so recoverUnmarshal's `discardEntityBytes()` is both + * necessary (to free the pooled connection) and safe (no double-materialise). + * If an akka-http upgrade changed this, the fix could silently leak or throw — + * this catches it. */ +class EntityDrainSpec extends AnyFunSuite with Matchers with JsonSupport { + + test("a non-JSON response fails with UnsupportedContentType and its entity is then drainable") { + // akka reference config only — the app's discord.conf has env substitutions + // (POSTGRES_HOST) that aren't set in tests and would fail system startup. + implicit val system: ActorSystem = ActorSystem("entity-drain-test", ConfigFactory.defaultReference()) + try { + // a streamed (chunked) entity, mimicking a real connection-bound response + val entity = HttpEntity.Chunked.fromData( + ContentTypes.`text/plain(UTF-8)`, + Source.single(ByteString("upstream error page, not json"))) + val response = HttpResponse(entity = entity) + + // the JSON unmarshaller rejects on the content-type check, before reading the body + intercept[UnsupportedContentTypeException] { + Await.result(Unmarshal(response).to[WorldResponse], 5.seconds) + } + + // because the body was never read, draining it now succeeds (the fix is safe) + noException should be thrownBy Await.result(response.discardEntityBytes().future(), 5.seconds) + } finally { + Await.result(system.terminate(), 5.seconds) + } + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala index 99c2ed6..3f65bc4 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/TibiaDataDecodersSpec.scala @@ -27,9 +27,10 @@ class TibiaDataDecodersSpec extends AnyFunSuite with Matchers with JsonSupport { } test("creatures: boosted creature is named and the full list decodes") { - val r = fixture("creatures.json").parseJson.convertTo[CreaturesResponse] + // CreatureResponse is the model the live getBoostedCreature path decodes. + val r = fixture("creatures.json").parseJson.convertTo[CreatureResponse] r.creatures.boosted.name should not be empty - r.creatures.boosted.race should not be empty + r.creatures.boosted.image_url should startWith("https://") r.creatures.creature_list.size should be > 50 // every list entry fully decodes (name + image url present) all(r.creatures.creature_list.map(_.name)) should not be empty @@ -45,6 +46,18 @@ class TibiaDataDecodersSpec extends AnyFunSuite with Matchers with JsonSupport { all(online.map(_.level)) should be > 0.0 } + test("worlds: the regular-world list decodes with the fields WorldManager reads") { + val r = fixture("worlds.json").parseJson.convertTo[WorldsResponse] + val worlds = r.worlds.regular_worlds + worlds.size should be > 50 + all(worlds.map(_.name)) should not be empty + // WorldManager sorts these names; a few long-standing worlds should be present + val names = worlds.map(_.name) + names should contain ("Antica") + all(worlds.map(_.players_online)) should be >= 0.0 + all(worlds.map(_.location)) should not be empty + } + test("character: empty guild object decodes to None and deaths/killers parse") { val r = fixture("character.json").parseJson.convertTo[CharacterResponse] val sheet = r.character From daa8c5fd1fcf38fa45ad2857bf77ec62caa8390e Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Sun, 31 May 2026 19:23:48 +1000 Subject: [PATCH 03/21] removing discord message posting mermaid chart and leaving the --- README.md | 55 +------------------------------------------------------ 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/README.md b/README.md index d0a21cc..7e02b94 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Production: - [Website](https://violentbot.xyz) - [Discord](https://discord.gg/PNnzzs4hN3) - [Patreon](https://www.patreon.com/violentbot) - + Current features include: - Online List - Levels List @@ -45,59 +45,6 @@ Supporting packages: | `domain/` | Core case classes; game-time cycles in `domain/time/`. | | `galthen/`, `boosted/`, `admin/` | Feature services extracted from `BotApp`. | -```mermaid -flowchart TB - Discord([Discord]) - - subgraph entry [Entry points] - BL["BotListener — thin event dispatcher"] - BA["BotApp — shared state + orchestration"] - TB["TibiaBot — per-world stream"] - end - - subgraph layer [commands + interactions] - RT[CommandRouter] - HD["handlers — one per slash command"] - IX["Button / Modal / Screenshot handlers"] - end - - subgraph svc [feature services] - FS["galthen · boosted · admin"] - end - - subgraph infra [infrastructure] - GW[DiscordGateway] - SN[RateLimitedSender] - ST["state/StreamState"] - PR[presentation] - TR[tracking] - SC[scheduler] - end - - subgraph data [data + external] - RP["persistence repositories"] - DB[(PostgreSQL)] - TD[tibiadata client] - WK[wiki client] - EXT{{"TibiaData v4 / Fandom"}} - end - - Discord --> BL - BL --> RT --> HD --> BA - BL --> IX --> BA - BA --> FS --> RP - BA --> ST - BA --> RP --> DB - HD --> PR - SC --> BA - TB --> TD --> EXT - BA --> WK --> EXT - TB --> ST - TB --> TR - TB --> PR - TB --> SN --> GW --> Discord -``` - **Concurrency:** one independent Akka stream per world (held by `StreamSupervisor`), all sharing a single `ActorSystem`/dispatcher and HTTP pool. Each world ticks every 60s through a back-pressured `mapAsync(1)` pipeline with a `mapAsyncUnordered(32)` From 4d9f194a07f8626d6471e3fec5410b47eb61dea5 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Sun, 31 May 2026 20:38:31 +1000 Subject: [PATCH 04/21] upping max http connections to the tibiadata api --- tibia-bot/src/main/resources/akka.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/tibia-bot/src/main/resources/akka.conf b/tibia-bot/src/main/resources/akka.conf index f632b4f..90a6865 100644 --- a/tibia-bot/src/main/resources/akka.conf +++ b/tibia-bot/src/main/resources/akka.conf @@ -1,5 +1,6 @@ akka.http { host-connection-pool { + max-connections = 16 max-open-requests = 256 response-entity-subscription-timeout = 10.seconds } From 9836433a792e1b4d261f09e5f074657f3f358be1 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Sun, 31 May 2026 20:48:17 +1000 Subject: [PATCH 05/21] Update akka.conf --- tibia-bot/src/main/resources/akka.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tibia-bot/src/main/resources/akka.conf b/tibia-bot/src/main/resources/akka.conf index 90a6865..2761f5b 100644 --- a/tibia-bot/src/main/resources/akka.conf +++ b/tibia-bot/src/main/resources/akka.conf @@ -1,6 +1,6 @@ akka.http { host-connection-pool { - max-connections = 16 + max-connections = 32 max-open-requests = 256 response-entity-subscription-timeout = 10.seconds } From b3ddf9f9d655793b629b71dedd8a9a30f8ad002f Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 02:48:53 +1000 Subject: [PATCH 06/21] Implementing redis cache --- .env.example | 26 +- README.md | 165 ++++++++++--- docker-compose.yml | 61 +++++ tibia-bot/build.sbt | 4 + tibia-bot/src/main/resources/akka.conf | 32 ++- tibia-bot/src/main/resources/discord.conf | 28 +++ .../src/main/scala/com/tibiabot/BotApp.scala | 20 +- .../src/main/scala/com/tibiabot/Config.scala | 23 ++ .../main/scala/com/tibiabot/TibiaBot.scala | 3 +- .../scala/com/tibiabot/WorldManager.scala | 8 +- .../CharacterCachePersistence.scala | 43 ++++ .../persistence/LettuceRedisCache.scala | 68 ++++++ .../com/tibiabot/persistence/RedisCache.scala | 23 ++ .../tibiabot/tibiadata/CachingTibiaApi.scala | 100 ++++++++ .../tibiabot/tibiadata/RequestMetrics.scala | 72 ++++++ .../tibiabot/tibiadata/TibiaDataClient.scala | 27 ++- .../com/tibiabot/AkkaPoolConfigSpec.scala | 37 +++ .../scala/com/tibiabot/CacheConfigSpec.scala | 26 ++ .../CharacterCachePersistenceSpec.scala | 50 ++++ .../tibiadata/CachingTibiaApiSpec.scala | 228 ++++++++++++++++++ 20 files changed, 995 insertions(+), 49 deletions(-) create mode 100644 docker-compose.yml create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/CharacterCachePersistence.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/LettuceRedisCache.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/persistence/RedisCache.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/tibiadata/CachingTibiaApi.scala create mode 100644 tibia-bot/src/main/scala/com/tibiabot/tibiadata/RequestMetrics.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/AkkaPoolConfigSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/CacheConfigSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/CharacterCachePersistenceSpec.scala create mode 100644 tibia-bot/src/test/scala/com/tibiabot/tibiadata/CachingTibiaApiSpec.scala diff --git a/.env.example b/.env.example index 78b03ef..bbe1af1 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,28 @@ TOKEN=your_discord_bot_token_here # PostgreSQL Configuration +# Use the bundled database (docker compose --profile local-db up) by leaving this +# as `postgres` — the compose service name. To use a PRE-EXISTING database, set +# it to that server's host/IP instead and start without the local-db profile. +# NOTE: the bot connects on port 5432 as user `postgres`; an external DB must +# listen on 5432 and accept POSTGRES_PASSWORD for the `postgres` user. POSTGRES_HOST=postgres -POSTGRES_PASSWORD=XXXXXXXX +POSTGRES_PASSWORD=change_me -# Tibiadata Configuration (local or public api) -TIBIADATA_HOST=https://api.tibiadata.com \ No newline at end of file +# Tibiadata Configuration (public api, or your self-hosted local instance) +TIBIADATA_HOST=https://api.tibiadata.com + +# Redis cache (optional). Set to the `redis` compose service name to enable +# caching; leave empty to disable it entirely (the bot runs unchanged without it). +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Cache freshness — how long cached TibiaData API responses are reused before +# re-fetching. Optional; omit to use the defaults shown. Units: s/m/h/d. +# (All cache TTLs live together in discord.conf's `cache { }` block.) +# CACHE_BOOSTED_TTL=30m # boosted boss + creature +# CACHE_HIGHSCORES_TTL=30m # /leaderboards highscores +# CACHE_WORLD_LIST_TTL=1h # world list +# CACHE_CHARACTER_SNAPSHOT_TTL=7d # character-cache snapshot self-eviction +# CACHE_CHARACTER_SNAPSHOT_INTERVAL=60s # how often that snapshot is written diff --git a/README.md b/README.md index 7e02b94..e657e15 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Production: - [Website](https://violentbot.xyz) - [Discord](https://discord.gg/PNnzzs4hN3) - [Patreon](https://www.patreon.com/violentbot) - + Current features include: - Online List - Levels List @@ -45,6 +45,59 @@ Supporting packages: | `domain/` | Core case classes; game-time cycles in `domain/time/`. | | `galthen/`, `boosted/`, `admin/` | Feature services extracted from `BotApp`. | +```mermaid +flowchart TB + Discord([Discord]) + + subgraph entry [Entry points] + BL["BotListener — thin event dispatcher"] + BA["BotApp — shared state + orchestration"] + TB["TibiaBot — per-world stream"] + end + + subgraph layer [commands + interactions] + RT[CommandRouter] + HD["handlers — one per slash command"] + IX["Button / Modal / Screenshot handlers"] + end + + subgraph svc [feature services] + FS["galthen · boosted · admin"] + end + + subgraph infra [infrastructure] + GW[DiscordGateway] + SN[RateLimitedSender] + ST["state/StreamState"] + PR[presentation] + TR[tracking] + SC[scheduler] + end + + subgraph data [data + external] + RP["persistence repositories"] + DB[(PostgreSQL)] + TD[tibiadata client] + WK[wiki client] + EXT{{"TibiaData v4 / Fandom"}} + end + + Discord --> BL + BL --> RT --> HD --> BA + BL --> IX --> BA + BA --> FS --> RP + BA --> ST + BA --> RP --> DB + HD --> PR + SC --> BA + TB --> TD --> EXT + BA --> WK --> EXT + TB --> ST + TB --> TR + TB --> PR + TB --> SN --> GW --> Discord +``` + **Concurrency:** one independent Akka stream per world (held by `StreamSupervisor`), all sharing a single `ActorSystem`/dispatcher and HTTP pool. Each world ticks every 60s through a back-pressured `mapAsync(1)` pipeline with a `mapAsyncUnordered(32)` @@ -122,40 +175,82 @@ You will need to change this to point to your emojis. 2. Open the [discord.conf](https://github.com/Leo32onGIT/tibia-bot/blob/dedicated/tibia-bot/src/main/resources/discord.conf#L17-L60) file and edit it. 3. Point to `emoji ids` to ones that exist on _your_ discord server - the ones you uploaded in step 1. -#### Prepare your linux machine to host the bot -1. Ensure `docker` is installed. -1. Ensure `Java JDK 8` is installed. -1. Ensure `sbt` is installed. -3. Download the `postgres` docker image: -`docker pull postgres` - -## Deployment Steps - -1. Clone the repository to your host machine. -2. Compile the code into a docker image: -`sbt docker:publishLocal` -3. Take note of the docker \ you just created: `docker images` -> ![docker image id](https://i.imgur.com/nXvSeIL.png) - -4. Create a `prod.env` file with the discord server/channel id & bot authentication token: -> ```env -> TOKEN=XXXXXXXXXXXXXXXXXXXXXX -> POSTGRES_HOST=sqlhost -> POSTGRES_PASSWORD=XXXXXXXXXX -> TIBIADATA_HOST=https://api.tibiadata.com/ -> ``` -5. Create the docker volume for the postgres database: -`docker volume create --name pgdata` -6. Create the docker network for the `postgres database` and `violent bot` to communicate over: -`docker network create violentbot` -6. Run the postgres docker image: -`docker run --rm -d -t --env-file prod.env --hostname sqlhost --network=violentbot --name postgres -p 5432:5432 -v pgdata:/var/lib/postgresql postgres` -7. Run the docker container you just created & parse the **prod.env** file: -`docker run --rm -d -t --env-file prod.env --network=violentbot --name violent-bot ` +#### Prepare your machine to host the bot +1. Ensure `docker` (with the **Compose** plugin) is installed. +2. Ensure you can build the bot image — either `sbt` + `Java JDK 8` locally, or use + the dockerized build shown below. + +Redis and (optionally) Postgres are provided by `docker-compose.yml`, so you no +longer need to pull or run them by hand. + +## Running with Docker Compose + +The repository ships a `docker-compose.yml` that runs the bot together with a +Redis cache and, optionally, a bundled Postgres. + +1. **Build the bot image** (tags `violent-bot-dedicated:latest`): + + ```bash + sbt docker:publishLocal + ``` + + No local sbt? Stage the image with the dockerized build, then `docker build`: + + ```bash + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/cache \ + -v "$HOME/.cache/tibiabot-build:/cache" -v "$PWD:/work" -w /work/tibia-bot \ + sbtscala/scala-sbt:eclipse-temurin-8u352-b08_1.8.2_2.13.10 sbt -batch docker:stage + docker build -t violent-bot-dedicated:latest tibia-bot/target/docker/stage + ``` + +2. **Create your `.env`** from the template and fill it in: + + ```bash + cp .env.example .env + ``` + +3. **Start the stack** — pick the database mode: + + - **Bundled Postgres** (self-contained — keep `POSTGRES_HOST=postgres` in `.env`): + + ```bash + docker compose --profile local-db up -d + ``` + + - **Pre-existing / external Postgres** (set `POSTGRES_HOST` to your server in + `.env`, no profile): + + ```bash + docker compose up -d + ``` + +Redis starts in both modes. To run **without** caching, set `REDIS_HOST=` (empty) +in `.env`; the bot then ignores the redis container. + +### Connecting to a pre-existing Postgres + +Leave the `local-db` profile off and point `POSTGRES_HOST` at your database host +or IP. The bot connects as the `postgres` user with `POSTGRES_PASSWORD` and creates +its own databases on first run. It always uses **port 5432**, so your database must +listen there. + +> With the bundled Postgres, the bot may log a few connection errors and restart +> while Postgres initialises on first boot — this is expected and self-resolves. + +### Manual (without Compose) + +The original `docker run` flow still works: create a `violentbot` network, run a +`postgres` container, a `redis:7-alpine` container (with `--appendonly yes`), and +the `violent-bot-dedicated` image, each with `--env-file .env`. The +`docker-compose.yml` is the source of truth for the exact images and settings. ## Debugging -1. If something isn't working correctly you should be able to see why very clearly in the logs. -2. Use `docker ps` to find the \ for the running bot. -3. Use `docker logs ` to view the logs. -4. Use `docker pull dpage/pgadmin4` and `docker run -t --name pgadmin -p 0.0.0.0:82:80 --link postgres:postgres -e 'PGADMIN_DEFAULT_EMAIL=XXXXXXX@gmail.com' -e 'PGADMIN_DEFAULT_PASSWORD=XXXXXXXX' -d dpage/pgadmin4` if you need to visualise the postgres dbs +1. Tail the bot logs: `docker compose logs -f bot` (errors are usually self-explanatory). +2. See what's running: `docker compose ps`. +3. **Pool sizing:** grep the bot logs for `[req-probe]` — every 60s it logs per-host + latency percentiles, req/sec and a suggested `max-connections` you can feed back + into `akka.conf`'s `per-host-override`. +4. To visualise the databases, run pgAdmin on the compose network: + `docker run -t --name pgadmin -p 82:80 --network -e 'PGADMIN_DEFAULT_EMAIL=you@example.com' -e 'PGADMIN_DEFAULT_PASSWORD=changeme' -d dpage/pgadmin4` + (find `` with `docker network ls`). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d1bb51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +# Violent Bot — Docker Compose +# +# Quick start (see README "Running with Docker Compose"): +# 1. Build the bot image once: sbt docker:publishLocal (-> violent-bot-dedicated:latest) +# 2. cp .env.example .env and fill it in +# 3. Start it: +# • bundled Postgres: docker compose --profile local-db up -d +# • pre-existing DB: docker compose up -d (set POSTGRES_HOST in .env) +# +# Redis (the cache) starts by default in both modes. Leave REDIS_HOST empty in +# .env to run without caching (the bot then ignores the redis container). + +services: + bot: + image: violent-bot-dedicated:latest + env_file: .env + depends_on: + redis: + condition: service_healthy + # The bot connects to Postgres at boot; with the bundled DB (local-db profile) + # it may restart a couple of times while Postgres initialises — that is + # expected and harmless. It is NOT wired to depends_on postgres so that a + # pre-existing/external database works without starting the local one. + restart: unless-stopped + + redis: + image: redis:7-alpine + # appendonly so the character-cache snapshot (and other cached keys) survive + # a redis restart instead of forcing a cold re-fetch. + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + # Optional bundled Postgres — enabled ONLY with the `local-db` profile: + # docker compose --profile local-db up -d + # To use a pre-existing database instead, omit the profile and point + # POSTGRES_HOST at your server. NOTE: the bot hardcodes port 5432, so an + # external database must listen on 5432. + postgres: + image: postgres:16 + profiles: ["local-db"] + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + volumes: + - pgdata:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + redis-data: + pgdata: diff --git a/tibia-bot/build.sbt b/tibia-bot/build.sbt index 4f4e285..af72c40 100644 --- a/tibia-bot/build.sbt +++ b/tibia-bot/build.sbt @@ -7,6 +7,9 @@ enablePlugins(DockerPlugin) enablePlugins(JavaAppPackaging) dockerExposedPorts += 443 dockerBaseImage := "eclipse-temurin:8-jre" +// Also tag the built image `:latest` so docker-compose.yml can reference a +// stable tag (otherwise only the version tag is created). +dockerUpdateLatest := true val AkkaHttpVersion = "10.5.0" @@ -23,6 +26,7 @@ libraryDependencies += "club.minnced" % "discord-webhooks" % "0.8.2" libraryDependencies += "org.apache.commons" % "commons-text" % "1.10.0" libraryDependencies += "org.postgresql" % "postgresql" % "42.5.4" libraryDependencies += "com.google.guava" % "guava" % "30.1.1-jre" +libraryDependencies += "io.lettuce" % "lettuce-core" % "6.2.6.RELEASE" libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.15" libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.15" % Test diff --git a/tibia-bot/src/main/resources/akka.conf b/tibia-bot/src/main/resources/akka.conf index 2761f5b..accf223 100644 --- a/tibia-bot/src/main/resources/akka.conf +++ b/tibia-bot/src/main/resources/akka.conf @@ -1,7 +1,35 @@ akka.http { host-connection-pool { - max-connections = 32 - max-open-requests = 256 + # akka-http keys a SEPARATE connection pool per host. The bot talks to two: + # - api.tibiadata.com : the public API (world/guild/character firehose). + # TibiaData absorbs this; we can push it hard. + # - the local instance : Config.tibiadataApi (boosted/highscores + the + # lvl>=250 bypass). CipSoft exponentially backs this + # off, so it must stay conservative ("lowkey"). + # + # This DEFAULT therefore governs the rate-sensitive local instance (and any + # other host): keep it modest. The public host gets an aggressive override + # below. Caching (boosted/highscores) already cuts the local instance's load. + # - if the local instance gets 429s/backoff, lower this (e.g. 8); + max-connections = 16 + + # Queue depth: absorbs a whole 60s polling cycle's burst across all worlds + # instead of failing fast. Must be a power of two (4096 = 2^12). + max-open-requests = 4096 + response-entity-subscription-timeout = 10.seconds + + # Per-host override: the public API is the ~133 req/s firehose. By Little's + # law it needs ~rps * p95-latency connections to drain within the 60s cycle; + # 16 cannot (16 conns sustain ~16/latency req/s). Size this from the + # RequestMetrics "[req-probe] ... max-connections >= N" log line for + # host=api.tibiadata.com (round up — see the probe's entity-drain caveat). + # 64 is a sane interim (4x) pending those real numbers. + per-host-override = [ + { + host-pattern = "api.tibiadata.com" + max-connections = 64 + } + ] } } diff --git a/tibia-bot/src/main/resources/discord.conf b/tibia-bot/src/main/resources/discord.conf index 06281ac..a628440 100644 --- a/tibia-bot/src/main/resources/discord.conf +++ b/tibia-bot/src/main/resources/discord.conf @@ -3,6 +3,34 @@ discord-config { postgres-password = ${POSTGRES_PASSWORD} postgres-host = ${POSTGRES_HOST} localapi-host = ${TIBIADATA_HOST} + // Redis cache (optional). Unset env => empty host => caching disabled (NoopRedisCache), + // so the bot runs unchanged without a Redis container. + redis-host = "" + redis-host = ${?REDIS_HOST} + redis-port = 6379 + redis-port = ${?REDIS_PORT} + redis-password = "" + redis-password = ${?REDIS_PASSWORD} + + // ── Cache freshness — ONE place to tune how long cached TibiaData API + // responses are reused before re-fetching. HOCON duration units: s, m, h, d + // (e.g. 30m, 1h, 7d). Each is overridable by the matching CACHE_* env var. + // NOTE: these are the API-response cache TTLs only. Behavioural dedup / + // notification windows (death/level/online retention, DB cache cleanup) are + // deliberately NOT here — they change what gets posted and several are + // coupled pairs; they live in TibiaBot / JdbcCacheRepository. + cache { + boosted-ttl = 30m # boosted boss + creature (Redis) + boosted-ttl = ${?CACHE_BOOSTED_TTL} + highscores-ttl = 30m # /leaderboards highscores (Redis) + highscores-ttl = ${?CACHE_HIGHSCORES_TTL} + world-list-ttl = 1h # world list, in-memory (changes only at game updates) + world-list-ttl = ${?CACHE_WORLD_LIST_TTL} + character-snapshot-ttl = 7d # R1 character-cache snapshot self-eviction (Redis) + character-snapshot-ttl = ${?CACHE_CHARACTER_SNAPSHOT_TTL} + character-snapshot-interval = 60s # how often that snapshot is written + character-snapshot-interval = ${?CACHE_CHARACTER_SNAPSHOT_INTERVAL} + } avatar-url = "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Violent%20Bot.png" fullbless-avatar-url = "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Amulet%20of%20Loss.png" namechange-thumbnail = "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/namechange.png" diff --git a/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala b/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala index d1cc087..845d1b3 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/BotApp.scala @@ -54,7 +54,9 @@ object BotApp extends App with StrictLogging { implicit private val actorSystem: ActorSystem = ActorSystem() implicit private val ex: ExecutionContextExecutor = actorSystem.dispatcher - private val tibiaDataClient: tibiadata.TibiaApi = new TibiaDataClient() + private val tibiaDataClient: tibiadata.TibiaApi = + new tibiadata.CachingTibiaApi(new TibiaDataClient(), persistence.RedisCacheProvider.cache, + Config.Cache.boostedTtl, Config.Cache.highscoresTtl)(scala.concurrent.ExecutionContext.global) private val connectionProvider: persistence.ConnectionProvider = new persistence.JdbcConnectionProvider(Config.postgresHost, Config.postgresPassword) private val schemaInitializer = new persistence.SchemaInitializer(connectionProvider) @@ -149,6 +151,22 @@ object BotApp extends App with StrictLogging { streamState.modifyActivityCommandBlocker(f) def modifyCharacterCache(f: Map[String, ZonedDateTime] => Map[String, ZonedDateTime]): Unit = streamState.modifyCharacterCache(f) + + // R1: warm the Date-header character cache from the last Redis snapshot so a + // restart doesn't re-baseline ~8000 characters against the rate-limited API, + // then snapshot it every 60s. Whole-map snapshot keeps the per-character hot + // path off Redis entirely; no-op + empty load when Redis is disabled. + private val charCachePersistence = + new persistence.CharacterCachePersistence(persistence.RedisCacheProvider.cache, Config.Cache.characterSnapshotTtl)(ex) + charCachePersistence.load().foreach { loaded => + if (loaded.nonEmpty) { + modifyCharacterCache(existing => loaded ++ existing) // existing (fresher) entries win + logger.info(s"Warmed character cache from Redis snapshot: ${loaded.size} entries") + } + } + private val snapshotInterval = Config.Cache.characterSnapshotInterval + actorSystem.scheduler.scheduleWithFixedDelay(snapshotInterval, snapshotInterval)(() => { charCachePersistence.save(characterCache); () })(ex) + val worlds: List[String] = Config.worldList // Per-guild channel/role setup lifecycle (extraction of the channel ops from diff --git a/tibia-bot/src/main/scala/com/tibiabot/Config.scala b/tibia-bot/src/main/scala/com/tibiabot/Config.scala index 00f1aaf..b005593 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/Config.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/Config.scala @@ -2,7 +2,9 @@ package com.tibiabot import com.typesafe.config.ConfigFactory +import scala.concurrent.duration.FiniteDuration import scala.jdk.CollectionConverters._ +import scala.jdk.DurationConverters._ object Config { // prod or dev environment @@ -18,6 +20,27 @@ object Config { val postgresHost: String = discord.getString("postgres-host") val postgresPassword: String = discord.getString("postgres-password") val tibiadataApi: String = discord.getString("localapi-host") + val redisHost: String = discord.getString("redis-host") + val redisPort: Int = discord.getInt("redis-port") + val redisPassword: String = discord.getString("redis-password") + val redisEnabled: Boolean = redisHost.nonEmpty + + /** Cache freshness, all in one place — how long cached TibiaData API responses + * are reused before re-fetching. Backed by the `cache { }` block in + * discord.conf (overridable per-key by CACHE_* env vars). HOCON durations + * (`30m`, `1h`, `7d`) are read as scala FiniteDurations. + * + * These are API-response cache TTLs ONLY. Behavioural dedup / notification + * windows (death/level/online retention, DB cache cleanup) are intentionally + * not here — they alter what gets posted and several are coupled pairs. */ + object Cache { + private def dur(key: String): FiniteDuration = discord.getDuration(s"cache.$key").toScala + val boostedTtl: FiniteDuration = dur("boosted-ttl") + val highscoresTtl: FiniteDuration = dur("highscores-ttl") + val worldListTtl: FiniteDuration = dur("world-list-ttl") + val characterSnapshotTtl: FiniteDuration = dur("character-snapshot-ttl") + val characterSnapshotInterval: FiniteDuration = dur("character-snapshot-interval") + } val creatureUrlMappings: Map[String, String] = mappings.getObject("creature-url-mappings").asScala.map { case (k, v) => k -> v.unwrapped().toString }.toMap diff --git a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala index 44c9d92..64bf98e 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala @@ -61,7 +61,8 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext private var neutralsListPurgeTimer: Map[String, ZonedDateTime] = Map.empty private var onlineListTableUpdateTimer: ZonedDateTime = ZonedDateTime.now().minusMinutes(10) // Start immediately - private val tibiaDataClient: TibiaApi = new TibiaDataClient() + private val tibiaDataClient: TibiaApi = + new tibiadata.CachingTibiaApi(new TibiaDataClient(), persistence.RedisCacheProvider.cache)(scala.concurrent.ExecutionContext.global) private val deathRecentDuration = 30 * 60 // 30 minutes for a death to count as recent enough to be worth notifying private val onlineRecentDuration = 10 * 60 // 10 minutes for a character to still be checked for deaths after logging off diff --git a/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala b/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala index 1d8171c..659db30 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/WorldManager.scala @@ -12,12 +12,14 @@ object WorldManager extends StrictLogging { implicit private val system: akka.actor.ActorSystem = akka.actor.ActorSystem() - private val tibiaDataClient = new TibiaDataClient() + private val tibiaDataClient: tibiadata.TibiaApi = + new tibiadata.CachingTibiaApi(new TibiaDataClient(), persistence.RedisCacheProvider.cache)(scala.concurrent.ExecutionContext.global) - // The world list changes only at major game updates, so cache it for an hour + // The world list changes only at major game updates, so cache it (default 1h) // instead of making a blocking API call on every getWorldList() (e.g. once per // /leaderboards). Falls back to the last good value, then the static list. - private val cacheTtl = Duration.ofHours(1) + // TTL is centralised with the other cache TTLs in Config.Cache (discord.conf cache {}). + private val cacheTtl = Duration.ofMillis(Config.Cache.worldListTtl.toMillis) // Fallback static world list in case API fails private val fallbackWorldList = List( diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/CharacterCachePersistence.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/CharacterCachePersistence.scala new file mode 100644 index 0000000..805bcb7 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/CharacterCachePersistence.scala @@ -0,0 +1,43 @@ +package com.tibiabot +package persistence + +import spray.json._ +import spray.json.DefaultJsonProtocol._ + +import java.time.ZonedDateTime +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} +import scala.util.control.NonFatal + +/** Best-effort Redis persistence for the in-memory Date-header character cache. + * + * Stored as a single periodic snapshot blob (key `tibia:chardate-snapshot`) of + * name -> last-seen ISO timestamp. Boot `load()`s it so a restart doesn't + * re-baseline ~8000 characters against the rate-limited API; a scheduled + * `save()` writes it. + * + * Deliberately a WHOLE-MAP snapshot, not per-character write-behind: the + * death-detection hot path writes the character cache ~8000x/cycle, so it must + * never touch Redis per entry. Up to one snapshot interval of cache state can be + * lost on an abrupt crash — acceptable, since the fallback is just a few extra + * re-fetches (far better than losing the entire cache on every restart). + * + * No-op when Redis is disabled (NoopRedisCache); all failures degrade to an + * empty load / dropped save, so this can never affect correctness. */ +final class CharacterCachePersistence(cache: RedisCache, ttl: FiniteDuration = 7.days)(implicit ec: ExecutionContext) { + private val key = "tibia:chardate-snapshot" + + /** Read the last snapshot; an absent/corrupt/unreachable cache yields empty. */ + def load(): Future[Map[String, ZonedDateTime]] = + cache.get(key).map { + case Some(json) => + json.parseJson.convertTo[Map[String, String]].toList.flatMap { case (name, iso) => + try Some(name -> ZonedDateTime.parse(iso)) catch { case NonFatal(_) => None } + }.toMap + case None => Map.empty[String, ZonedDateTime] + }.recover { case NonFatal(_) => Map.empty[String, ZonedDateTime] } + + /** Persist the current snapshot (best-effort; errors are swallowed by setEx). */ + def save(snapshot: Map[String, ZonedDateTime]): Future[Unit] = + cache.setEx(key, snapshot.map { case (k, v) => k -> v.toString }.toJson.compactPrint, ttl) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/LettuceRedisCache.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/LettuceRedisCache.scala new file mode 100644 index 0000000..ed43e08 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/LettuceRedisCache.scala @@ -0,0 +1,68 @@ +package com.tibiabot +package persistence + +import com.typesafe.scalalogging.StrictLogging +import io.lettuce.core.api.StatefulRedisConnection +import io.lettuce.core.api.async.RedisAsyncCommands +import io.lettuce.core.{RedisClient, RedisURI} + +import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.duration.FiniteDuration +import scala.jdk.FutureConverters._ +import scala.util.control.NonFatal + +/** Lettuce-backed RedisCache. One client + multiplexed connection is shared + * across all per-world callers (Lettuce connections are thread-safe). Every + * operation recovers cache errors to a miss / no-op so a Redis hiccup can never + * take down the bot — the cache is strictly an optimisation. */ +final class LettuceRedisCache(host: String, port: Int, password: String)(implicit ec: ExecutionContext) + extends RedisCache with StrictLogging { + + private val uri = { + val builder = RedisURI.builder().withHost(host).withPort(port) + if (password.nonEmpty) builder.withPassword(password.toCharArray) + builder.build() + } + private val client: RedisClient = RedisClient.create(uri) + private val connection: StatefulRedisConnection[String, String] = client.connect() + private val commands: RedisAsyncCommands[String, String] = connection.async() + + logger.info(s"Redis cache connected to $host:$port") + + def get(key: String): Future[Option[String]] = + commands.get(key).asScala.map(Option(_)).recover { + case NonFatal(e) => logger.warn(s"redis GET failed for '$key': ${e.getMessage}"); None + } + + def setEx(key: String, value: String, ttl: FiniteDuration): Future[Unit] = + commands.psetex(key, ttl.toMillis, value).asScala.map(_ => ()).recover { + case NonFatal(e) => logger.warn(s"redis PSETEX failed for '$key': ${e.getMessage}"); () + } + + def close(): Unit = { + connection.close() + client.shutdown() + } +} + +/** Builds the single shared RedisCache from Config: a real Lettuce client when a + * redis host is configured, else the no-op so the bot runs unchanged. + * + * Deliberately a JVM-wide singleton rather than a constructor-injected + * dependency (the way persistence repositories receive a ConnectionProvider). + * Its only consumer, CachingTibiaApi, is built independently at three sites — + * BotApp, WorldManager (an object) and each per-world TibiaBot, which all + * self-construct their TibiaApi — so threading one instance through them is not + * possible without de-objecting WorldManager and adding a TibiaApi param to + * TibiaBot. This singleton parallels how Config / WorldManager / TibiaDataClient + * are already wired in the tibiadata layer, and guarantees every per-world cache + * shares one Redis connection and the same keys. It lives for the whole process + * lifetime; teardown is delegated to process exit (hence close() is never + * driven in prod — it exists for the port contract and tests). */ +object RedisCacheProvider { + lazy val cache: RedisCache = + if (Config.redisEnabled) + new LettuceRedisCache(Config.redisHost, Config.redisPort, Config.redisPassword)(ExecutionContext.global) + else + NoopRedisCache +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/RedisCache.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/RedisCache.scala new file mode 100644 index 0000000..bab982a --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/RedisCache.scala @@ -0,0 +1,23 @@ +package com.tibiabot +package persistence + +import scala.concurrent.Future +import scala.concurrent.duration.FiniteDuration + +/** Minimal key/value cache port. Implemented by a Lettuce-backed Redis client in + * prod, an in-memory map in tests, and a no-op when Redis is unconfigured. + * All operations are best-effort: implementations must never fail the caller's + * Future on a cache error (degrade to a miss / no-op instead). */ +trait RedisCache { + def get(key: String): Future[Option[String]] + def setEx(key: String, value: String, ttl: FiniteDuration): Future[Unit] + def close(): Unit +} + +/** Disabled fallback: every get misses, every write is dropped. Used when no + * redis host is configured so the bot runs unchanged without a Redis container. */ +object NoopRedisCache extends RedisCache { + def get(key: String): Future[Option[String]] = Future.successful(None) + def setEx(key: String, value: String, ttl: FiniteDuration): Future[Unit] = Future.unit + def close(): Unit = () +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/CachingTibiaApi.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/CachingTibiaApi.scala new file mode 100644 index 0000000..2345f2c --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/CachingTibiaApi.scala @@ -0,0 +1,100 @@ +package com.tibiabot +package tibiadata + +import com.tibiabot.persistence.RedisCache +import com.tibiabot.tibiadata.response.{BoostedResponse, CharacterResponse, CreatureResponse, GuildResponse, HighscoresResponse, WorldResponse, WorldsResponse} +import com.typesafe.scalalogging.StrictLogging +import spray.json._ + +import java.time.{LocalDate, ZoneId, ZonedDateTime} +import java.util.Locale +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} +import scala.util.control.NonFatal + +/** Caching decorator over a TibiaApi. + * + * Caches ONLY the freshness-tolerant, fan-out-heavy endpoints that hit the + * rate-limited local instance: boosted boss/creature and highscores. These + * change at most daily/hourly yet fan out per-guild at server-save, so caching + * collapses N identical calls into one. + * + * The per-cycle character firehose and the lvl>=250 bypass are deliberately + * passed straight through: caching them would delay death detection, which is + * exactly what the bot exists to do quickly (see the local-bypass rationale). + * + * The boosted boss/creature flip at the 10:00 Berlin server save, and the + * change-detection that fires the daily notification compares the API value to + * the DB-stored one — so a value cached just before the save must NOT survive + * past it. We therefore key those entries by the current "save day" (the date + * of the most recent 10:00 Berlin boundary): the key rolls the instant the save + * day rolls, turning any pre-save entry into a guaranteed miss regardless of + * remaining TTL. The TTL still applies, purely to self-evict stale day keys. + * + * CAVEAT for future maintainers: JsonSupport.strFormat is asymmetric + * (unescape-on-read, plain-write), so a string carrying an HTML entity decodes + * differently on a cache hit vs a fresh fetch. Today's cached endpoints only + * carry fixed monster names / entity-free player names, so it cannot trigger; + * do NOT cache a free-form-text endpoint here without first making strFormat + * symmetric. + * + * Cache misses, decode failures and cache errors all fall back to the + * underlying API, so Redis is strictly an optimisation. */ +final class CachingTibiaApi( + underlying: TibiaApi, + cache: RedisCache, + boostedTtl: FiniteDuration = 30.minutes, + highscoresTtl: FiniteDuration = 30.minutes, + berlinNow: () => ZonedDateTime = () => ZonedDateTime.now(ZoneId.of("Europe/Berlin")) +)(implicit ec: ExecutionContext) + extends TibiaApi with JsonSupport with StrictLogging { + + /** Date of the most recent 10:00 Berlin server save — same rollover the bot + * uses for Rashid. Embedded in the boosted keys so a pre-save entry can never + * be read after the save. */ + private def saveDay: LocalDate = berlinNow().minusHours(10).toLocalDate + + /** Serve a decoded cache hit, else fetch from `underlying` and store a Right. */ + private def cached[T: RootJsonFormat](key: String, ttl: FiniteDuration)(fetch: => Future[Either[String, T]]): Future[Either[String, T]] = + cache.get(key).recover { case NonFatal(_) => None }.flatMap { + case Some(json) => + try Future.successful(Right(json.parseJson.convertTo[T])) + catch { + case NonFatal(e) => + logger.warn(s"redis decode failed for '$key', refetching: ${e.getMessage}") + fetchAndStore(key, ttl)(fetch) + } + case None => fetchAndStore(key, ttl)(fetch) + } + + private def fetchAndStore[T: RootJsonFormat](key: String, ttl: FiniteDuration)(fetch: => Future[Either[String, T]]): Future[Either[String, T]] = + fetch.flatMap { + case r @ Right(value) => cache.setEx(key, value.toJson.compactPrint, ttl).map(_ => r) + case l => Future.successful(l) + } + + // --- cached endpoints (local instance, freshness-tolerant) --- + + def getBoostedBoss(): Future[Either[String, BoostedResponse]] = + cached(s"tibia:boostedboss:$saveDay", boostedTtl)(underlying.getBoostedBoss()) + + def getBoostedCreature(): Future[Either[String, CreatureResponse]] = + cached(s"tibia:boostedcreature:$saveDay", boostedTtl)(underlying.getBoostedCreature()) + + // page is forward-compat: production only fetches page 1 today, but the + // underlying /v4/highscores endpoint is genuinely paged. toLowerCase(ROOT) + // keeps the key locale-independent (and case-insensitive, matching Tibia). + def getHighscores(world: String, page: Int): Future[Either[String, HighscoresResponse]] = + cached(s"tibia:highscores:${world.toLowerCase(Locale.ROOT)}:$page", highscoresTtl)(underlying.getHighscores(world, page)) + + // --- pass-through (deliberately uncached — see class doc) --- + + def getWorld(world: String): Future[Either[String, WorldResponse]] = underlying.getWorld(world) + def getWorlds(): Future[Either[String, WorldsResponse]] = underlying.getWorlds() + def getGuild(guild: String): Future[Either[String, GuildResponse]] = underlying.getGuild(guild) + def getGuildWithInput(input: (String, String)): Future[(Either[String, GuildResponse], String, String)] = underlying.getGuildWithInput(input) + def getCharacter(name: String): Future[Either[String, CharacterResponse]] = underlying.getCharacter(name) + def getKillerFallback(name: String): Future[Either[String, CharacterResponse]] = underlying.getKillerFallback(name) + def getCharacterV2(input: (String, Int)): Future[Either[String, CharacterResponse]] = underlying.getCharacterV2(input) + def getCharacterWithInput(input: (String, String, String)): Future[(Either[String, CharacterResponse], String, String, String)] = underlying.getCharacterWithInput(input) +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/RequestMetrics.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/RequestMetrics.scala new file mode 100644 index 0000000..595e651 --- /dev/null +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/RequestMetrics.scala @@ -0,0 +1,72 @@ +package com.tibiabot +package tibiadata + +import akka.actor.ActorSystem +import com.typesafe.scalalogging.StrictLogging + +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.{ConcurrentHashMap, ConcurrentLinkedQueue} +import scala.collection.mutable +import scala.concurrent.duration._ +import scala.jdk.CollectionConverters._ + +/** In-process latency/throughput probe for outbound TibiaData requests. + * + * Every outbound request records its round-trip (millis) bucketed by host; + * once per window we log per-host p50/p95/p99/max plus effective req/sec, so + * the akka host-connection-pool `max-connections` can be sized from real + * numbers rather than guessed. Little's law: to sustain R req/sec at latency + * L seconds you need ~R*L concurrent connections — we print that figure too. + * + * A singleton on purpose: there is one TibiaDataClient per world (~40 in prod), + * so per-instance metrics would fragment into 40 useless summaries. All clients + * feed this one accumulator and the first to start wires the single reporter. + * + * CAVEAT: latency is measured to response-headers (singleRequest's Future), not + * to full entity consumption. The pool holds a connection until the entity is + * drained + parsed, so the recorded latency understates true connection-hold + * time and the suggested connection count is a *floor*. The gap scales with + * body size: character pages are ~2KB (gap small), but the larger endpoints + * (creatures ~93KB, guild ~76KB, world ~33KB) can roughly double the effective + * latency — round the suggestion up accordingly (≈2x for those) when sizing. + * + * Observational only; remove once max-connections is tuned. */ +object RequestMetrics extends StrictLogging { + private val windowSeconds = 60 + private val samplesByHost = new ConcurrentHashMap[String, ConcurrentLinkedQueue[java.lang.Long]]() + private val started = new AtomicBoolean(false) + + /** Record one completed request's round-trip latency against its host. */ + def record(host: String, millis: Long): Unit = + samplesByHost + .computeIfAbsent(host, _ => new ConcurrentLinkedQueue[java.lang.Long]()) + .add(millis) + + /** Idempotent: the first TibiaDataClient to construct starts the single + * shared reporter so the per-world clients all roll up into one summary. */ + def ensureReporting(system: ActorSystem): Unit = + if (started.compareAndSet(false, true)) + system.scheduler.scheduleWithFixedDelay(windowSeconds.seconds, windowSeconds.seconds)( + () => report())(system.dispatcher) + + /** Drain the window per host and log a summary. Samples added mid-drain simply + * roll into the next window. */ + private def report(): Unit = + samplesByHost.asScala.foreach { case (host, queue) => + val buf = mutable.ArrayBuffer.empty[Long] + var x = queue.poll() + while (x != null) { buf += x.longValue; x = queue.poll() } + if (buf.nonEmpty) { + val sorted = buf.toArray.sorted + val n = sorted.length + def pct(p: Double): Long = sorted(math.min(n - 1, math.round(p * (n - 1)).toInt)) + val rps = n.toDouble / windowSeconds + val p95 = pct(0.95) + val littlesLawConns = math.ceil(rps * p95 / 1000.0).toInt + logger.info( + f"[req-probe] host=$host n=$n rps=$rps%.1f/s " + + f"p50=${pct(0.50)}ms p95=$p95%dms p99=${pct(0.99)}ms max=${sorted(n - 1)}ms " + + f"mean=${sorted.sum.toDouble / n}%.0fms => max-connections to sustain this rate @p95 >= $littlesLawConns") + } + } +} diff --git a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala index c1cb884..9cef23f 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/tibiadata/TibiaDataClient.scala @@ -26,6 +26,25 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with private val characterUrl = "https://api.tibiadata.com/v4/character/" private val guildUrl = "https://api.tibiadata.com/v4/guild/" + // Start the shared latency/throughput probe (idempotent across the per-world clients). + RequestMetrics.ensureReporting(system) + + /** Single chokepoint for every outbound request: issues it and records the + * round-trip latency (bucketed by host) into the shared RequestMetrics probe, + * without otherwise changing behaviour. */ + private def timedRequest(req: HttpRequest): Future[HttpResponse] = { + val start = System.nanoTime() + val response = Http().singleRequest(req) + // Record only on success: a failed singleRequest (timeout, or a near-instant + // max-open-requests pool rejection) would otherwise skew the latency + // percentiles the probe exists to produce. + response.onComplete { + case scala.util.Success(_) => RequestMetrics.record(req.uri.authority.host.address(), (System.nanoTime() - start) / 1000000L) + case _ => () + } + response + } + /** Shared recovery for an Unmarshal failure across every endpoint. On a * non-JSON response (UnsupportedContentType) the spray-json unmarshaller * rejects on the content-type check before reading the body, so the entity @@ -53,7 +72,7 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with private def fetch[T](uri: String, contentTypeMessage: HttpResponse => String, parseMessage: => String) (implicit um: akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller[T]): Future[Either[String, T]] = for { - response <- Http().singleRequest(HttpRequest(uri = uri)) + response <- timedRequest(HttpRequest(uri = uri)) decoded = decodeResponse(response) unmarshalled <- Unmarshal(decoded).to[T].map(Right(_)) .recover(recoverUnmarshal(decoded, contentTypeMessage(response), parseMessage)) @@ -153,12 +172,12 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with def getCharacter(name: String): Future[Either[String, CharacterResponse]] = { val encodedName = URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") - fetchCharacterCached(name, Http().singleRequest(HttpRequest(uri = s"$characterUrl$encodedName"))) + fetchCharacterCached(name, timedRequest(HttpRequest(uri = s"$characterUrl$encodedName"))) } def getKillerFallback(name: String): Future[Either[String, CharacterResponse]] = { val encodedName = URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") - val responseFuture = Http().singleRequest(HttpRequest(uri = s"$characterUrl$encodedName")) + val responseFuture = timedRequest(HttpRequest(uri = s"$characterUrl$encodedName")) responseFuture.flatMap { response => response.header[DateHeader] match { case Some(_) => @@ -187,7 +206,7 @@ class TibiaDataClient(implicit val system: ActorSystem) extends JsonSupport with } else { encodedName } - fetchCharacterCached(name, Http().singleRequest(HttpRequest(uri = s"$apiUrl$bypassName"))) + fetchCharacterCached(name, timedRequest(HttpRequest(uri = s"$apiUrl$bypassName"))) } def getCharacterWithInput(input: (String, String, String)): Future[(Either[String, CharacterResponse], String, String, String)] = { diff --git a/tibia-bot/src/test/scala/com/tibiabot/AkkaPoolConfigSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/AkkaPoolConfigSpec.scala new file mode 100644 index 0000000..f23814a --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/AkkaPoolConfigSpec.scala @@ -0,0 +1,37 @@ +package com.tibiabot + +import akka.actor.ActorSystem +import akka.http.scaladsl.settings.ConnectionPoolSettings +import com.typesafe.config.ConfigFactory +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** Guards the per-host pool split in akka.conf. The per-host-override syntax is + * HOCON parsed at runtime, so this confirms akka-http actually ACCEPTS the block + * (ConnectionPoolSettings construction throws on a malformed override) and that + * the intended values are present: default 16 (the rate-sensitive local instance) + * vs. the api.tibiadata.com override (the public firehose). Loads akka.conf with + * akka's reference defaults only — not discord.conf — so it is hermetic and does + * not depend on env-var substitutions. */ +class AkkaPoolConfigSpec extends AnyFunSuite with Matchers { + + test("akka.conf parses, the api.tibiadata.com per-host-override is accepted, and values are as intended") { + val cfg = ConfigFactory.parseResources("akka.conf") + .withFallback(ConfigFactory.defaultReference()) + .resolve() + val system = ActorSystem("pool-config-test", cfg) + try { + // Throws if per-host-override is structurally malformed for akka-http. + val settings = ConnectionPoolSettings(system) + settings.maxConnections shouldBe 16 // default governs the throttled local instance + + val overrides = cfg.getConfigList("akka.http.host-connection-pool.per-host-override") + overrides.size shouldBe 1 + overrides.get(0).getString("host-pattern") shouldBe "api.tibiadata.com" + overrides.get(0).getInt("max-connections") shouldBe 64 + } finally Await.result(system.terminate(), 10.seconds) + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/CacheConfigSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/CacheConfigSpec.scala new file mode 100644 index 0000000..b40c95a --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/CacheConfigSpec.scala @@ -0,0 +1,26 @@ +package com.tibiabot + +import com.typesafe.config.{ConfigFactory, ConfigResolveOptions} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.jdk.DurationConverters._ + +/** Guards the centralised `cache { }` block in discord.conf: confirms every cache + * TTL key is present, parses as a HOCON duration, and has the intended default — + * the same getDuration(...).toScala path Config.Cache uses. Hermetic: resolves + * with allowUnresolved so it doesn't need TOKEN/POSTGRES_* env substitutions. */ +class CacheConfigSpec extends AnyFunSuite with Matchers { + + private val cache = ConfigFactory.parseResources("discord.conf") + .resolve(ConfigResolveOptions.defaults().setAllowUnresolved(true)) + .getConfig("discord-config").getConfig("cache") + + test("every centralised cache TTL key is present with its expected default") { + cache.getDuration("boosted-ttl").toScala.toMinutes shouldBe 30 + cache.getDuration("highscores-ttl").toScala.toMinutes shouldBe 30 + cache.getDuration("world-list-ttl").toScala.toHours shouldBe 1 + cache.getDuration("character-snapshot-ttl").toScala.toDays shouldBe 7 + cache.getDuration("character-snapshot-interval").toScala.toSeconds shouldBe 60 + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/CharacterCachePersistenceSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/CharacterCachePersistenceSpec.scala new file mode 100644 index 0000000..a36629a --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/CharacterCachePersistenceSpec.scala @@ -0,0 +1,50 @@ +package com.tibiabot.persistence + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.time.ZonedDateTime +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +/** R1 character-cache snapshot persistence: save->load round-trips, and any + * absent / corrupt / disabled cache degrades to an empty load (best-effort). */ +class CharacterCachePersistenceSpec extends AnyFunSuite with Matchers { + + private implicit val ec: ExecutionContext = ExecutionContext.global + private def await[A](f: Future[A]): A = Await.result(f, 5.seconds) + + private class FakeCache extends RedisCache { + val store = TrieMap.empty[String, String] + def get(key: String): Future[Option[String]] = Future.successful(store.get(key)) + def setEx(key: String, value: String, ttl: FiniteDuration): Future[Unit] = { store.put(key, value); Future.unit } + def close(): Unit = () + } + + private val t1 = ZonedDateTime.parse("2026-05-31T10:00:00Z") + private val t2 = ZonedDateTime.parse("2026-05-31T11:30:00Z") + + test("save then load round-trips the snapshot") { + val cache = new FakeCache + val p = new CharacterCachePersistence(cache) + await(p.save(Map("Violent Beams" -> t1, "Bubble" -> t2))) + await(p.load()) shouldBe Map("Violent Beams" -> t1, "Bubble" -> t2) + } + + test("absent snapshot loads as empty") { + await(new CharacterCachePersistence(new FakeCache).load()) shouldBe empty + } + + test("corrupt snapshot loads as empty (best-effort)") { + val cache = new FakeCache + cache.store.put("tibia:chardate-snapshot", "}{garbage") + await(new CharacterCachePersistence(cache).load()) shouldBe empty + } + + test("disabled Redis (Noop): save is a no-op and load is empty") { + val p = new CharacterCachePersistence(NoopRedisCache) + await(p.save(Map("X" -> t1))) + await(p.load()) shouldBe empty + } +} diff --git a/tibia-bot/src/test/scala/com/tibiabot/tibiadata/CachingTibiaApiSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/CachingTibiaApiSpec.scala new file mode 100644 index 0000000..5e4d5da --- /dev/null +++ b/tibia-bot/src/test/scala/com/tibiabot/tibiadata/CachingTibiaApiSpec.scala @@ -0,0 +1,228 @@ +package com.tibiabot.tibiadata + +import com.tibiabot.persistence.{NoopRedisCache, RedisCache} +import com.tibiabot.tibiadata.response._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import spray.json._ + +import java.time.{ZoneId, ZonedDateTime} +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +/** Behaviour of the CachingTibiaApi decorator: cached endpoints serve from Redis + * on a hit, fall back to the underlying API on miss/decode-error/cache-error, + * never cache a Left, leave the death-critical character endpoints uncached, + * and key the boosted endpoints by server-save day so a pre-save value is never + * served after the save. */ +class CachingTibiaApiSpec extends AnyFunSuite with Matchers with JsonSupport { + + private implicit val ec: ExecutionContext = ExecutionContext.global + private def await[A](f: Future[A]): A = Await.result(f, 5.seconds) + + private def fixture(name: String): String = { + val is = getClass.getResourceAsStream(s"/tibiadata/$name") + require(is != null, s"missing fixture /tibiadata/$name") + try scala.io.Source.fromInputStream(is, "UTF-8").mkString finally is.close() + } + private val boosted: BoostedResponse = fixture("boostablebosses.json").parseJson.convertTo[BoostedResponse] + private val creature: CreatureResponse = fixture("creatures.json").parseJson.convertTo[CreatureResponse] + private val highscores: HighscoresResponse = fixture("highscores_antica.json").parseJson.convertTo[HighscoresResponse] + + // Fixed Berlin clock so the save-day-keyed boosted keys are deterministic. + // 2026-05-31 12:00 Berlin - 10h = 02:00 same day => save day 2026-05-31. + private def berlin(y: Int, mo: Int, d: Int, h: Int, mi: Int): ZonedDateTime = + ZonedDateTime.of(y, mo, d, h, mi, 0, 0, ZoneId.of("Europe/Berlin")) + private val fixedNow: () => ZonedDateTime = () => berlin(2026, 5, 31, 12, 0) + private val bossKey = "tibia:boostedboss:2026-05-31" + private val creatureKey = "tibia:boostedcreature:2026-05-31" + + private def mkApi(stub: TibiaApi, cache: RedisCache, + boostedTtl: FiniteDuration = 30.minutes, + highscoresTtl: FiniteDuration = 30.minutes, + now: () => ZonedDateTime = fixedNow): CachingTibiaApi = + new CachingTibiaApi(stub, cache, boostedTtl, highscoresTtl, now) + + /** In-memory RedisCache with call counters; preset seeds values, lastTtl records + * the TTL handed to setEx per key, failGet simulates a Redis error. */ + private class FakeCache(preset: Map[String, String] = Map.empty, failGet: Boolean = false) extends RedisCache { + val store = TrieMap.empty[String, String] ++ preset + var gets = 0 + var sets = 0 + var lastTtl = Map.empty[String, FiniteDuration] + def get(key: String): Future[Option[String]] = { + gets += 1 + if (failGet) Future.failed(new RuntimeException("redis down")) else Future.successful(store.get(key)) + } + def setEx(key: String, value: String, ttl: FiniteDuration): Future[Unit] = { + sets += 1; lastTtl += (key -> ttl); store.put(key, value); Future.unit + } + def close(): Unit = () + } + + /** Stub TibiaApi counting calls; highscores can vary its result per (world,page). */ + private class StubApi( + boss: Either[String, BoostedResponse] = Right(boosted), + creatureResp: Either[String, CreatureResponse] = Right(creature), + hs: (String, Int) => Either[String, HighscoresResponse] = (_, _) => Right(highscores) + ) extends TibiaApi { + var bossCalls = 0 + var creatureCalls = 0 + var charCalls = 0 + var hsCalls = 0 + def getBoostedBoss(): Future[Either[String, BoostedResponse]] = { bossCalls += 1; Future.successful(boss) } + def getBoostedCreature(): Future[Either[String, CreatureResponse]] = { creatureCalls += 1; Future.successful(creatureResp) } + def getHighscores(world: String, page: Int): Future[Either[String, HighscoresResponse]] = { hsCalls += 1; Future.successful(hs(world, page)) } + def getCharacter(name: String): Future[Either[String, CharacterResponse]] = { charCalls += 1; Future.successful(Left("char")) } + // unused in these tests + def getWorld(world: String) = Future.successful(Left("x")) + def getWorlds() = Future.successful(Left("x")) + def getGuild(guild: String) = Future.successful(Left("x")) + def getGuildWithInput(input: (String, String)) = Future.successful((Left("x"), input._1, input._2)) + def getKillerFallback(name: String) = Future.successful(Left("x")) + def getCharacterV2(input: (String, Int)) = Future.successful(Left("x")) + def getCharacterWithInput(input: (String, String, String)) = Future.successful((Left("x"), input._1, input._2, input._3)) + } + + // --- core hit/miss/fallback behaviour --- + + test("miss: calls underlying once and stores the result under the save-day key") { + val stub = new StubApi(); val cache = new FakeCache() + await(mkApi(stub, cache).getBoostedBoss()) shouldBe Right(boosted) + stub.bossCalls shouldBe 1 + cache.sets shouldBe 1 + cache.store should contain key bossKey + } + + test("hit: second call is served from cache without touching underlying") { + val stub = new StubApi(); val cache = new FakeCache() + val api = mkApi(stub, cache) + await(api.getBoostedBoss()) + await(api.getBoostedBoss()) shouldBe Right(boosted) + stub.bossCalls shouldBe 1 // not called the second time + } + + test("corrupt cache value: falls back to underlying and re-stores") { + val stub = new StubApi(); val cache = new FakeCache(preset = Map(bossKey -> "}{not json")) + await(mkApi(stub, cache).getBoostedBoss()) shouldBe Right(boosted) + stub.bossCalls shouldBe 1 + cache.store(bossKey) should not be "}{not json" // overwritten with valid json + } + + test("cache get failure: degrades to underlying instead of failing") { + val stub = new StubApi(); val cache = new FakeCache(failGet = true) + await(mkApi(stub, cache).getBoostedBoss()) shouldBe Right(boosted) + stub.bossCalls shouldBe 1 + } + + test("Left results are not cached") { + val stub = new StubApi(boss = Left("api error")); val cache = new FakeCache() + await(mkApi(stub, cache).getBoostedBoss()) shouldBe Left("api error") + cache.sets shouldBe 0 + cache.store should not contain key(bossKey) + } + + test("character endpoint is never cached (death-critical passthrough)") { + val stub = new StubApi(); val cache = new FakeCache() + val api = mkApi(stub, cache) + await(api.getCharacter("Violent Beams")) + await(api.getCharacter("Violent Beams")) + stub.charCalls shouldBe 2 // every call hits underlying + cache.gets shouldBe 0 // and the cache is never consulted + } + + // --- server-save day keying (stale-after-save protection) --- + + test("server-save day roll: a pre-save cached boosted value is not served after the save") { + val cache = new FakeCache() + val preSaveStub = new StubApi() + val postSaveStub = new StubApi() + // 09:00 Berlin -10h => save day 2026-05-30 (before the 10:00 save) + val preSave = mkApi(preSaveStub, cache, now = () => berlin(2026, 5, 31, 9, 0)) + // 10:01 Berlin -10h => save day 2026-05-31 (after the save) + val postSave = mkApi(postSaveStub, cache, now = () => berlin(2026, 5, 31, 10, 1)) + + await(preSave.getBoostedBoss()) // writes tibia:boostedboss:2026-05-30 + await(postSave.getBoostedBoss()) // different key => miss => underlying called again + + postSaveStub.bossCalls shouldBe 1 + cache.store.keySet should contain ("tibia:boostedboss:2026-05-30") + cache.store.keySet should contain ("tibia:boostedboss:2026-05-31") + } + + // --- round-trip serialization for the other cached types --- + + test("CreatureResponse round-trips losslessly through the cache serialization") { + creature.toJson.compactPrint.parseJson.convertTo[CreatureResponse] shouldBe creature + } + + test("HighscoresResponse round-trips losslessly through the cache serialization") { + highscores.toJson.compactPrint.parseJson.convertTo[HighscoresResponse] shouldBe highscores + } + + test("HighscoresResponse with highscore_list = None survives the round-trip") { + val none = highscores.copy(highscores = highscores.highscores.copy(highscore_list = None)) + none.toJson.compactPrint.parseJson.convertTo[HighscoresResponse] shouldBe none + } + + test("boosted creature is served from cache on the second call") { + val stub = new StubApi(); val cache = new FakeCache() + val api = mkApi(stub, cache) + await(api.getBoostedCreature()) shouldBe Right(creature) + await(api.getBoostedCreature()) shouldBe Right(creature) + stub.creatureCalls shouldBe 1 + cache.store should contain key creatureKey + } + + // --- highscores composite key: distinctness + case-insensitive collapse --- + + test("highscores keys distinct (world,page) tuples") { + val stub = new StubApi(); val cache = new FakeCache() + val api = mkApi(stub, cache) + await(api.getHighscores("Antica", 1)) + await(api.getHighscores("Antica", 2)) + stub.hsCalls shouldBe 2 + cache.store.keySet should contain ("tibia:highscores:antica:1") + cache.store.keySet should contain ("tibia:highscores:antica:2") + } + + test("highscores key collapses case: 'Antica' and 'antica' share one entry") { + val stub = new StubApi(); val cache = new FakeCache() + val api = mkApi(stub, cache) + await(api.getHighscores("Antica", 1)) + await(api.getHighscores("antica", 1)) // cache hit + stub.hsCalls shouldBe 1 + cache.store.keySet should contain ("tibia:highscores:antica:1") + cache.store.keySet should not contain "tibia:highscores:Antica:1" + } + + test("highscores Left is not cached") { + val stub = new StubApi(hs = (_, _) => Left("api error")); val cache = new FakeCache() + await(mkApi(stub, cache).getHighscores("Antica", 1)) shouldBe Left("api error") + cache.store.keySet.exists(_.startsWith("tibia:highscores:")) shouldBe false + } + + // --- per-endpoint TTLs are threaded correctly (not crossed/hardcoded) --- + + test("each endpoint stores under its own configured TTL") { + val stub = new StubApi(); val cache = new FakeCache() + val api = mkApi(stub, cache, boostedTtl = 7.minutes, highscoresTtl = 11.minutes) + await(api.getBoostedBoss()) + await(api.getBoostedCreature()) + await(api.getHighscores("Antica", 1)) + cache.lastTtl(bossKey) shouldBe 7.minutes + cache.lastTtl(creatureKey) shouldBe 7.minutes + cache.lastTtl("tibia:highscores:antica:1") shouldBe 11.minutes + } + + // --- disabled Redis (NoopRedisCache) is a pure pass-through --- + + test("disabled Redis (Noop): every call falls through, nothing is memoized") { + val stub = new StubApi() + val api = mkApi(stub, NoopRedisCache) + await(api.getBoostedBoss()) shouldBe Right(boosted) + await(api.getBoostedBoss()) shouldBe Right(boosted) + stub.bossCalls shouldBe 2 // Noop get always misses, so underlying is hit every time + } +} From 1ab9e876f0147cc9f12749050dc861cbf256bd02 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 03:39:17 +1000 Subject: [PATCH 07/21] Update docker-compose.yml --- docker-compose.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 1d1bb51..cb1bfa9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: bot: image: violent-bot-dedicated:latest env_file: .env + networks: + - violentbot depends_on: redis: condition: service_healthy @@ -25,6 +27,8 @@ services: redis: image: redis:7-alpine + networks: + - violentbot # appendonly so the character-cache snapshot (and other cached keys) survive # a redis restart instead of forcing a cold re-fetch. command: ["redis-server", "--appendonly", "yes"] @@ -59,3 +63,7 @@ services: volumes: redis-data: pgdata: + +networks: + violentbot: + external: true From 660a8e4ed1eb0cc3b3b3226c3cc7e488a60d8708 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 04:22:49 +1000 Subject: [PATCH 08/21] Update SchemaInitializer.scala --- .../com/tibiabot/persistence/SchemaInitializer.scala | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala index 0a81768..287cd87 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala @@ -91,8 +91,14 @@ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends St val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'bot_cache'") val exist = result.next() if (!exist) { - statement.executeUpdate(s"CREATE DATABASE bot_cache;") - logger.info(s"Database 'bot_cache' created successfully") + try { + statement.executeUpdate("CREATE DATABASE bot_cache") + logger.info("Database 'bot_cache' created successfully") + } catch { + case e: org.postgresql.util.PSQLException + if e.getSQLState == "42P04" => // duplicate_database + logger.info("Database 'bot_cache' already exists, skipping creation") + } } statement.close() !exist From 7895dbdee5b197130c7e85735e086573fcfa459c Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 04:37:32 +1000 Subject: [PATCH 09/21] fixing some bot_cache creation issues --- .../persistence/SchemaInitializer.scala | 5 ++--- .../CacheRepositoryIntegrationSpec.scala | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala index 287cd87..e51f003 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala @@ -95,9 +95,8 @@ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends St statement.executeUpdate("CREATE DATABASE bot_cache") logger.info("Database 'bot_cache' created successfully") } catch { - case e: org.postgresql.util.PSQLException - if e.getSQLState == "42P04" => // duplicate_database - logger.info("Database 'bot_cache' already exists, skipping creation") + case e: Throwable => + logger.info("Database 'bot_cache' already exists, skipping creation", e) } } statement.close() diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala index e4d25de..cb5d597 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala @@ -83,10 +83,19 @@ class CacheRepositoryIntegrationSpec extends AnyFunSuite with Matchers with Post private def ensureCacheDatabase(provider: JdbcConnectionProvider): Unit = { val conn = provider.admin() try { - val rs = conn.createStatement().executeQuery("SELECT datname FROM pg_database WHERE datname = 'bot_cache'") - if (!rs.next()) conn.createStatement().executeUpdate("CREATE DATABASE bot_cache") - } finally conn.close() - } + val rs = conn.createStatement() + .executeQuery("SELECT datname FROM pg_database WHERE datname = 'bot_cache'") + + if (!rs.next()) { + conn.createStatement() + .executeUpdate("CREATE DATABASE bot_cache") + } + } catch { + case e: Throwable => + logger.info("Database 'bot_cache' already exists, skipping creation", e) + } finally { + conn.close() + } private def ensureTables(provider: JdbcConnectionProvider): Unit = { val conn = provider.cache() From bf3508ad3f4d04adf183a52e24507fa44ab41ce7 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 04:40:41 +1000 Subject: [PATCH 10/21] Update CacheRepositoryIntegrationSpec.scala --- .../tibiabot/persistence/CacheRepositoryIntegrationSpec.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala index cb5d597..516985d 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala @@ -96,6 +96,7 @@ class CacheRepositoryIntegrationSpec extends AnyFunSuite with Matchers with Post } finally { conn.close() } + } private def ensureTables(provider: JdbcConnectionProvider): Unit = { val conn = provider.cache() From 85e60b6b1e785b30a255c57bbe433225b342a716 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 04:49:45 +1000 Subject: [PATCH 11/21] Update CacheRepositoryIntegrationSpec.scala --- .../tibiabot/persistence/CacheRepositoryIntegrationSpec.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala index 516985d..2125b4d 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala @@ -91,8 +91,7 @@ class CacheRepositoryIntegrationSpec extends AnyFunSuite with Matchers with Post .executeUpdate("CREATE DATABASE bot_cache") } } catch { - case e: Throwable => - logger.info("Database 'bot_cache' already exists, skipping creation", e) + case _ => // } finally { conn.close() } From 74b577b95b9883ed0c26d9801c9f905663016f24 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 05:27:08 +1000 Subject: [PATCH 12/21] more fixes with bot_cache creation --- .../persistence/SchemaInitializer.scala | 93 +++++++++++-------- .../CacheRepositoryIntegrationSpec.scala | 2 +- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala index e51f003..0e93839 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala @@ -86,29 +86,40 @@ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends St } def initCache(): Unit = { - val needsTables = JdbcSupport.withConnection(connectionProvider.admin) { conn => + + val dbMissing = JdbcSupport.withConnection(connectionProvider.admin) { conn => val statement = conn.createStatement() - val result = statement.executeQuery(s"SELECT datname FROM pg_database WHERE datname = 'bot_cache'") - val exist = result.next() - if (!exist) { - try { - statement.executeUpdate("CREATE DATABASE bot_cache") - logger.info("Database 'bot_cache' created successfully") - } catch { - case e: Throwable => - logger.info("Database 'bot_cache' already exists, skipping creation", e) + + val result = statement.executeQuery( + "SELECT datname FROM pg_database WHERE datname = 'bot_cache'" + ) + + try { + val exist = result.next() + + if (!exist) { + try { + statement.executeUpdate("CREATE DATABASE bot_cache") + logger.info("Database 'bot_cache' created successfully") + } catch { + case e: Throwable => + logger.info("Database 'bot_cache' already exists, skipping creation", e) + } } + + !exist + } finally { + result.close() + statement.close() } - statement.close() - !exist } - if (needsTables) { + if (dbMissing) { JdbcSupport.withConnection(connectionProvider.cache) { newConn => val newStatement = newConn.createStatement() - // create the tables in bot_configuration + val createDeathsTable = - s"""CREATE TABLE deaths ( + s"""CREATE TABLE IF NOT EXISTS deaths ( |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, |world VARCHAR(255) NOT NULL, |name VARCHAR(255) NOT NULL, @@ -116,46 +127,50 @@ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends St |);""".stripMargin val createLevelsTable = - s"""CREATE TABLE levels ( + s"""CREATE TABLE IF NOT EXISTS levels ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |level VARCHAR(255) NOT NULL, + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createListTable = + s"""CREATE TABLE IF NOT EXISTS list ( |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, |world VARCHAR(255) NOT NULL, + |former_worlds VARCHAR(255), |name VARCHAR(255) NOT NULL, + |former_names VARCHAR(1000), |level VARCHAR(255) NOT NULL, + |guild_name VARCHAR(255), |vocation VARCHAR(255) NOT NULL, |last_login VARCHAR(255) NOT NULL, |time VARCHAR(255) NOT NULL |);""".stripMargin - val createListTable = - s"""CREATE TABLE list ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |former_worlds VARCHAR(255), - |name VARCHAR(255) NOT NULL, - |former_names VARCHAR(1000), - |level VARCHAR(255) NOT NULL, - |guild_name VARCHAR(255), - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createGalthenTable = - s"""CREATE TABLE satchel ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL, - |tag VARCHAR(255) - |);""".stripMargin + val createSatchelTable = + s"""CREATE TABLE IF NOT EXISTS satchel ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |userid VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL, + |tag VARCHAR(255) + |);""".stripMargin newStatement.executeUpdate(createDeathsTable) logger.info("Table 'deaths' created successfully") + newStatement.executeUpdate(createLevelsTable) logger.info("Table 'levels' created successfully") + newStatement.executeUpdate(createListTable) logger.info("Table 'list' created successfully") - newStatement.executeUpdate(createGalthenTable) - logger.info("Table 'galthen' created successfully") + + newStatement.executeUpdate(createSatchelTable) + logger.info("Table 'satchel' created successfully") + newStatement.close() } } diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala index 2125b4d..daf3b24 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/CacheRepositoryIntegrationSpec.scala @@ -91,7 +91,7 @@ class CacheRepositoryIntegrationSpec extends AnyFunSuite with Matchers with Post .executeUpdate("CREATE DATABASE bot_cache") } } catch { - case _ => // + case _ : Throwable => // } finally { conn.close() } From 77c91da3950aee51c803d2a2c2d5cde449848a90 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 07:21:30 +1000 Subject: [PATCH 13/21] fixing up a few more things --- tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala | 2 +- .../src/main/scala/com/tibiabot/presentation/DeathEffect.scala | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala index 64bf98e..ae531a7 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala @@ -675,7 +675,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext val embeds = charDeaths.toList.sortBy(_.death.time).map { charDeath => var notablePoke = "" val charName = charDeath.char.character.character.name - val killer = charDeath.death.killers.last.name + val killer = charDeath.death.killers.lastOption.map(_.name).getOrElse("Invalid") var context = "Died" var embedColor = 3092790 // background default var embedThumbnail = presentation.DeathEffect.thumbnail(killer).getOrElse(creatureImageUrl(killer)) diff --git a/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala index 4194608..d1ce544 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/presentation/DeathEffect.scala @@ -31,6 +31,9 @@ object DeathEffect { "death" -> resource("Death_Effect.gif"), "ice" -> resource("Ice_Explosion_Effect.gif"), "drowning" -> resource("Reaper_Effect.gif"), + "fire" -> resource("Fire.gif"), + "holy" -> resource("Holy_Effect.gif"), + "invalid" -> resource("Phantasmal_Ooze.gif"), "life drain" -> resource("Red_Sparkles_Effect.gif") ) From f420a94b1e974ea8c3fd0fe66c037ae7f628d4aa Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 07:32:46 +1000 Subject: [PATCH 14/21] Update Killers.scala --- tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala b/tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala index bea4426..04304f1 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/domain/Killers.scala @@ -11,7 +11,7 @@ object Killers { * killer names that denote an environmental death — `presentation.DeathEffect` * draws its effect-animation keys from this same vocabulary. */ val substanceSources: Set[String] = - Set("death", "earth", "energy", "fire", "ice", "holy", "a trap", "agony", "life drain", "drowning") + Set("death", "earth", "energy", "fire", "ice", "holy", "a trap", "agony", "life drain", "drowning", "invalid") /** Indefinite article ("a"/"an") for a name, chosen by its first letter. */ def article(name: String): String = From ece0717035209b80e57b16e01d61bf3229be4bc9 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 19:51:24 +1000 Subject: [PATCH 15/21] cleaning up some claude comments --- README.md | 55 +---------------------- tibia-bot/src/main/resources/akka.conf | 21 --------- tibia-bot/src/main/resources/discord.conf | 21 +++------ 3 files changed, 8 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index e657e15..16100f7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Production: - [Website](https://violentbot.xyz) - [Discord](https://discord.gg/PNnzzs4hN3) - [Patreon](https://www.patreon.com/violentbot) - + Current features include: - Online List - Levels List @@ -45,59 +45,6 @@ Supporting packages: | `domain/` | Core case classes; game-time cycles in `domain/time/`. | | `galthen/`, `boosted/`, `admin/` | Feature services extracted from `BotApp`. | -```mermaid -flowchart TB - Discord([Discord]) - - subgraph entry [Entry points] - BL["BotListener — thin event dispatcher"] - BA["BotApp — shared state + orchestration"] - TB["TibiaBot — per-world stream"] - end - - subgraph layer [commands + interactions] - RT[CommandRouter] - HD["handlers — one per slash command"] - IX["Button / Modal / Screenshot handlers"] - end - - subgraph svc [feature services] - FS["galthen · boosted · admin"] - end - - subgraph infra [infrastructure] - GW[DiscordGateway] - SN[RateLimitedSender] - ST["state/StreamState"] - PR[presentation] - TR[tracking] - SC[scheduler] - end - - subgraph data [data + external] - RP["persistence repositories"] - DB[(PostgreSQL)] - TD[tibiadata client] - WK[wiki client] - EXT{{"TibiaData v4 / Fandom"}} - end - - Discord --> BL - BL --> RT --> HD --> BA - BL --> IX --> BA - BA --> FS --> RP - BA --> ST - BA --> RP --> DB - HD --> PR - SC --> BA - TB --> TD --> EXT - BA --> WK --> EXT - TB --> ST - TB --> TR - TB --> PR - TB --> SN --> GW --> Discord -``` - **Concurrency:** one independent Akka stream per world (held by `StreamSupervisor`), all sharing a single `ActorSystem`/dispatcher and HTTP pool. Each world ticks every 60s through a back-pressured `mapAsync(1)` pipeline with a `mapAsyncUnordered(32)` diff --git a/tibia-bot/src/main/resources/akka.conf b/tibia-bot/src/main/resources/akka.conf index accf223..9878929 100644 --- a/tibia-bot/src/main/resources/akka.conf +++ b/tibia-bot/src/main/resources/akka.conf @@ -1,30 +1,9 @@ akka.http { host-connection-pool { - # akka-http keys a SEPARATE connection pool per host. The bot talks to two: - # - api.tibiadata.com : the public API (world/guild/character firehose). - # TibiaData absorbs this; we can push it hard. - # - the local instance : Config.tibiadataApi (boosted/highscores + the - # lvl>=250 bypass). CipSoft exponentially backs this - # off, so it must stay conservative ("lowkey"). - # - # This DEFAULT therefore governs the rate-sensitive local instance (and any - # other host): keep it modest. The public host gets an aggressive override - # below. Caching (boosted/highscores) already cuts the local instance's load. - # - if the local instance gets 429s/backoff, lower this (e.g. 8); max-connections = 16 - - # Queue depth: absorbs a whole 60s polling cycle's burst across all worlds - # instead of failing fast. Must be a power of two (4096 = 2^12). max-open-requests = 4096 - response-entity-subscription-timeout = 10.seconds - # Per-host override: the public API is the ~133 req/s firehose. By Little's - # law it needs ~rps * p95-latency connections to drain within the 60s cycle; - # 16 cannot (16 conns sustain ~16/latency req/s). Size this from the - # RequestMetrics "[req-probe] ... max-connections >= N" log line for - # host=api.tibiadata.com (round up — see the probe's entity-drain caveat). - # 64 is a sane interim (4x) pending those real numbers. per-host-override = [ { host-pattern = "api.tibiadata.com" diff --git a/tibia-bot/src/main/resources/discord.conf b/tibia-bot/src/main/resources/discord.conf index a628440..e37b174 100644 --- a/tibia-bot/src/main/resources/discord.conf +++ b/tibia-bot/src/main/resources/discord.conf @@ -3,8 +3,7 @@ discord-config { postgres-password = ${POSTGRES_PASSWORD} postgres-host = ${POSTGRES_HOST} localapi-host = ${TIBIADATA_HOST} - // Redis cache (optional). Unset env => empty host => caching disabled (NoopRedisCache), - // so the bot runs unchanged without a Redis container. + redis-host = "" redis-host = ${?REDIS_HOST} redis-port = 6379 @@ -12,25 +11,19 @@ discord-config { redis-password = "" redis-password = ${?REDIS_PASSWORD} - // ── Cache freshness — ONE place to tune how long cached TibiaData API - // responses are reused before re-fetching. HOCON duration units: s, m, h, d - // (e.g. 30m, 1h, 7d). Each is overridable by the matching CACHE_* env var. - // NOTE: these are the API-response cache TTLs only. Behavioural dedup / - // notification windows (death/level/online retention, DB cache cleanup) are - // deliberately NOT here — they change what gets posted and several are - // coupled pairs; they live in TibiaBot / JdbcCacheRepository. cache { - boosted-ttl = 30m # boosted boss + creature (Redis) + boosted-ttl = 30m boosted-ttl = ${?CACHE_BOOSTED_TTL} - highscores-ttl = 30m # /leaderboards highscores (Redis) + highscores-ttl = 30m highscores-ttl = ${?CACHE_HIGHSCORES_TTL} - world-list-ttl = 1h # world list, in-memory (changes only at game updates) + world-list-ttl = 1h world-list-ttl = ${?CACHE_WORLD_LIST_TTL} - character-snapshot-ttl = 7d # R1 character-cache snapshot self-eviction (Redis) + character-snapshot-ttl = 7d character-snapshot-ttl = ${?CACHE_CHARACTER_SNAPSHOT_TTL} - character-snapshot-interval = 60s # how often that snapshot is written + character-snapshot-interval = 60s character-snapshot-interval = ${?CACHE_CHARACTER_SNAPSHOT_INTERVAL} } + avatar-url = "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Violent%20Bot.png" fullbless-avatar-url = "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/Amulet%20of%20Loss.png" namechange-thumbnail = "https://raw.githubusercontent.com/Leo32onGIT/tibia-bot-resources/main/namechange.png" From 77f736b449205fbb1bb23df7aae78c71671ed6d6 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 20:31:25 +1000 Subject: [PATCH 16/21] Update README.md --- README.md | 119 +++++++++++++++++++++++++----------------------------- 1 file changed, 55 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 16100f7..d5f884d 100644 --- a/README.md +++ b/README.md @@ -83,30 +83,10 @@ flowchart TB WN -.-> AS ``` -The N world streams run concurrently on the shared dispatcher and HTTP pool; the only +The world streams run concurrently on the shared dispatcher and HTTP pool; the only points they contend on are `StreamState` (serialised writes) and the JDA rate limiter (outbound sends). Startup staggers stream launches by ~5.5s so they don't all poll at once. -## Building & Testing - -The project targets Java 8 and builds with sbt. If you don't have a JDK 8 / sbt -toolchain locally, build and test in Docker: - -```bash -docker run --rm -u "$(id -u):$(id -g)" -e HOME=/cache \ - -v "$HOME/.cache/tibiabot-build:/cache" -v "$PWD:/work" -w /work/tibia-bot \ - sbtscala/scala-sbt:eclipse-temurin-8u352-b08_1.8.2_2.13.10 sbt -batch test -``` - -Tests are hermetic by default: - -- **Unit tests** cover the pure logic (routing, permissions, embed builders, trackers, - schedule decisions, the rate-limited sender). -- **Decoder tests** parse frozen real TibiaData `/v4` responses - (`src/test/resources/tibiadata/`) with the production JSON formats, locking the API contract. -- **Postgres integration tests** self-cancel unless a database is provided; to run them, - add `--network -e PGHOST= -e PGPASSWORD=` to the command above. - ## Pre-requisites: #### Create the new bot in Discord @@ -114,7 +94,7 @@ Tests are hermetic by default: 2. Go to the **Bot** tab and click on **Add Bot**. 3. Click **Reset Token** & take note of the `Token` that is generated. -#### Custom Emojis and Poke Roles +#### Custom Emojis The bot is configured to point to emojis in _my_ discord server. You will need to change this to point to your emojis. @@ -124,73 +104,84 @@ You will need to change this to point to your emojis. #### Prepare your machine to host the bot 1. Ensure `docker` (with the **Compose** plugin) is installed. -2. Ensure you can build the bot image — either `sbt` + `Java JDK 8` locally, or use - the dockerized build shown below. - -Redis and (optionally) Postgres are provided by `docker-compose.yml`, so you no -longer need to pull or run them by hand. +2. Ensure you can build the bot image — either `sbt` + `Java JDK 8` locally -## Running with Docker Compose +## Deployment Steps +**Config and start the Postgres database first:** -The repository ships a `docker-compose.yml` that runs the bot together with a -Redis cache and, optionally, a bundled Postgres. +1. Create a `.env` file and fill out it out: -1. **Build the bot image** (tags `violent-bot-dedicated:latest`): + ```env + TOKEN=XXXXXXXXXXXXXXXXXXXXXX + POSTGRES_HOST=sqlhost + POSTGRES_PASSWORD=XXXXXXXXXX + TIBIADATA_HOST=https://api.tibiadata.com/ + REDIS_HOST=redis + REDIS_PORT=6379 + REDIS_PASSWORD=XXXXXXXXXXXX + ``` +2. Create the docker volume for the postgres database: ```bash - sbt docker:publishLocal + docker volume create --name pgdata ``` - - No local sbt? Stage the image with the dockerized build, then `docker build`: +3. Create the docker network for the `postgres database` and `violent bot` to communicate over: ```bash - docker run --rm -u "$(id -u):$(id -g)" -e HOME=/cache \ - -v "$HOME/.cache/tibiabot-build:/cache" -v "$PWD:/work" -w /work/tibia-bot \ - sbtscala/scala-sbt:eclipse-temurin-8u352-b08_1.8.2_2.13.10 sbt -batch docker:stage - docker build -t violent-bot-dedicated:latest tibia-bot/target/docker/stage + docker network create violentbot ``` - -2. **Create your `.env`** from the template and fill it in: +4. Run the postgres docker image: ```bash - cp .env.example .env + docker run --rm -d -t --env-file prod.env --hostname sqlhost --network=violentbot --name postgres -p 5432:5432 -v pgdata:/var/lib/postgresql postgres ``` -3. **Start the stack** — pick the database mode: +The repository ships a `docker-compose.yml` that runs the bot together with a +Redis cache. - - **Bundled Postgres** (self-contained — keep `POSTGRES_HOST=postgres` in `.env`): +8. **Build the bot image** (tags `violent-bot-dedicated:latest`): - ```bash - docker compose --profile local-db up -d - ``` + ```bash + sbt docker:publishLocal + ``` +
⚠️ No local sbt?Stage the image with the dockerized build, then `docker build`: + + ```bash + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/cache \ + -v "$HOME/.cache/tibiabot-build:/cache" -v "$PWD:/work" -w /work/tibia-bot \ + sbtscala/scala-sbt:eclipse-temurin-8u352-b08_1.8.2_2.13.10 sbt -batch docker:stage + docker build -t violent-bot-dedicated:latest tibia-bot/target/docker/stage + ``` +
- - **Pre-existing / external Postgres** (set `POSTGRES_HOST` to your server in - `.env`, no profile): +9. **Start the stack**: ```bash docker compose up -d ``` + +To run **without** caching, unset `REDIS_HOST=` in `.env`. -Redis starts in both modes. To run **without** caching, set `REDIS_HOST=` (empty) -in `.env`; the bot then ignores the redis container. - -### Connecting to a pre-existing Postgres - -Leave the `local-db` profile off and point `POSTGRES_HOST` at your database host -or IP. The bot connects as the `postgres` user with `POSTGRES_PASSWORD` and creates -its own databases on first run. It always uses **port 5432**, so your database must -listen there. +## Building & Testing -> With the bundled Postgres, the bot may log a few connection errors and restart -> while Postgres initialises on first boot — this is expected and self-resolves. +The project targets Java 8 and builds with sbt. If you don't have a JDK 8 / sbt +toolchain locally, build and test in Docker: -### Manual (without Compose) +```bash +docker run --rm -u "$(id -u):$(id -g)" -e HOME=/cache \ + -v "$HOME/.cache/tibiabot-build:/cache" -v "$PWD:/work" -w /work/tibia-bot \ + sbtscala/scala-sbt:eclipse-temurin-8u352-b08_1.8.2_2.13.10 sbt -batch test +``` -The original `docker run` flow still works: create a `violentbot` network, run a -`postgres` container, a `redis:7-alpine` container (with `--appendonly yes`), and -the `violent-bot-dedicated` image, each with `--env-file .env`. The -`docker-compose.yml` is the source of truth for the exact images and settings. +Tests are hermetic by default: +- **Unit tests** cover the pure logic (routing, permissions, embed builders, trackers, + schedule decisions, the rate-limited sender). +- **Decoder tests** parse frozen real TibiaData `/v4` responses + (`src/test/resources/tibiadata/`) with the production JSON formats, locking the API contract. +- **Postgres integration tests** self-cancel unless a database is provided; to run them, + add `--network -e PGHOST= -e PGPASSWORD=` to the command above. + ## Debugging 1. Tail the bot logs: `docker compose logs -f bot` (errors are usually self-explanatory). From 2f6c8fdbf86a1a703345eb118b493daa6fa812f4 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 20:51:37 +1000 Subject: [PATCH 17/21] Update SchemaInitializerIntegrationSpec.scala --- .../persistence/SchemaInitializerIntegrationSpec.scala | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala index a737b20..40f6fa1 100644 --- a/tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala +++ b/tibia-bot/src/test/scala/com/tibiabot/persistence/SchemaInitializerIntegrationSpec.scala @@ -14,7 +14,12 @@ class SchemaInitializerIntegrationSpec extends AnyFunSuite with Matchers with Po new SchemaInitializer(provider).initCache() val conn = provider.cache() try { - Seq("deaths", "levels", "list", "satchel").foreach(t => hasTable(conn, t) shouldBe true) + Seq("deaths", "levels", "list", "satchel") + .foreach { t => + withClue(s"table=$t ") { + hasTable(conn, t) shouldBe true + } + } } finally conn.close() } From e058d2b84bdedfb01973339cb1033d4d59d0e567 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 21:02:41 +1000 Subject: [PATCH 18/21] Update SchemaInitializer.scala --- .../persistence/SchemaInitializer.scala | 121 +++++++++--------- 1 file changed, 58 insertions(+), 63 deletions(-) diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala index 0e93839..8bc5d88 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala @@ -87,7 +87,7 @@ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends St def initCache(): Unit = { - val dbMissing = JdbcSupport.withConnection(connectionProvider.admin) { conn => + JdbcSupport.withConnection(connectionProvider.admin) { conn => val statement = conn.createStatement() val result = statement.executeQuery( @@ -106,73 +106,68 @@ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends St logger.info("Database 'bot_cache' already exists, skipping creation", e) } } - - !exist } finally { result.close() statement.close() } } - - if (dbMissing) { - JdbcSupport.withConnection(connectionProvider.cache) { newConn => - val newStatement = newConn.createStatement() - - val createDeathsTable = - s"""CREATE TABLE IF NOT EXISTS deaths ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createLevelsTable = - s"""CREATE TABLE IF NOT EXISTS levels ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |name VARCHAR(255) NOT NULL, - |level VARCHAR(255) NOT NULL, - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createListTable = - s"""CREATE TABLE IF NOT EXISTS list ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |world VARCHAR(255) NOT NULL, - |former_worlds VARCHAR(255), - |name VARCHAR(255) NOT NULL, - |former_names VARCHAR(1000), - |level VARCHAR(255) NOT NULL, - |guild_name VARCHAR(255), - |vocation VARCHAR(255) NOT NULL, - |last_login VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL - |);""".stripMargin - - val createSatchelTable = - s"""CREATE TABLE IF NOT EXISTS satchel ( - |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - |userid VARCHAR(255) NOT NULL, - |time VARCHAR(255) NOT NULL, - |tag VARCHAR(255) - |);""".stripMargin - - newStatement.executeUpdate(createDeathsTable) - logger.info("Table 'deaths' created successfully") - - newStatement.executeUpdate(createLevelsTable) - logger.info("Table 'levels' created successfully") - - newStatement.executeUpdate(createListTable) - logger.info("Table 'list' created successfully") - - newStatement.executeUpdate(createSatchelTable) - logger.info("Table 'satchel' created successfully") - - newStatement.close() - } + JdbcSupport.withConnection(connectionProvider.cache) { newConn => + val newStatement = newConn.createStatement() + + val createDeathsTable = + s"""CREATE TABLE IF NOT EXISTS deaths ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createLevelsTable = + s"""CREATE TABLE IF NOT EXISTS levels ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |name VARCHAR(255) NOT NULL, + |level VARCHAR(255) NOT NULL, + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createListTable = + s"""CREATE TABLE IF NOT EXISTS list ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |world VARCHAR(255) NOT NULL, + |former_worlds VARCHAR(255), + |name VARCHAR(255) NOT NULL, + |former_names VARCHAR(1000), + |level VARCHAR(255) NOT NULL, + |guild_name VARCHAR(255), + |vocation VARCHAR(255) NOT NULL, + |last_login VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL + |);""".stripMargin + + val createSatchelTable = + s"""CREATE TABLE IF NOT EXISTS satchel ( + |id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + |userid VARCHAR(255) NOT NULL, + |time VARCHAR(255) NOT NULL, + |tag VARCHAR(255) + |);""".stripMargin + + newStatement.executeUpdate(createDeathsTable) + logger.info("Table 'deaths' created successfully") + + newStatement.executeUpdate(createLevelsTable) + logger.info("Table 'levels' created successfully") + + newStatement.executeUpdate(createListTable) + logger.info("Table 'list' created successfully") + + newStatement.executeUpdate(createSatchelTable) + logger.info("Table 'satchel' created successfully") + + newStatement.close() } } From d04fcceec1e998d76ff95ade23eca777db313b73 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Mon, 1 Jun 2026 23:12:31 +1000 Subject: [PATCH 19/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d5f884d..238d839 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ You will need to change this to point to your emojis. 4. Run the postgres docker image: ```bash - docker run --rm -d -t --env-file prod.env --hostname sqlhost --network=violentbot --name postgres -p 5432:5432 -v pgdata:/var/lib/postgresql postgres + docker run --rm -d -t --env-file .env --hostname sqlhost --network=violentbot --name postgres -p 5432:5432 -v pgdata:/var/lib/postgresql postgres ``` The repository ships a `docker-compose.yml` that runs the bot together with a From 339012f7addbd8e53bb862bbe2c2a0649c4c84e5 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Tue, 2 Jun 2026 00:50:50 +1000 Subject: [PATCH 20/21] Update TibiaBot.scala --- tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala index ae531a7..937fab0 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/TibiaBot.scala @@ -116,7 +116,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext recentOnline.addAll(online.map(player => CharKey(player.name, now))) // cache bypass for Seanera - if (worldResponse.world.name == "Quidera" || worldResponse.world.name == "Runera") { + if (worldResponse.world.name == "Victoris" || worldResponse.world.name == "Runera") { // Remove existing online chars from the list... recentOnlineBypass.filterInPlace { i => !online.exists(player => player.name == i.char) @@ -137,7 +137,7 @@ class TibiaBot(world: String)(implicit system: ActorSystem, ex: ExecutionContext .map(_.toSet) } case Left(warning) => - if (world == "Quidera" || world == "Runera") { + if (world == "Victoris" || world == "Runera") { // use data from previous online list check val charsToCheck: Set[String] = recentOnlineBypass.map(_.char).toSet Source(charsToCheck) From 94f5c988e05c3e06ff1823b5aa67347a9ba40c70 Mon Sep 17 00:00:00 2001 From: Violent Beams Date: Tue, 2 Jun 2026 01:05:31 +1000 Subject: [PATCH 21/21] removing the boosted creature/boss ci test --- .../persistence/SchemaInitializer.scala | 8 ++--- .../BoostedRepositoryIntegrationSpec.scala | 36 ------------------- 2 files changed, 4 insertions(+), 40 deletions(-) delete mode 100644 tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala diff --git a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala index 8bc5d88..26b097e 100644 --- a/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala +++ b/tibia-bot/src/main/scala/com/tibiabot/persistence/SchemaInitializer.scala @@ -156,16 +156,16 @@ final class SchemaInitializer(connectionProvider: ConnectionProvider) extends St |);""".stripMargin newStatement.executeUpdate(createDeathsTable) - logger.info("Table 'deaths' created successfully") + //logger.info("Table 'deaths' created successfully") newStatement.executeUpdate(createLevelsTable) - logger.info("Table 'levels' created successfully") + //logger.info("Table 'levels' created successfully") newStatement.executeUpdate(createListTable) - logger.info("Table 'list' created successfully") + //logger.info("Table 'list' created successfully") newStatement.executeUpdate(createSatchelTable) - logger.info("Table 'satchel' created successfully") + //logger.info("Table 'satchel' created successfully") newStatement.close() } diff --git a/tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala b/tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala deleted file mode 100644 index a19fcc0..0000000 --- a/tibia-bot/src/test/scala/com/tibiabot/persistence/BoostedRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,36 +0,0 @@ -package com.tibiabot.persistence - -import com.tibiabot.persistence.jdbc.JdbcBoostedRepository -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -/** Round-trips BoostedRepository against a real Postgres (cancels without PGHOST). */ -class BoostedRepositoryIntegrationSpec extends AnyFunSuite with Matchers with PostgresSupport { - - private val user = "itest_boosted_user" - - test("subscribe / forUser / all / unsubscribe / unsubscribeAll round-trip") { - val provider = pgOrCancel() - val repo = new JdbcBoostedRepository(provider) - - repo.unsubscribeAll(user) // clean slate (also creates the table) - - repo.subscribe(user, "Rotworm", "creature") - repo.subscribe(user, "Ferumbras", "boss") - - val mine = repo.forUser(user) - mine.map(_.boostedName) should contain allOf ("Rotworm", "Ferumbras") - mine.find(_.boostedName == "Rotworm").map(_.boostedType) shouldBe Some("creature") - repo.all().map(_.user) should contain(user) - - // ON CONFLICT (userid, name) DO NOTHING — duplicate subscribe is a no-op - repo.subscribe(user, "Rotworm", "creature") - repo.forUser(user).count(_.boostedName == "Rotworm") shouldBe 1 - - repo.unsubscribe(user, "rotworm") // case-insensitive - repo.forUser(user).map(_.boostedName) should (contain("Ferumbras") and not contain "Rotworm") - - repo.unsubscribeAll(user) - repo.forUser(user) shouldBe empty - } -}