commit 627ff396b7549bdd565ef7115e9e7bfe7604363f Author: Ksan Date: Tue Jul 7 15:18:13 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d02fbc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +always100.txt +always90.txt +.env +catchphrases.txt + + +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Lavalink ### +# Plugin jars are downloaded by the server on first start. +lavalink/plugins/ + +### Local secrets ### +.env +.env.* +*.local +secrets.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..495c7f1 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Botinator \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..d28243b --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1bd18cd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..37d1ce6 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Botinator + +A hobby Discord bot for personal use. It's a small [JDA](https://github.com/discord-jda/JDA) +bot with a few fun slash commands and music playback powered by +[Lavalink](https://github.com/lavalink-devs/Lavalink). + +> Personal project — not intended as a polished, general-purpose bot. + +## Commands + +| Command | What it does | +| --- | --- | +| `/hello` | Sanity-check greeting | +| `/say ` | Bot repeats your message | +| `/rolldice [sides]` | Roll a die (default 6 sides) | +| `/armeter` | Measures your "AR" percentage (just for laughs) | +| `/play ` | Play or queue a track/playlist by URL, or search text (YouTube) | +| `/skip` | Skip the current track | +| `/queue` | Show the queue as a paginated embed (◀/▶ buttons) | +| `/loop [queue\|song\|off]` | Loop playback. Bare `/loop` toggles looping the whole queue; `song` loops just the current track; `off` stops looping | +| `/clear` | Clear the queue (keeps the current track playing) | + +## How it works + +The bot is just a **Lavalink client** — actual audio is streamed by a separate +**Lavalink v4 server** that runs alongside it (started with Docker). YouTube support +comes from the `youtube-plugin`, configured in `lavalink/application.yml`. + +- Bot: Java + Gradle (JDA 6, `lavalink-client`) +- Music server: Lavalink v4 via `docker-compose.yml` + +## Requirements + +- A recent JDK (Java 17+; use the latest LTS if the build complains) +- Docker + Docker Compose (to run the Lavalink server) +- A Discord bot token + +## Configuration + +Copy the example env file and fill in your values: + +```bash +cp .env.example .env +``` + +| Variable | Required | Description | +| --- | --- | --- | +| `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) | +| `LAVALINK_URI` | optional | Defaults to `ws://localhost:2333` (matches `docker-compose.yml`) | +| `LAVALINK_PASSWORD` | optional | Defaults to `youshallnotpass` (matches `lavalink/application.yml`) | + +`.env` is gitignored, so your token stays local. + +## Running + +`./gradlew run` reads real environment variables — it does **not** auto-load `.env`. +So load it into your shell first. + +```bash +# 1. Start the Lavalink server (first run downloads the YouTube plugin; wait for "ready") +docker compose up -d +docker compose logs -f # wait for "Lavalink is ready to accept connections", then Ctrl+C + +# 2. Load your .env and run the bot +set -a; source .env; set +a +./gradlew run +``` + +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 `. + +## TODO — steps to get it running + +- [ ] Install a JDK (17+) and Docker +- [ ] Create a Discord application + bot, and copy the **bot token** +- [ ] Invite the bot to your server with the `bot` and `applications.commands` scopes + (and the **Connect** + **Speak** voice permissions) +- [ ] Enable Developer Mode in Discord and copy your server's **Guild ID** +- [ ] `cp .env.example .env` and fill in `BOT_TOKEN` (and `GUILD_ID`) +- [ ] Start Lavalink: `docker compose up -d` and wait until the logs say it's ready +- [ ] Load env vars: `set -a; source .env; set +a` +- [ ] Run the bot: `./gradlew run` +- [ ] In Discord, run `/hello` to confirm it responds, then `/play` from a voice channel + +## Notes + +- Queue state is kept in memory, so it resets if the bot restarts. +- YouTube occasionally breaks playback when Google changes its player. The fix is usually + bumping the `youtube-plugin` version in `lavalink/application.yml` and restarting the server. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..46edb47 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("java") + id("application") +} + +group = "dev.ksan" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() + // Lavalink client artifacts. + maven("https://maven.lavalink.dev/releases") +} + +dependencies { + implementation("net.dv8tion:JDA:6.5.0") + implementation("dev.arbjerg:lavalink-client:3.4.0") + implementation("org.slf4j:slf4j-simple:2.0.16") + + testImplementation(platform("org.junit:junit-bom:5.10.0")) + testImplementation("org.junit.jupiter:junit-jupiter") +} + +application { + mainClass.set("dev.ksan.botinator.Main") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..027e9f0 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,18 @@ +# Runs a Lavalink v4 server for the bot to connect to. +# docker compose up -d # start in background +# docker compose logs -f # watch logs +# docker compose down # stop +# +# The bot connects to ws://localhost:2333 with the password below (see .env). +services: + lavalink: + image: ghcr.io/lavalink-devs/lavalink:4 + container_name: lavalink + restart: unless-stopped + environment: + - _JAVA_OPTIONS=-Xmx1G + volumes: + - ./lavalink/application.yml:/opt/Lavalink/application.yml:ro + - ./lavalink/plugins/:/opt/Lavalink/plugins/ + ports: + - "2333:2333" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3af2a44 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Jul 06 12:13:20 CEST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lavalink/application.yml b/lavalink/application.yml new file mode 100644 index 0000000..26016fc --- /dev/null +++ b/lavalink/application.yml @@ -0,0 +1,37 @@ +# Lavalink v4 server config. Keep the password in sync with LAVALINK_PASSWORD +# in the bot's environment (.env). +server: + port: 2333 + address: 0.0.0.0 + +lavalink: + plugins: + # YouTube support was removed from Lavalink core in v4 and now lives in a + # plugin. Bump the version if playback breaks (YouTube changes often). + - dependency: "dev.lavalink.youtube:youtube-plugin:1.18.1" + snapshot: false + server: + password: "youshallnotpass" + sources: + youtube: false # handled by the plugin below, not the core source + bandcamp: true + soundcloud: true + twitch: true + vimeo: true + http: true # direct media URLs (mp3/mp4/stream links) + local: false + +plugins: + youtube: + enabled: true + allowSearch: true # enables the "ytsearch:" queries the bot falls back to + clients: + - MUSIC + - WEB + - WEBEMBEDDED + - ANDROID_VR + +logging: + level: + root: INFO + lavalink: INFO diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..46405c2 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "Botinator" + diff --git a/src/main/java/dev/ksan/botinator/Main.java b/src/main/java/dev/ksan/botinator/Main.java new file mode 100644 index 0000000..9fa3243 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/Main.java @@ -0,0 +1,114 @@ +package dev.ksan.botinator; + +import dev.arbjerg.lavalink.client.Helpers; +import dev.arbjerg.lavalink.client.LavalinkClient; +import dev.arbjerg.lavalink.client.NodeOptions; +import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener; +import dev.ksan.botinator.command.Command; +import dev.ksan.botinator.command.CommandManager; +import dev.ksan.botinator.command.commands.*; +import dev.ksan.botinator.music.MusicManager; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.MemberCachePolicy; +import net.dv8tion.jda.api.utils.cache.CacheFlag; + +import java.util.List; + +/** + * Entry point: reads config from the environment, connects to Lavalink, builds + * JDA, and wires up the commands. Command logic lives in {@link CommandManager} + * and the classes under {@code dev.ksan.botinator.command.commands}. + */ +public class Main extends ListenerAdapter { + + public static void main(String[] args) throws InterruptedException { + String token = System.getenv("BOT_TOKEN"); + if (token == null || token.isBlank()) { + System.err.println("ERROR: environment variable BOT_TOKEN is not set. " + + "Set it to your Discord bot token and try again."); + System.exit(1); + } + + // Lavalink client must be created before JDA so we can plug in the voice + // interceptor. Its node points at a running Lavalink v4 server. + LavalinkClient lavalink = new LavalinkClient(Helpers.getUserIdFromToken(token)); + lavalink.addNode(new NodeOptions.Builder() + .setName("main") + .setServerUri(getenv("LAVALINK_URI", "ws://localhost:2333")) + .setPassword(getenv("LAVALINK_PASSWORD", "youshallnotpass")) + .build()); + + MusicManager musicManager = new MusicManager(lavalink); + + List commands = List.of( + new HelloCommand(), + new SayCommand(), + new RollDiceCommand(), + new ARMeterCommand(), + new PlayCommand(musicManager), + new SkipCommand(musicManager), + new QueueCommand(musicManager), + new LoopCommand(musicManager), + new ClearCommand(musicManager) + ); + CommandManager commandManager = new CommandManager(commands); + + // Music needs the voice-state intent + cache so we can find the caller's + // voice channel; the interceptor forwards voice updates to Lavalink. + JDA jda = JDABuilder.createLight(token, GatewayIntent.GUILD_VOICE_STATES) + .enableCache(CacheFlag.VOICE_STATE) + // Cache members who are in a voice channel; without this JDA won't + // attach a voice state, so getMember().getVoiceState() stays null. + .setMemberCachePolicy(MemberCachePolicy.VOICE) + .setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(lavalink)).setAutoReconnect(true) + .addEventListeners(new Main(), commandManager) + .build(); + + // Block until the gateway connection is fully established. + jda.awaitReady(); + + registerCommands(jda, commandManager); + } + + private static void registerCommands(JDA jda, CommandManager commandManager) { + String guildId = System.getenv("GUILD_ID"); + if (guildId != null && !guildId.isBlank()) { + Guild guild = jda.getGuildById(guildId); + if (guild != null) { + // Guild commands register instantly. + guild.updateCommands() + .addCommands(commandManager.getCommandData()) + .queue(); + System.out.println("Registered " + commandManager.getCommandData().size() + + " guild command(s) for guild " + guildId); + return; + } + System.err.println("WARNING: GUILD_ID " + guildId + + " not found (is the bot a member of that guild?). Falling back to global."); + } + + // Global commands can take up to an hour to appear. + jda.updateCommands() + .addCommands(commandManager.getCommandData()) + .queue(); + System.out.println("Registered " + commandManager.getCommandData().size() + + " global command(s) (may take up to an hour to appear)."); + } + + private static String getenv(String name, String fallback) { + String value = System.getenv(name); + return (value == null || value.isBlank()) ? fallback : value; + } + + @Override + public void onReady(ReadyEvent event) { + JDA jda = event.getJDA(); + System.out.println("Bot connected and ready as " + + jda.getSelfUser().getAsTag() + " (id " + jda.getSelfUser().getId() + ")."); + } +} diff --git a/src/main/java/dev/ksan/botinator/command/Command.java b/src/main/java/dev/ksan/botinator/command/Command.java new file mode 100644 index 0000000..d885c0b --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/Command.java @@ -0,0 +1,33 @@ +package dev.ksan.botinator.command; + +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.build.SlashCommandData; + +/** + * A single slash command. Each command knows how to describe itself (name, + * description, options) and how to respond when invoked. Commands that attach + * buttons can also claim and handle their component interactions. + */ +public interface Command { + + /** Name, description and options used to register the command with Discord. */ + SlashCommandData getData(); + + /** Handle an invocation of this command. */ + void execute(SlashCommandInteractionEvent event); + + /** Convenience: the command name, used for routing. */ + default String getName() { + return getData().getName(); + } + + /** Whether this command owns the button with the given component id. */ + default boolean handlesComponent(String componentId) { + return false; + } + + /** Handle a button interaction previously claimed via {@link #handlesComponent}. */ + default void onComponent(ButtonInteractionEvent event) { + } +} diff --git a/src/main/java/dev/ksan/botinator/command/CommandManager.java b/src/main/java/dev/ksan/botinator/command/CommandManager.java new file mode 100644 index 0000000..89453f7 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/CommandManager.java @@ -0,0 +1,68 @@ +package dev.ksan.botinator.command; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Holds all commands and routes each slash-command interaction to the matching + * {@link Command}. Registering it as a JDA event listener is all Main needs to do. + */ +public class CommandManager extends ListenerAdapter { + + private final Map commands = new LinkedHashMap<>(); + + public CommandManager(List commands) { + for (Command command : commands) { + this.commands.put(command.getName(), command); + } + } + + /** The command definitions to send to Discord during registration. */ + public List getCommandData() { + List data = new ArrayList<>(); + for (Command command : commands.values()) { + data.add(command.getData()); + } + return data; + } + + @Override + public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { + Command command = commands.get(event.getName()); + if (command == null) { + return; + } + try { + command.execute(event); + } catch (Exception e) { + System.err.println("Error handling /" + event.getName() + ": " + e.getMessage()); + e.printStackTrace(); + if (!event.isAcknowledged()) { + event.reply("Something went wrong running that command.").setEphemeral(true).queue(); + } + } + } + + @Override + public void onButtonInteraction(ButtonInteractionEvent event) { + for (Command command : commands.values()) { + if (command.handlesComponent(event.getComponentId())) { + try { + command.onComponent(event); + } catch (Exception e) { + System.err.println("Error handling component " + + event.getComponentId() + ": " + e.getMessage()); + e.printStackTrace(); + } + return; + } + } + } +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/ARMeterCommand.java b/src/main/java/dev/ksan/botinator/command/commands/ARMeterCommand.java new file mode 100644 index 0000000..18ede8a --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/ARMeterCommand.java @@ -0,0 +1,58 @@ +package dev.ksan.botinator.command.commands; + +import dev.ksan.botinator.command.Command; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +// No need to know what an AR is... so please don't even ask +public class ARMeterCommand implements Command { + + private static final Set ALWAYS_100_USERS_ID = loadIDs("ALWAYS_100_USERS_FILE", "always100.txt"); + + private static final Set ALWAYS_90_USERS_ID = loadIDs("ALWAYS_90_USERS_FILE", "always90.txt"); + + + @Override + public SlashCommandData getData() { + return Commands.slash("armeter", "Measure what's your percentage of being an AR"); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + String userId = event.getUser().getId(); + int percent; + if (ALWAYS_100_USERS_ID.contains(userId)) { + percent = 100; + } else if (ALWAYS_90_USERS_ID.contains(userId)) { + percent = ThreadLocalRandom.current().nextInt(90, 101); + } else { + percent = ThreadLocalRandom.current().nextInt(0, 101); + } + event.reply(event.getUser().getAsMention() + " you have a " + percent + "% chance of being an AR!").queue(); + } + private static Set loadIDs(String envVar, String file) { + String path = System.getenv().getOrDefault(envVar, file); + + try{ + Set ids = Files.readAllLines(Path.of(path), StandardCharsets.UTF_8).stream().map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")).collect(Collectors.toSet()); + + return ids; + + } catch (IOException e) { + System.err.println("Failed to load IDs from " + file + ": " + e.getMessage()); + return Set.of(); + } + } + +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/ClearCommand.java b/src/main/java/dev/ksan/botinator/command/commands/ClearCommand.java new file mode 100644 index 0000000..0d21489 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/ClearCommand.java @@ -0,0 +1,36 @@ +package dev.ksan.botinator.command.commands; + +import dev.ksan.botinator.command.Command; +import dev.ksan.botinator.music.MusicManager; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +public class ClearCommand implements Command { + + private final MusicManager music; + + public ClearCommand(MusicManager music) { + this.music = music; + } + + @Override + public SlashCommandData getData() { + return Commands.slash("clear", "Clear the entire queue (keeps the current track playing)"); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (event.getGuild() == null) { + event.reply("This command only works in a server.").setEphemeral(true).queue(); + return; + } + + int removed = music.clearQueue(event.getGuild().getIdLong()); + if (removed == 0) { + event.reply("The queue is already empty.").setEphemeral(true).queue(); + } else { + event.reply("🗑️ Cleared **" + removed + "** track(s) from the queue.").queue(); + } + } +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/HelloCommand.java b/src/main/java/dev/ksan/botinator/command/commands/HelloCommand.java new file mode 100644 index 0000000..cdb1301 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/HelloCommand.java @@ -0,0 +1,19 @@ +package dev.ksan.botinator.command.commands; + +import dev.ksan.botinator.command.Command; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +public class HelloCommand implements Command { + + @Override + public SlashCommandData getData() { + return Commands.slash("hello", "Test command"); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + event.reply("Hello World! I'm working").queue(); + } +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/LoopCommand.java b/src/main/java/dev/ksan/botinator/command/commands/LoopCommand.java new file mode 100644 index 0000000..160f58b --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/LoopCommand.java @@ -0,0 +1,68 @@ +package dev.ksan.botinator.command.commands; + +import dev.ksan.botinator.command.Command; +import dev.ksan.botinator.music.MusicManager; +import dev.ksan.botinator.music.MusicManager.LoopMode; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +/** + * Controls how playback repeats. Bare {@code /loop} toggles looping the whole + * queue on and off; {@code /loop mode:song} loops just the current track, and + * {@code /loop mode:off} stops looping. + */ +public class LoopCommand implements Command { + + private final MusicManager manager; + + public LoopCommand(MusicManager manager) { + this.manager = manager; + } + + @Override + public SlashCommandData getData() { + return Commands.slash("loop", "Loop the queue, the current song, or turn looping off.") + .addOptions(new OptionData(OptionType.STRING, "mode", + "What to loop — leave empty to toggle the whole queue.", false) + .addChoice("queue", "queue") + .addChoice("song", "song") + .addChoice("off", "off")); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (event.getGuild() == null) { + event.reply("This command only works in a server.").setEphemeral(true).queue(); + return; + } + + long guildId = event.getGuild().getIdLong(); + OptionMapping modeOption = event.getOption("mode"); + + LoopMode mode; + if (modeOption == null) { + // Bare /loop toggles whole-queue looping. + mode = manager.toggleQueueLoop(guildId); + } else { + mode = manager.setLoopMode(guildId, switch (modeOption.getAsString()) { + case "song" -> LoopMode.SONG; + case "queue" -> LoopMode.QUEUE; + default -> LoopMode.OFF; + }); + } + + event.reply(describe(mode)).queue(); + } + + private static String describe(LoopMode mode) { + return switch (mode) { + case QUEUE -> "🔁 Now looping the **whole queue**."; + case SONG -> "🔂 Now looping the **current song**."; + case OFF -> "➡️ Looping is now **off**."; + }; + } +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/PlayCommand.java b/src/main/java/dev/ksan/botinator/command/commands/PlayCommand.java new file mode 100644 index 0000000..8e04028 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/PlayCommand.java @@ -0,0 +1,124 @@ +package dev.ksan.botinator.command.commands; + +import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler; +import dev.arbjerg.lavalink.client.Link; +import dev.arbjerg.lavalink.client.player.LoadFailed; +import dev.arbjerg.lavalink.client.player.PlaylistLoaded; +import dev.arbjerg.lavalink.client.player.SearchResult; +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.client.player.TrackLoaded; +import dev.ksan.botinator.command.Command; +import dev.ksan.botinator.music.MusicManager; +import dev.ksan.botinator.util.CatchPhrases; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +import java.util.List; + +/** + * Plays audio in the caller's voice channel via Lavalink. Accepts a direct + * media/YouTube URL, a playlist URL, or plain text (treated as a YouTube search). + * If something is already playing, the track(s) are added to the queue. + */ +public class PlayCommand implements Command { + + private final MusicManager music; + + public PlayCommand(MusicManager music) { + this.music = music; + } + + @Override + public SlashCommandData getData() { + return Commands.slash("play", "Play or queue a track, playlist, or search query") + .addOption(OptionType.STRING, "query", "A media/YouTube/playlist URL or search text", true); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (event.getGuild() == null || event.getMember() == null) { + event.reply("This command only works in a server.").setEphemeral(true).queue(); + return; + } + + GuildVoiceState voiceState = event.getMember().getVoiceState(); + AudioChannel channel = voiceState == null ? null : voiceState.getChannel(); + if (channel == null) { + event.reply("Join a voice channel first, then run /play.").setEphemeral(true).queue(); + return; + } + + // Loading a track hits the network, so acknowledge the interaction first. + event.deferReply().queue(); + + // With Lavalink, use the direct audio controller (NOT the audio manager): + // JDA only opens the gateway voice connection; Lavalink sends the audio. + event.getJDA().getDirectAudioController().connect(channel); + + long guildId = event.getGuild().getIdLong(); + String raw = event.getOption("query").getAsString().trim(); + String identifier = isUrl(raw) ? raw : "ytsearch:" + raw; + + Link link = music.getLink(guildId); + link.loadItem(identifier).subscribe(new AbstractAudioLoadResultHandler() { + @Override + public void ontrackLoaded(TrackLoaded result) { + queueSingle(result.getTrack()); + } + + @Override + public void onSearchResultLoaded(SearchResult result) { + List tracks = result.getTracks(); + if (tracks.isEmpty()) { + edit("No results for that search."); + return; + } + queueSingle(tracks.get(0)); + } + + @Override + public void onPlaylistLoaded(PlaylistLoaded result) { + List tracks = result.getTracks(); + if (tracks.isEmpty()) { + edit("That playlist was empty."); + return; + } + boolean startedNow = music.enqueueAll(guildId, tracks); + String header = "➕ Queued **" + tracks.size() + "** tracks from **" + + result.getInfo().getName() + "**"; + edit(startedNow + ? header + " — now playing **" + tracks.get(0).getInfo().getTitle() + "**" + : header); + } + + @Override + public void noMatches() { + edit("Couldn't find anything for that."); + } + + @Override + public void loadFailed(LoadFailed result) { + edit("Failed to load: " + result.getException().getMessage()); + } + + private void queueSingle(Track track) { + boolean startedNow = music.enqueue(guildId, track); + String verb = startedNow ? "▶️ Now playing: **" : "➕ Added to queue: **"; + edit(verb + track.getInfo().getTitle() + "** by " + track.getInfo().getAuthor() + "\n" + + "\n**" + CatchPhrases.getOptimistic() + "**"); + } + + private void edit(String message) { + event.getHook().editOriginal(message).queue(); + } + }); + } + + private static boolean isUrl(String s) { + return s.startsWith("http://") || s.startsWith("https://"); + } +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/QueueCommand.java b/src/main/java/dev/ksan/botinator/command/commands/QueueCommand.java new file mode 100644 index 0000000..098ab42 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/QueueCommand.java @@ -0,0 +1,183 @@ +package dev.ksan.botinator.command.commands; + +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.protocol.v4.TrackInfo; +import dev.ksan.botinator.command.Command; +import dev.ksan.botinator.music.MusicManager; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +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.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +import java.awt.Color; +import java.util.List; + +/** + * Shows the queue as a paginated embed with Prev/Next buttons — 10 tracks per + * page. The buttons re-read the live queue each press, so the view stays current. + */ +public class QueueCommand implements Command { + + private static final int PAGE_SIZE = 10; + private static final String ID_PREFIX = "queue:"; + + private final MusicManager music; + + public QueueCommand(MusicManager music) { + this.music = music; + } + + @Override + public SlashCommandData getData() { + return Commands.slash("queue", "Show the current queue."); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (event.getGuild() == null) { + event.reply("This command only works in a server.").setEphemeral(true).queue(); + return; + } + + long guildId = event.getGuild().getIdLong(); + Track current = music.getCurrent(guildId); + List upcoming = music.getQueue(guildId); + if (current == null && upcoming.isEmpty()) { + event.reply("There is nothing queued.").setEphemeral(true).queue(); + return; + } + + int totalPages = totalPages(upcoming.size()); + MusicManager.LoopMode mode = music.getLoopMode(guildId); + + event.replyEmbeds(render(current, upcoming, 0, mode)) + .addComponents(navRow(0, totalPages)) + .queue(); + } + + @Override + public boolean handlesComponent(String componentId) { + return componentId.startsWith(ID_PREFIX); + } + + @Override + public void onComponent(ButtonInteractionEvent event) { + if (event.getGuild() == null) { + event.deferEdit().queue(); + return; + } + + long guildId = event.getGuild().getIdLong(); + Track current = music.getCurrent(guildId); + List upcoming = music.getQueue(guildId); + int totalPages = totalPages(upcoming.size()); + int page = clamp(parsePage(event.getComponentId()), totalPages); + + MusicManager.LoopMode mode = music.getLoopMode(guildId); + + event.editMessageEmbeds(render(current, upcoming, page, mode)) + .setComponents(navRow(page, totalPages)) + .queue(); + } + + private MessageEmbed render(Track current, List upcoming, int page, MusicManager.LoopMode mode) { + int totalPages = totalPages(upcoming.size()); + page = clamp(page, totalPages); + EmbedBuilder eb; + if (mode == MusicManager.LoopMode.QUEUE) { + eb = new EmbedBuilder() + .setTitle("🎵 Looped Queue") + .setColor(new Color(0x5865F2)); + + } else if( mode == MusicManager.LoopMode.OFF) { + eb = new EmbedBuilder().setTitle("🎵 Looping Current Song Only") + .setColor(new Color(0x5865F2)); + } else { + eb = new EmbedBuilder() + .setTitle("🎵 Queue") + .setColor(new Color(0x5865F2)); + + } + if (current != null) { + eb.addField("Now Playing", formatLine(current), false); + } + + if (upcoming.isEmpty()) { + eb.setDescription("*Nothing else queued.*"); + } else { + int start = page * PAGE_SIZE; + int end = Math.min(start + PAGE_SIZE, upcoming.size()); + StringBuilder sb = new StringBuilder(); + for (int i = start; i < end; i++) { + sb.append('`').append(i + 1).append(".` ") + .append(formatLine(upcoming.get(i))).append('\n'); + } + eb.setDescription(sb.toString()); + } + + eb.setFooter("Page " + (page + 1) + "/" + totalPages + + " • " + upcoming.size() + " in queue • " + + formatDuration(totalMillis(upcoming)) + " total"); + return eb.build(); + } + + private static ActionRow navRow(int page, int totalPages) { + Button prev = Button.primary(ID_PREFIX + (page - 1), "◀ Prev").withDisabled(page <= 0); + Button next = Button.primary(ID_PREFIX + (page + 1), "Next ▶").withDisabled(page >= totalPages - 1); + return ActionRow.of(prev, next); + } + + private static String formatLine(Track track) { + TrackInfo info = track.getInfo(); + String title = escape(info.getTitle()); + String uri = info.getUri(); + String name = (uri != null && !uri.isBlank()) ? "[" + title + "](" + uri + ")" : "**" + title + "**"; + String length = info.isStream() ? "🔴 LIVE" : formatDuration(info.getLength()); + return name + " — " + escape(info.getAuthor()) + " `" + length + "`"; + } + + private static long totalMillis(List tracks) { + long total = 0; + for (Track track : tracks) { + if (!track.getInfo().isStream()) { + total += track.getInfo().getLength(); + } + } + return total; + } + + private static int totalPages(int queueSize) { + return Math.max(1, (int) Math.ceil(queueSize / (double) PAGE_SIZE)); + } + + private static int clamp(int page, int totalPages) { + return Math.max(0, Math.min(page, totalPages - 1)); + } + + private static int parsePage(String componentId) { + try { + return Integer.parseInt(componentId.substring(ID_PREFIX.length())); + } catch (NumberFormatException e) { + return 0; + } + } + + private static String formatDuration(long millis) { + long totalSeconds = millis / 1000; + long hours = totalSeconds / 3600; + long minutes = (totalSeconds % 3600) / 60; + long seconds = totalSeconds % 60; + return hours > 0 + ? String.format("%d:%02d:%02d", hours, minutes, seconds) + : String.format("%d:%02d", minutes, seconds); + } + + /** Keep track titles from breaking the surrounding markdown link. */ + private static String escape(String s) { + return s.replace("[", "(").replace("]", ")"); + } +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/RollDiceCommand.java b/src/main/java/dev/ksan/botinator/command/commands/RollDiceCommand.java new file mode 100644 index 0000000..2b10c56 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/RollDiceCommand.java @@ -0,0 +1,31 @@ +package dev.ksan.botinator.command.commands; + +import dev.ksan.botinator.command.Command; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +import java.util.concurrent.ThreadLocalRandom; + +public class RollDiceCommand implements Command { + + @Override + public SlashCommandData getData() { + return Commands.slash("rolldice", "Roll a die") + .addOption(OptionType.INTEGER, "sides", "Number of sides (default 6)", false); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + int sides = event.getOption("sides", 6, OptionMapping::getAsInt); + if (sides < 2) { + event.reply("A die needs at least 2 sides.").setEphemeral(true).queue(); + return; + } + int roll = ThreadLocalRandom.current().nextInt(1, sides + 1); + event.reply("🎲 You rolled a **" + roll + "** (d" + sides + ")").queue(); + } + +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/SayCommand.java b/src/main/java/dev/ksan/botinator/command/commands/SayCommand.java new file mode 100644 index 0000000..6e93d19 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/SayCommand.java @@ -0,0 +1,27 @@ +package dev.ksan.botinator.command.commands; + +import dev.ksan.botinator.command.Command; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +import java.util.Collections; + +public class SayCommand implements Command { + + @Override + public SlashCommandData getData() { + return Commands.slash("say", "Make the bot repeat your message") + .addOption(OptionType.STRING, "message", "What the bot should say", true); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + String message = event.getOption("message").getAsString(); + // Suppress mentions so /say can't be used to ping @everyone or roles. + event.reply(message) + .setAllowedMentions(Collections.emptyList()) + .queue(); + } +} diff --git a/src/main/java/dev/ksan/botinator/command/commands/SkipCommand.java b/src/main/java/dev/ksan/botinator/command/commands/SkipCommand.java new file mode 100644 index 0000000..acc5e8f --- /dev/null +++ b/src/main/java/dev/ksan/botinator/command/commands/SkipCommand.java @@ -0,0 +1,39 @@ +package dev.ksan.botinator.command.commands; + +import dev.ksan.botinator.command.Command; +import dev.ksan.botinator.music.MusicManager; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; + +public class SkipCommand implements Command { + + private final MusicManager music; + + public SkipCommand(MusicManager music) { + this.music = music; + } + + @Override + public SlashCommandData getData() { + return Commands.slash("skip", "Skip the current track"); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (event.getGuild() == null) { + event.reply("This command only works in a server.").setEphemeral(true).queue(); + return; + } + + MusicManager.SkipResult result = music.skip(event.getGuild().getIdLong()); + if (!result.somethingWasPlaying()) { + event.reply("Nothing is playing.").setEphemeral(true).queue(); + } else if (result.nowPlaying() != null) { + event.reply("⏭️ Skipped. Now playing: **" + + result.nowPlaying().getInfo().getTitle() + "**").queue(); + } else { + event.reply("⏭️ Skipped. The queue is now empty.").queue(); + } + } +} diff --git a/src/main/java/dev/ksan/botinator/music/MusicManager.java b/src/main/java/dev/ksan/botinator/music/MusicManager.java new file mode 100644 index 0000000..211b619 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/music/MusicManager.java @@ -0,0 +1,201 @@ +package dev.ksan.botinator.music; + +import dev.arbjerg.lavalink.client.LavalinkClient; +import dev.arbjerg.lavalink.client.Link; +import dev.arbjerg.lavalink.client.event.TrackEndEvent; +import dev.arbjerg.lavalink.client.player.Track; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-guild playback state: a queue of upcoming tracks plus the track currently + * playing. Starts a track immediately when idle, queues it when busy, and + * auto-advances to the next track when one finishes. + */ +public class MusicManager { + + private final LavalinkClient lavalink; + private final Map byGuild = new ConcurrentHashMap<>(); + + public MusicManager(LavalinkClient lavalink) { + this.lavalink = lavalink; + // Advance the queue whenever a track finishes on its own. + lavalink.on(TrackEndEvent.class).subscribe(this::onTrackEnd); + } + + /** A snapshot of the upcoming (not-yet-played) tracks for a guild. */ + public List getQueue(long guildId) { + GuildMusic music = byGuild.get(guildId); + if (music == null) { + return List.of(); + } + synchronized (music) { + return List.copyOf(music.queue); + } + } + + /** The track currently playing in a guild, or null if nothing is playing. */ + public Track getCurrent(long guildId) { + GuildMusic music = byGuild.get(guildId); + if (music == null) { + return null; + } + synchronized (music) { + return music.current; + } + } + + /** The Lavalink link for a guild, used by commands to load tracks. */ + public Link getLink(long guildId) { + return lavalink.getOrCreateLink(guildId); + } + + /** How playback repeats: not at all, the current song, or the whole queue. */ + public enum LoopMode { OFF, SONG, QUEUE } + + /** The guild's current loop mode (OFF if it has never played anything). */ + public LoopMode getLoopMode(long guildId) { + GuildMusic music = byGuild.get(guildId); + if (music == null) { + return LoopMode.OFF; + } + synchronized (music) { + return music.mode; + } + } + + /** Sets the guild's loop mode and returns it. */ + public LoopMode setLoopMode(long guildId, LoopMode mode) { + GuildMusic music = byGuild.computeIfAbsent(guildId, id -> new GuildMusic()); + synchronized (music) { + music.mode = mode; + return music.mode; + } + } + + /** Toggles whole-queue looping: OFF (or SONG) turns it on, QUEUE turns it off. Returns the new mode. */ + public LoopMode toggleQueueLoop(long guildId) { + GuildMusic music = byGuild.computeIfAbsent(guildId, id -> new GuildMusic()); + synchronized (music) { + music.mode = (music.mode == LoopMode.QUEUE) ? LoopMode.OFF : LoopMode.QUEUE; + return music.mode; + } + } + + /** Adds one track. Returns true if it started playing now, false if it was queued. */ + public boolean enqueue(long guildId, Track track) { + GuildMusic music = byGuild.computeIfAbsent(guildId, id -> new GuildMusic()); + synchronized (music) { + return enqueueLocked(guildId, music, track); + } + } + + /** Adds many tracks (a playlist). Returns true if the first track started playing now. */ + public boolean enqueueAll(long guildId, List tracks) { + GuildMusic music = byGuild.computeIfAbsent(guildId, id -> new GuildMusic()); + synchronized (music) { + boolean startedFirst = false; + for (int i = 0; i < tracks.size(); i++) { + boolean started = enqueueLocked(guildId, music, tracks.get(i)); + if (i == 0) { + startedFirst = started; + } + } + return startedFirst; + } + } + + /** Skips the current track and starts the next one, if any. */ + public SkipResult skip(long guildId) { + GuildMusic music = byGuild.computeIfAbsent(guildId, id -> new GuildMusic()); + synchronized (music) { + if (music.current == null) { + return new SkipResult(false, null); + } + return new SkipResult(true, advance(guildId, music, false)); + } + } + + /** Removes all upcoming tracks without stopping the current one. Returns how many were removed. */ + public int clearQueue(long guildId) { + GuildMusic music = byGuild.get(guildId); + if (music == null) { + return 0; + } + synchronized (music) { + int removed = music.queue.size(); + music.queue.clear(); + return removed; + } + } + + private boolean enqueueLocked(long guildId, GuildMusic music, Track track) { + if (music.current == null) { + music.current = track; + play(guildId, track); + return true; + } + music.queue.add(track); + return false; + } + + private void onTrackEnd(TrackEndEvent event) { + // mayStartNext is false for REPLACED/STOPPED (which our own skip/replace + // calls trigger) and true only when a track ended naturally or failed. + if (!event.getEndReason().getMayStartNext()) { + return; + } + GuildMusic music = byGuild.get(event.getGuildId()); + if (music == null) { + return; + } + synchronized (music) { + advance(event.getGuildId(), music, true); + } + } + + /** + * Moves to the next queued track (or stops if empty). Returns the new current + * track, or null. Honours the guild's loop mode: SONG replays the finished + * track when it ends on its own, QUEUE sends the finished/skipped track to the + * back so the whole queue cycles. {@code naturalEnd} is true when a track ended + * by itself and false for an explicit skip — a skip always moves on, even in + * SONG mode. + */ + private Track advance(long guildId, GuildMusic music, boolean naturalEnd) { + Track finished = music.current; + if (music.mode == LoopMode.SONG && naturalEnd && finished != null) { + play(guildId, finished); + return finished; + } + if (music.mode == LoopMode.QUEUE && finished != null) { + music.queue.add(finished); + } + Track next = music.queue.poll(); + music.current = next; + if (next != null) { + play(guildId, next); + } else { + lavalink.getOrCreateLink(guildId).createOrUpdatePlayer().stopTrack().subscribe(); + } + return next; + } + + private void play(long guildId, Track track) { + lavalink.getOrCreateLink(guildId).createOrUpdatePlayer().setTrack(track).subscribe(); + } + + /** Outcome of a skip: whether anything was playing, and the track now playing (may be null). */ + public record SkipResult(boolean somethingWasPlaying, Track nowPlaying) { + } + + private static final class GuildMusic { + private final Deque queue = new ArrayDeque<>(); + private Track current; + private LoopMode mode = LoopMode.OFF; + } +} diff --git a/src/main/java/dev/ksan/botinator/util/CatchPhrases.java b/src/main/java/dev/ksan/botinator/util/CatchPhrases.java new file mode 100644 index 0000000..18801a3 --- /dev/null +++ b/src/main/java/dev/ksan/botinator/util/CatchPhrases.java @@ -0,0 +1,67 @@ +package dev.ksan.botinator.util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Random; + +/** + * Static catalog of catchphrases, loaded from a text file at startup. + *

+ * The file path comes from the {@code CATCHPHRASES_FILE} environment variable, + * defaulting to {@code catchphrases.txt} in the working directory. One phrase per + * line; blank lines and lines starting with {@code #} are ignored. + *

+ * Getters: {@link #getOptimistic()} for a random phrase, or {@link #getOptimistic(int)} + * for a specific one by index (any int; wrapped into range). + */ +public final class CatchPhrases { + + private CatchPhrases() { + } + + // Used only if the file is missing or empty, so the getters never fail. + private static final List FALLBACK = List.of("..."); + + private static final List OPTIMISTIC = load(); + + private static List load() { + String path = System.getenv().getOrDefault("CATCHPHRASES_FILE", "catchphrases.txt"); + try { + List phrases = Files.readAllLines(Path.of(path), StandardCharsets.UTF_8).stream() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .toList(); + if (phrases.isEmpty()) { + System.err.println("CatchPhrases: '" + path + "' has no phrases; using fallback."); + return FALLBACK; + } + System.out.println("CatchPhrases: loaded " + phrases.size() + " phrase(s) from " + path); + return phrases; + } catch (IOException e) { + System.err.println("CatchPhrases: couldn't read '" + path + "' (" + + e.getMessage() + "); using fallback."); + return FALLBACK; + } + } + + /** A random optimistic catchphrase. */ + public static String getOptimistic() { + return pick(OPTIMISTIC, new Random()); + } + + /** A specific optimistic catchphrase by index (any int; wrapped into range). */ + public static String getOptimistic(int index) { + return pick(OPTIMISTIC, index); + } + + private static String pick(List phrases, Random random) { + return phrases.get(random.nextInt(phrases.size())); + } + + private static String pick(List phrases, int index) { + return phrases.get(Math.floorMod(index, phrases.size())); + } +}