Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"ebc4796c-a9b2-4ddf-8fb2-21b78f4c13bf","pid":1063296,"procStart":"3488585","acquiredAt":1780162977068}
26 changes: 23 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
193 changes: 157 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,55 +19,176 @@ 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/`. 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. |
| `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`. |

**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<br/>mapAsync(1)"]
GWp --> GC["getCharacterData<br/>mapAsyncUnordered(32)"]
GC --> SDp["scanForDeaths<br/>mapAsync(1)"]
SDp --> PDp["postToDiscord<br/>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<br/>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 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.

## Pre-requisites:

#### Create the new bot in Discord
1. Go to: https://discord.com/developers/applications and create a **New Application**.
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.

1. Upload the emojis provided in the [discord emojis](https://github.com/Leo32onGIT/tibia-bot/tree/dedicated/tibia-bot/src/main/resources/discord%20emojis) folder to your discord.
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 \<image id\> 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 <image_id>`
#### 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

## Deployment Steps
**Config and start the Postgres database first:**

1. Create a `.env` file and fill out it out:

```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
docker volume create --name pgdata
```
3. Create the docker network for the `postgres database` and `violent bot` to communicate over:

```bash
docker network create violentbot
```
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
```

The repository ships a `docker-compose.yml` that runs the bot together with a
Redis cache.

8. **Build the bot image** (tags `violent-bot-dedicated:latest`):

```bash
sbt docker:publishLocal
```
<details><summary>⚠️ No local sbt?</summary>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
```
</details>

9. **Start the stack**:

```bash
docker compose up -d
```

To run **without** caching, unset `REDIS_HOST=` in `.env`.

## 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 <pg-net> -e PGHOST=<host> -e PGPASSWORD=<pw>` to the command above.
## 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 \<container id\> for the running bot.
3. Use `docker logs <container id>` 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 <compose-network> -e 'PGADMIN_DEFAULT_EMAIL=you@example.com' -e 'PGADMIN_DEFAULT_PASSWORD=changeme' -d dpage/pgadmin4`
(find `<compose-network>` with `docker network ls`).
69 changes: 69 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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
networks:
- violentbot
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
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"]
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:

networks:
violentbot:
external: true
4 changes: 4 additions & 0 deletions tibia-bot/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
Loading
Loading