Compare commits

..

8 Commits

Author SHA1 Message Date
ksan 64a472c540 fixed readme
CI/CD / build (push) Successful in 44s
CI/CD / deploy (push) Successful in 8s
2026-07-10 01:34:33 +02:00
ksan f70a50c06f updated README and added .env example
CI/CD / build (push) Successful in 44s
CI/CD / deploy (push) Successful in 31s
2026-07-10 01:29:53 +02:00
ksan cab8f32542 fixing root permissions inside container
CI/CD / build (push) Successful in 44s
CI/CD / deploy (push) Successful in 15s
2026-07-10 01:22:46 +02:00
ksan 25cbe3bd56 fixing permission issues
CI/CD / build (push) Successful in 44s
CI/CD / deploy (push) Successful in 9s
2026-07-10 01:13:26 +02:00
ksan be360843eb fixed image url
CI/CD / build (push) Successful in 44s
CI/CD / deploy (push) Successful in 10s
2026-07-10 00:59:13 +02:00
ksan d4062f98a3 fixed path
CI/CD / build (push) Successful in 44s
CI/CD / deploy (push) Failing after 27s
2026-07-10 00:47:23 +02:00
ksan 80719eb253 fixing some stuff
CI/CD / build (push) Successful in 44s
CI/CD / deploy (push) Failing after 21s
2026-07-10 00:41:56 +02:00
ksan cbefb7b21f added ci/cd
CI/CD / build (push) Successful in 2m17s
CI/CD / deploy (push) Failing after 10s
2026-07-10 00:13:57 +02:00
9 changed files with 221 additions and 17 deletions
+13
View File
@@ -0,0 +1,13 @@
# Keep the build context small and reproducible — everything the image needs is
# rebuilt inside the Dockerfile.
.git
.gradle
build/
.idea
*.iml
.env
.env.*
lavalink/plugins/
catchphrases.txt
always100.txt
always90.txt
+23
View File
@@ -0,0 +1,23 @@
# Copy this file to `.env` and fill in your real values.
# `.env` is gitignored so your secrets stay local.
# Your Discord bot token (Developer Portal -> your app -> Bot -> Reset/Copy Token).
BOT_TOKEN=your-bot-token-here
# Numeric ID of your test server, so /hello registers instantly as a guild command.
# Enable Developer Mode in Discord, right-click the server, "Copy Server ID".
# Leave unset to register /hello globally (can take up to an hour to appear).
GUILD_ID=123456789012345678
# Lavalink server the bot connects to for music playback.
# In docker-compose the bot reaches Lavalink at ws://lavalink:2333 (set there);
# ws://localhost:2333 is the default for running the bot outside a container.
LAVALINK_URI=ws://localhost:2333
# Shared secret between the bot and the Lavalink server. docker-compose passes
# this same value to the server as LAVALINK_SERVER_PASSWORD, so the two always
# match. Set a strong value — do NOT ship the youshallnotpass default.
LAVALINK_PASSWORD=change-me
# Path to the catchphrases file (one phrase per line). Optional; defaults to
# catchphrases.txt in the working directory.
CATCHPHRASES_FILE=catchphrases.txt
+61
View File
@@ -0,0 +1,61 @@
name: CI/CD
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 25
- name: Build
run: |
chmod +x gradlew
./gradlew --no-daemon build
deploy:
runs-on: ubuntu-latest
needs: build
if: gitea.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build & push Docker image
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.${{ secrets.DOMAIN }} \
-u ${{ secrets.REGISTRY_USER }} --password-stdin
docker build -t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/botinator:latest .
docker push git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/botinator:latest
- name: Deploy to VPS
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/key
chmod 600 ~/.ssh/key
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
HOST=${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}
# Path relative to the remote user's home (ssh/scp start there). Do NOT
# use ~ here — it would expand on the runner, not on the server.
DIR=programs/botinator
# Ship the compose file and Lavalink config to the server. .env and
# catchphrases.txt live on the server and are intentionally not copied.
# Plugins live in a named Docker volume (see docker-compose.yaml), so
# only the lavalink/ dir for application.yml is needed here.
ssh -i ~/.ssh/key $HOST "mkdir -p $DIR/lavalink"
scp -i ~/.ssh/key docker-compose.yaml $HOST:"$DIR/docker-compose.yaml"
scp -i ~/.ssh/key lavalink/application.yml $HOST:"$DIR/lavalink/application.yml"
ssh -i ~/.ssh/key $HOST \
"cd $DIR && docker compose pull && docker compose up -d"
+2
View File
@@ -54,5 +54,7 @@ lavalink/plugins/
### Local secrets ### ### Local secrets ###
.env .env
.env.* .env.*
# ...but keep the committed template.
!.env.example
*.local *.local
secrets.properties secrets.properties
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
# ---- Build stage: compile and assemble a runnable distribution ----
FROM eclipse-temurin:25-jdk AS build
WORKDIR /app
# Copy the Gradle wrapper and build scripts first so dependency resolution is
# cached in its own layer and only re-runs when the build files change.
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle
RUN chmod +x gradlew && ./gradlew --no-daemon dependencies > /dev/null 2>&1 || true
# Now the source. `installDist` produces build/install/Botinator with a launcher
# script and all dependency jars under lib/.
COPY src ./src
RUN ./gradlew --no-daemon --stacktrace installDist
# ---- Runtime stage: JRE only, no build tooling ----
FROM eclipse-temurin:25-jre
WORKDIR /app
# Run as an unprivileged user.
RUN useradd --system --create-home --uid 10001 botinator
USER botinator
# The assembled app (bin/Botinator launcher + lib/*.jar).
COPY --from=build --chown=botinator:botinator /app/build/install/Botinator/ ./
# BOT_TOKEN etc. and the catchphrases file are supplied at deploy time
# (env_file / -e for config, a mounted volume for catchphrases.txt).
ENTRYPOINT ["./bin/Botinator"]
+41 -9
View File
@@ -27,11 +27,11 @@ The bot is just a **Lavalink client** — actual audio is streamed by a separate
comes from the `youtube-plugin`, configured in `lavalink/application.yml`. comes from the `youtube-plugin`, configured in `lavalink/application.yml`.
- Bot: Java + Gradle (JDA 6, `lavalink-client`) - Bot: Java + Gradle (JDA 6, `lavalink-client`)
- Music server: Lavalink v4 via `docker-compose.yml` - Music server: Lavalink v4 via `docker-compose.yaml`
## Requirements ## Requirements
- A recent JDK (Java 17+; use the latest LTS if the build complains) - A JDK (builds on Java 17+; CI and the Docker image use Java 25)
- Docker + Docker Compose (to run the Lavalink server) - Docker + Docker Compose (to run the Lavalink server)
- A Discord bot token - A Discord bot token
@@ -47,8 +47,8 @@ cp .env.example .env
| --- | --- | --- | | --- | --- | --- |
| `BOT_TOKEN` | ✅ | Discord bot token (Developer Portal → your app → Bot) | | `BOT_TOKEN` | ✅ | Discord bot token (Developer Portal → your app → Bot) |
| `GUILD_ID` | optional | Test server ID for instant command registration. If unset, commands register globally (can take up to an hour) | | `GUILD_ID` | optional | Test server ID for instant command registration. If unset, commands register globally (can take up to an hour) |
| `LAVALINK_URI` | optional | Defaults to `ws://localhost:2333` (matches `docker-compose.yml`) | | `LAVALINK_URI` | optional | Defaults to `ws://localhost:2333` for local runs. In `docker-compose.yaml` the bot is pointed at `ws://lavalink:2333` (the service name) |
| `LAVALINK_PASSWORD` | optional | Defaults to `youshallnotpass` (matches `lavalink/application.yml`) | | `LAVALINK_PASSWORD` | ✅ | Shared secret between the bot and the Lavalink server. Compose passes the same value to the server as `LAVALINK_SERVER_PASSWORD`, so they always match. The bot **exits** if it's unset |
`.env` is gitignored, so your token stays local. `.env` is gitignored, so your token stays local.
@@ -58,15 +58,20 @@ cp .env.example .env
So load it into your shell first. So load it into your shell first.
```bash ```bash
# 1. Start the Lavalink server (first run downloads the YouTube plugin; wait for "ready") # 1. Start ONLY the Lavalink server — not the bot container — so it doesn't clash
docker compose up -d # with the bot you're running from source. (First run downloads the YouTube
docker compose logs -f # wait for "Lavalink is ready to accept connections", then Ctrl+C # plugin; wait for "ready".)
docker compose up -d lavalink
docker compose logs -f lavalink # wait for "Lavalink is ready to accept connections", then Ctrl+C
# 2. Load your .env and run the bot # 2. Load your .env and run the bot
set -a; source .env; set +a set -a; source .env; set +a
./gradlew run ./gradlew run
``` ```
> `docker compose up -d` (no service name) would start the **bot container too**,
> giving you two bots. For source development, always pass `lavalink` explicitly.
When it connects you'll see `Bot connected and ready as ...` in the console. Then join a When it connects you'll see `Bot connected and ready as ...` in the console. Then join a
voice channel in your server and try `/play <song or url>`. voice channel in your server and try `/play <song or url>`.
@@ -77,12 +82,39 @@ voice channel in your server and try `/play <song or url>`.
- [ ] Invite the bot to your server with the `bot` and `applications.commands` scopes - [ ] Invite the bot to your server with the `bot` and `applications.commands` scopes
(and the **Connect** + **Speak** voice permissions) (and the **Connect** + **Speak** voice permissions)
- [ ] Enable Developer Mode in Discord and copy your server's **Guild ID** - [ ] Enable Developer Mode in Discord and copy your server's **Guild ID**
- [ ] `cp .env.example .env` and fill in `BOT_TOKEN` (and `GUILD_ID`) - [ ] `cp .env.example .env` and fill in `BOT_TOKEN`, `LAVALINK_PASSWORD` (and `GUILD_ID`)
- [ ] Start Lavalink: `docker compose up -d` and wait until the logs say it's ready - [ ] Start Lavalink only: `docker compose up -d lavalink` and wait until the logs say it's ready
- [ ] Load env vars: `set -a; source .env; set +a` - [ ] Load env vars: `set -a; source .env; set +a`
- [ ] Run the bot: `./gradlew run` - [ ] Run the bot: `./gradlew run`
- [ ] In Discord, run `/hello` to confirm it responds, then `/play` from a voice channel - [ ] In Discord, run `/hello` to confirm it responds, then `/play` from a voice channel
## Run with Docker (prebuilt image)
If you'd rather not build from source, `docker-compose.yaml` pulls the prebuilt
bot image and runs it alongside Lavalink. You only need Docker — no JDK.
1. Create your config and the files the bot reads at runtime, next to the compose file:
```bash
cp .env.example .env # then fill in BOT_TOKEN and LAVALINK_PASSWORD
```
Make sure `catchphrases.txt`, `always100.txt`, and `always90.txt` exist as
**files** here. They're mounted into the container — if any is missing, Docker
creates an empty **directory** in its place and the mount breaks.
2. Pull and start both services:
```bash
docker compose pull # fetch the bot + Lavalink images
docker compose up -d # start in the background
docker compose logs -f # watch logs; look for "Bot connected and ready as ..."
docker compose down # stop
```
The bot connects to Lavalink over the internal Docker network automatically, so
you don't set `LAVALINK_URI` yourself here.
## Notes ## Notes
- Queue state is kept in memory, so it resets if the bot restarts. - Queue state is kept in memory, so it resets if the bot restarts.
+39 -6
View File
@@ -1,18 +1,51 @@
# Runs a Lavalink v4 server for the bot to connect to. # Runs the bot together with a Lavalink v4 server for music playback.
# docker compose up -d # start in background # docker compose up -d --build # build the bot image locally and start
# docker compose logs -f # watch logs
# docker compose down # stop
# #
# The bot connects to ws://localhost:2333 with the password below (see .env).
services: services:
bot:
image: git.ksan.dev/ksan/botinator:latest
build: .
container_name: botinator
restart: unless-stopped
depends_on:
- lavalink
# BOT_TOKEN, GUILD_ID, LAVALINK_PASSWORD, ... come from .env.
env_file:
- .env
environment:
- LAVALINK_URI=ws://lavalink:2333
# Catchphrases and the AR-meter user lists are supplied at deploy time,
# not baked into the image.
volumes:
- ./catchphrases.txt:/app/catchphrases.txt:ro
- ./always100.txt:/app/always100.txt:ro
- ./always90.txt:/app/always90.txt:ro
lavalink: lavalink:
image: ghcr.io/lavalink-devs/lavalink:4 image: ghcr.io/lavalink-devs/lavalink:4
container_name: lavalink container_name: lavalink
restart: unless-stopped restart: unless-stopped
# A mounted volume at /opt/Lavalink/plugins is created root-owned (the image
# has no plugins dir for Docker to copy ownership from), but Lavalink runs as
# a non-root user and can't write there. Run as root so it can download the
# plugin jars into the volume. The container is internal-only.
user: "0:0"
env_file:
- .env
environment: environment:
- _JAVA_OPTIONS=-Xmx1G - _JAVA_OPTIONS=-Xmx1G
# Lavalink reads this for lavalink.server.password in application.yml.
# Keep it equal to LAVALINK_PASSWORD (what the bot uses to connect).
- LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD}
volumes: volumes:
- ./lavalink/application.yml:/opt/Lavalink/application.yml:ro - ./lavalink/application.yml:/opt/Lavalink/application.yml:ro
- ./lavalink/plugins/:/opt/Lavalink/plugins/ # Named volume (managed by Docker) rather than a host bind mount. Docker
# initializes it from the image, so it's owned by Lavalink's container user
# and the plugin jars download without permission errors. No root-owned (or
# UID-mismatched) plugins/ dir in the deploy directory.
- lavalink-plugins:/opt/Lavalink/plugins
ports: ports:
- "2333:2333" - "2333:2333"
volumes:
lavalink-plugins:
+3 -1
View File
@@ -11,7 +11,9 @@ lavalink:
- dependency: "dev.lavalink.youtube:youtube-plugin:1.18.1" - dependency: "dev.lavalink.youtube:youtube-plugin:1.18.1"
snapshot: false snapshot: false
server: server:
password: "youshallnotpass" # Sourced from the LAVALINK_SERVER_PASSWORD env var (set by docker-compose
# from LAVALINK_PASSWORD in .env). Required — the server won't start without it.
password: "${LAVALINK_SERVER_PASSWORD}"
sources: sources:
youtube: false # handled by the plugin below, not the core source youtube: false # handled by the plugin below, not the core source
bandcamp: true bandcamp: true
+8 -1
View File
@@ -34,13 +34,20 @@ public class Main extends ListenerAdapter {
System.exit(1); System.exit(1);
} }
String lavalinkPassword = System.getenv("LAVALINK_PASSWORD");
if (lavalinkPassword == null || lavalinkPassword.isBlank()) {
System.err.println("ERROR: environment variable LAVALINK_PASSWORD is not set. "
+ "Set it to your Lavalink server password (see .env) and try again.");
System.exit(1);
}
// Lavalink client must be created before JDA so we can plug in the voice // Lavalink client must be created before JDA so we can plug in the voice
// interceptor. Its node points at a running Lavalink v4 server. // interceptor. Its node points at a running Lavalink v4 server.
LavalinkClient lavalink = new LavalinkClient(Helpers.getUserIdFromToken(token)); LavalinkClient lavalink = new LavalinkClient(Helpers.getUserIdFromToken(token));
lavalink.addNode(new NodeOptions.Builder() lavalink.addNode(new NodeOptions.Builder()
.setName("main") .setName("main")
.setServerUri(getenv("LAVALINK_URI", "ws://localhost:2333")) .setServerUri(getenv("LAVALINK_URI", "ws://localhost:2333"))
.setPassword(getenv("LAVALINK_PASSWORD", "youshallnotpass")) .setPassword(lavalinkPassword)
.build()); .build());
MusicManager musicManager = new MusicManager(lavalink); MusicManager musicManager = new MusicManager(lavalink);