Compare commits

20 Commits
Author SHA1 Message Date
anthorob 87f78ccbea finish abstraction for StepTask 2026-08-24 17:11:51 -04:00
anthorob 5ab18ff5ce create renovate config 2026-08-24 17:11:34 -04:00
anthorob e0e9bbff10 compile error whoops 2026-08-24 17:11:07 -04:00
anthorob d0c06cd6bf Bunch of improvements to tasks and DB logic
Use locks when executing tasks
Decouple GuildManager into services
Add more logging when failing to load a persistable task
Add ping method to WebModule
Improve Task interface and implementations
Start work on step tasks
2026-08-24 17:10:53 -04:00
anthorob e58854a85c change how events are handled by using a single class to register listeners onto 2026-08-24 17:10:16 -04:00
anthorob e0612f90a8 fix(MessageCommandContext): missing check for channel when option is of type STRING 2026-08-24 17:09:50 -04:00
anthorob 4b88be0517 better parsing for boolean options 2026-08-24 17:09:50 -04:00
anthorob 8fec0271cc show available choices, types, maximum and minimum length in command help message 2026-08-24 17:09:50 -04:00
anthorob 88d954427a use string chunking for method replyEphemeral(String) 2026-08-24 17:09:47 -04:00
anthorob febd12f2f2 change package of core command classes so it makes more sense and rename getType() to getCategory() in command interface 2026-08-24 17:09:42 -04:00
anthorob ffb393706b Add unique ID for each context 2026-08-24 17:04:21 -04:00
anthorob c021f84415 Add isSlashCommandOnly to command interface 2026-08-24 17:04:14 -04:00
anthorob da3142df0c Fixed message commands where a null optionnal argument was being parsed when it shouldn't be 2026-08-24 17:03:59 -04:00
anthorob 15d58bba38 Create abstraction to combine slash commands and message commands
Usage and options checking (arguments count, type checking)
Options parsing for message commands (both types of commands are now registered like slash commands and the abstraction will handle the parsing of all options as if it was a slash command)
Refactor of the help command for better readability and more helpful text, which is now based on the available options of a command
2026-08-24 17:03:49 -04:00
anthorob 247f217e2e Implement built-in tasks, improve message commands with arguments, fix command context in DM, better help message 2026-08-24 17:03:26 -04:00
anthorob c98d47b5ca Add getInteractionHook to InteractionContext abstraction 2026-08-24 17:03:13 -04:00
anthorob 81e15a5394 Improve cleanup for music player, fix resume logic, check if player is connected before checking url, refactor context 2026-08-24 17:02:38 -04:00
anthorob ddd7d119bb fix(MusicManager): missing checks and reworked logic of who is the DJ
DJ is now the first user who makes the bot connect to a voice channel and doesn't change until the voice channel changes
2026-08-24 17:02:21 -04:00
anthorob ccf56298d8 fix(CommandProcessor): missing guild check and if command exists before execution 2026-08-24 17:01:45 -04:00
anthorob 4fa790259e feat: implement button, modal and emoji tasks 2026-08-24 17:00:24 -04:00
22 changed files with 1097 additions and 73 deletions

No files matched your search

+53 -61
View File
@@ -10,15 +10,10 @@ on:
branches: branches:
- "main" - "main"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
package: package:
name: Package name: Package
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -37,7 +32,7 @@ jobs:
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3
with: with:
name: junit-reports name: junit-reports
path: | path: |
@@ -45,7 +40,7 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload Maven artifacts - name: Upload Maven artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3
with: with:
name: maven-artifacts name: maven-artifacts
path: | path: |
@@ -53,40 +48,11 @@ jobs:
startup.sh startup.sh
if-no-files-found: error if-no-files-found: error
maven-publish: docker-main:
name: Publish Maven Package name: Docker Build and Push (main)
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
timeout-minutes: 15
needs: package needs: package
if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) }} if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
server-id: gitea
server-username: REGISTRY_USER
server-password: REGISTRY_PASSWORD
- name: Publish Maven package
run: |
chmod +x ./mvnw
./mvnw --batch-mode deploy -DskipTests
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
docker:
name: Docker Build and Push
runs-on: ubuntu-24.04
timeout-minutes: 15
needs: package
if: ${{ github.event_name == 'push' }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -95,26 +61,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download build artifacts - name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3
with: with:
name: maven-artifacts name: maven-artifacts
path: . path: .
- name: Compute Docker tags
id: meta
shell: bash
run: |
REGISTRY="${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
ver="${GITHUB_REF_NAME#v}"
echo "tags=${REGISTRY}:${ver},${REGISTRY}:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${REGISTRY}:main" >> "$GITHUB_OUTPUT"
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to registry - name: Log in to registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
with: with:
@@ -122,12 +73,53 @@ jobs:
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push - name: Build and push (main)
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: "${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:main"
cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:buildcache
cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:buildcache,mode=max docker-release:
name: Docker Build and Push (release)
runs-on: ubuntu-latest
needs: package
if: ${{ startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download build artifacts
uses: actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3
with:
name: maven-artifacts
path: .
- name: Compute release version
id: rel
shell: bash
run: |
# Strip leading 'v' if present (e.g., v1.2.3 -> 1.2.3)
ver="${GITHUB_REF_NAME#v}"
echo "version=$ver" >> "$GITHUB_OUTPUT"
- name: Log in to registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push (release + latest)
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:${{ steps.rel.outputs.version }}
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:latest
+1 -1
View File
@@ -32,7 +32,7 @@ services:
MYSQL_PASSWORD: password MYSQL_PASSWORD: password
MYSQL_DATABASE: netneutrality MYSQL_DATABASE: netneutrality
ejs-api: ejs-api:
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449 image: ghcr.io/kikkia/yt-cipher:master@sha256:4fbb0d94cb4b9d2e5028986853825921d8c30975c0d948391fb9f4c7bf8520f6
container_name: ejs_signature_api container_name: ejs_signature_api
restart: unless-stopped restart: unless-stopped
networks: networks:
+8 -10
View File
@@ -11,13 +11,6 @@
<description>Discord bot</description> <description>Discord bot</description>
<packaging>jar</packaging> <packaging>jar</packaging>
<distributionManagement>
<repository>
<id>gitea</id>
<url>https://gitea.bobert.me/api/packages/anthorob/maven</url>
</repository>
</distributionManagement>
<properties> <properties>
<main.class>me.bobert.netneutrality.NetNeutrality</main.class> <main.class>me.bobert.netneutrality.NetNeutrality</main.class>
<java.version>25</java.version> <java.version>25</java.version>
@@ -143,7 +136,7 @@
<repository> <repository>
<id>arbjergDev-releases</id> <id>arbjergDev-releases</id>
<name>Lavalink Repository</name> <name>Lavalink Repository</name>
<url>https://maven.lavalink.dev/snapshots</url> <url>https://maven.lavalink.dev/releases</url>
</repository> </repository>
</repositories> </repositories>
@@ -151,7 +144,7 @@
<dependency> <dependency>
<groupId>net.dv8tion</groupId> <groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId> <artifactId>JDA</artifactId>
<version>6.7.0</version> <version>6.4.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.arbjerg</groupId> <groupId>dev.arbjerg</groupId>
@@ -161,7 +154,7 @@
<dependency> <dependency>
<groupId>dev.lavalink.youtube</groupId> <groupId>dev.lavalink.youtube</groupId>
<artifactId>v2</artifactId> <artifactId>v2</artifactId>
<version>f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT</version> <version>1.18.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>club.minnced</groupId> <groupId>club.minnced</groupId>
@@ -233,6 +226,11 @@
<artifactId>jemoji</artifactId> <artifactId>jemoji</artifactId>
<version>1.7.6</version> <version>1.7.6</version>
</dependency> </dependency>
<dependency>
<groupId>net.fellbaum</groupId>
<artifactId>jemoji</artifactId>
<version>1.7.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.json</groupId> <groupId>org.json</groupId>
@@ -49,7 +49,7 @@ public class NetNeutrality {
options.setEnvironment(env); options.setEnvironment(env);
options.setTracesSampleRate(1.0); options.setTracesSampleRate(1.0);
options.setDebug(false); options.setDebug(false);
options.setRelease(VERSION); options.setRelease(Settings.getVersion());
}); });
} }
@@ -37,6 +37,11 @@ public class MusicChannelContext extends AbstractContext implements NoMessageCon
return true; return true;
} }
@Override
public boolean isFromGuild() {
return true;
}
@Override @Override
public void deleteMessage() { public void deleteMessage() {
@@ -0,0 +1,19 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
public abstract class AbstractAdminCommand extends AbstractCommand {
@Override
public boolean needAdmin() {
return true;
}
@Override
public CommandCategory getCategory() {
return CommandCategory.ADMINISTRATION;
}
}
@@ -0,0 +1,56 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.command.MessageMenuContext;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.Message;
public abstract class AbstractMessageMenuCommand extends AbstractCommand {
@Override
public String getDescription() {
return null;
}
@Override
public boolean isGuildOnly() {
return true;
}
@Override
public boolean isSlashCommandOnly() {
return true;
}
@Override
public void registerCommandOptions(CommandOptionsBuilder builder) {
// no options for context commands
}
@Override
public CommandCategory getCategory() {
return CommandCategory.UNLISTED;
}
@Override
public Type getType() {
return Type.MESSAGE;
}
@Override
public CommandExecutionResult execute(CommandContext context) {
if (context instanceof MessageMenuContext mc) {
Message message = mc.getTarget();
return execute(message, context);
}
throw new IllegalArgumentException("Wrong context type for this command");
}
public abstract CommandExecutionResult execute(Message target, CommandContext context);
}
@@ -0,0 +1,46 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.command.UserMenuContext;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.User;
public abstract class AbstractUserMenuCommand extends AbstractCommand {
@Override
public String getDescription() {
return null;
}
@Override
public boolean isSlashCommandOnly() {
return false;
}
@Override
public void registerCommandOptions(CommandOptionsBuilder builder) {
// no options for context commands
}
@Override
public Type getType() {
return Type.USER;
}
@Override
public CommandExecutionResult execute(CommandContext context) {
if (context instanceof UserMenuContext uc) {
User user = uc.getTarget();
return execute(user, context);
}
throw new IllegalArgumentException("Wrong context type for this command");
}
public abstract CommandExecutionResult execute(User target, CommandContext context);
}
@@ -1,4 +1,8 @@
<<<<<<<< HEAD:src/main/java/me/bobert/netneutrality/core/command/impl/AbstractCommand.java
package me.bobert.netneutrality.core.command.impl; package me.bobert.netneutrality.core.command.impl;
========
package me.bobert.netneutrality.core.command;
>>>>>>>> 8479f71 (change package of core command classes so it makes more sense and rename getType() to getCategory() in command interface):src/main/java/me/bobert/netneutrality/core/command/AbstractCommand.java
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
@@ -1,4 +1,8 @@
<<<<<<<< HEAD:src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java
package me.bobert.netneutrality.core.command.impl; package me.bobert.netneutrality.core.command.impl;
========
package me.bobert.netneutrality.core.command;
>>>>>>>> 8479f71 (change package of core command classes so it makes more sense and rename getType() to getCategory() in command interface):src/main/java/me/bobert/netneutrality/core/command/AbstractMusicCommand.java
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
@@ -0,0 +1,170 @@
package me.bobert.netneutrality.core.command.options;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
public class CommandOptions {
private final Command command;
private final CommandDataImpl commandData;
private final List<OptionData> options;
private final List<SubcommandData> subcommands;
private final List<SubcommandGroupData> subcommandGroups;
public CommandOptions(Command command, CommandDataImpl commandData) {
this.command = command;
this.commandData = commandData;
this.options = commandData.getOptions();
this.subcommands = commandData.getSubcommands();
this.subcommandGroups = commandData.getSubcommandGroups();
}
public Command getCommand() {
return command;
}
public SlashCommandData getSlashCommandData() {
return commandData;
}
public List<OptionData> getOptions() {
return options;
}
public List<SubcommandData> getSubcommands() {
return subcommands;
}
public List<SubcommandGroupData> getSubcommandGroups() {
return subcommandGroups;
}
public boolean isEmpty() {
return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty();
}
public String printUsage() {
StringBuilder usage = new StringBuilder();
if (!options.isEmpty()) {
usage.append("- Usage: /").append(command.getCommandName());
for (OptionData option : options) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(printOptions(options, null, false));
}
if (!subcommands.isEmpty()) {
for (SubcommandData subcommand : subcommands) {
usage.append("- Usage: /").append(command.getCommandName()).append(" ").append(subcommand.getName());
for (OptionData option : subcommand.getOptions()) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(" (").append(subcommand.getDescription()).append(")");
usage.append(printOptions(subcommand.getOptions(), null, false));
usage.append("\n");
}
}
if (!subcommandGroups.isEmpty()) {
for (SubcommandGroupData subcommandGroup : subcommandGroups) {
usage.append("- Subcommand: /").append(command.getCommandName())
.append(" ").append(subcommandGroup.getName())
.append(" (").append(subcommandGroup.getDescription()).append(")")
.append("\n");
for (SubcommandData subcommand : subcommandGroup.getSubcommands()) {
usage.append(" - Usage: /").append(command.getCommandName())
.append(" ").append(subcommandGroup.getName())
.append(" ").append(subcommand.getName());
for (OptionData option : subcommand.getOptions()) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(printOptions(subcommand.getOptions(), subcommand.getDescription(), true));
usage.append("\n");
}
}
}
if (!isEmpty()) {
usage.append("\n\n-# *: required argument");
}
return usage.toString();
}
private String printOptions(List<OptionData> options, String description, boolean group) {
StringBuilder usage = new StringBuilder();
if (description != null) {
usage.append(" (").append(description).append(")");
}
for (OptionData option : options) {
usage.append("\n");
if (group) usage.append(" ");
usage.append(" - ");
if (option.isRequired()) {
usage.append("*");
}
usage.append(option.getName()).append(": ").append(option.getDescription());
if (option.getMinValue() != null) {
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
}
if (option.getMaxValue() != null) {
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
}
if (option.getMinLength() != null) {
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
}
if (option.getMaxLength() != null) {
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
}
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
if (!choices.isEmpty()) {
StringJoiner joiner = new StringJoiner(", ");
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
joiner.add(choice.getAsString());
}
usage.append(" [Available choices: ").append(joiner).append("]");
}
Set<ChannelType> requiredChannelTypes = option.getChannelTypes();
if (!requiredChannelTypes.isEmpty()) {
StringJoiner joiner = new StringJoiner(", ");
for (ChannelType type : requiredChannelTypes) {
joiner.add(type.name());
}
usage.append(" [Required channel types: ").append(joiner).append("]");
}
}
return usage.toString();
}
}
@@ -0,0 +1,185 @@
package me.bobert.netneutrality.core.command.options;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import net.dv8tion.jda.api.interactions.DiscordLocale;
import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
import net.dv8tion.jda.api.interactions.commands.build.*;
import net.dv8tion.jda.api.interactions.commands.localization.LocalizationFunction;
import net.dv8tion.jda.api.interactions.commands.localization.LocalizationMap;
import net.dv8tion.jda.api.utils.data.DataObject;
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
import javax.annotation.Nonnull;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
public class CommandOptionsBuilder implements SlashCommandData {
private final Command command;
private CommandDataImpl commandData;
public CommandOptionsBuilder(Command.Type type, Command command) {
this.command = command;
if (type == Command.Type.SLASH) {
commandData = (CommandDataImpl) Commands.slash(command.getCommandName(), command.getDescription());
}
if (type == Command.Type.USER) {
commandData = (CommandDataImpl) Commands.user(command.getCommandName());
}
if (type == Command.Type.MESSAGE) {
commandData = (CommandDataImpl) Commands.message(command.getCommandName());
}
}
public CommandOptions build() {
return new CommandOptions(command, commandData);
}
@Override
public @Nonnull SlashCommandData setLocalizationFunction(@Nonnull LocalizationFunction localizationFunction) {
return commandData.setLocalizationFunction(localizationFunction);
}
@Override
public @Nonnull SlashCommandData setName(@Nonnull String name) {
return commandData.setName(name);
}
@Override
public @Nonnull SlashCommandData setNameLocalization(@Nonnull DiscordLocale locale, @Nonnull String name) {
return commandData.setNameLocalization(locale, name);
}
@Override
public @Nonnull SlashCommandData setNameLocalizations(@Nonnull Map<DiscordLocale, String> map) {
return commandData.setNameLocalizations(map);
}
@Override
public @Nonnull SlashCommandData setDefaultPermissions(@Nonnull DefaultMemberPermissions permission) {
return commandData.setDefaultPermissions(permission);
}
@Override
public @Nonnull SlashCommandData setGuildOnly(boolean guildOnly) {
return commandData.setGuildOnly(guildOnly);
}
@Override
public @Nonnull SlashCommandData setNSFW(boolean nsfw) {
return commandData.setNSFW(nsfw);
}
@Override
public @Nonnull String getName() {
return commandData.getName();
}
@Override
public @Nonnull LocalizationMap getNameLocalizations() {
return commandData.getNameLocalizations();
}
@Override
public @Nonnull net.dv8tion.jda.api.interactions.commands.Command.Type getType() {
return commandData.getType();
}
@Override
public @Nonnull DefaultMemberPermissions getDefaultPermissions() {
return commandData.getDefaultPermissions();
}
@Override
public boolean isGuildOnly() {
return commandData.isGuildOnly();
}
@Override
public boolean isNSFW() {
return commandData.isNSFW();
}
@Override
public @Nonnull SlashCommandData setDescription(@Nonnull String description) {
return commandData.setDescription(description);
}
@Override
public @Nonnull SlashCommandData setDescriptionLocalization(@Nonnull DiscordLocale locale, @Nonnull String description) {
return commandData.setDescriptionLocalization(locale, description);
}
@Override
public @Nonnull SlashCommandData setDescriptionLocalizations(@Nonnull Map<DiscordLocale, String> map) {
return commandData.setDescriptionLocalizations(map);
}
@Override
public @Nonnull String getDescription() {
return commandData.getDescription();
}
@Override
public @Nonnull LocalizationMap getDescriptionLocalizations() {
return commandData.getDescriptionLocalizations();
}
@Override
public boolean removeOptions(@Nonnull Predicate<? super OptionData> condition) {
return commandData.removeOptions(condition);
}
@Override
public boolean removeSubcommands(@Nonnull Predicate<? super SubcommandData> condition) {
return commandData.removeSubcommands(condition);
}
@Override
public boolean removeSubcommandGroups(@Nonnull Predicate<? super SubcommandGroupData> condition) {
return commandData.removeSubcommandGroups(condition);
}
@Override
public @Nonnull List<SubcommandData> getSubcommands() {
return commandData.getSubcommands();
}
@Override
public @Nonnull List<SubcommandGroupData> getSubcommandGroups() {
return commandData.getSubcommandGroups();
}
@Override
public @Nonnull List<OptionData> getOptions() {
return commandData.getOptions();
}
@Override
public @Nonnull SlashCommandData addOptions(@Nonnull OptionData... options) {
return commandData.addOptions(options);
}
@Override
public @Nonnull SlashCommandData addSubcommands(@Nonnull SubcommandData... subcommands) {
return commandData.addSubcommands(subcommands);
}
@Override
public @Nonnull SlashCommandData addSubcommandGroups(@Nonnull SubcommandGroupData... groups) {
return commandData.addSubcommandGroups(groups);
}
@Override
public @Nonnull DataObject toData() {
return commandData.toData();
}
}
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.context;
*/ */
import io.sentry.Sentry; import io.sentry.Sentry;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
@@ -0,0 +1,40 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.TaskContext;
public abstract class AbstractBuiltInTask extends AbstractTask implements BuiltInTask {
@Override
public String getName() {
return getId();
}
@Override
public boolean isInsertable() {
return true;
}
@Override
public boolean isExpired() {
return false;
}
@Override
public void replyToInteraction(CommandContext context) {
// Task is loaded at runtime, this will never be called
}
@Override
public Lifecycle execute(TaskContext context) {
executeTask(context);
return Lifecycle.BUILTIN;
}
public abstract void executeTask(TaskContext context);
}
@@ -0,0 +1,58 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import org.json.JSONObject;
public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask {
@Override
public boolean isInsertable() {
return true;
}
@Override
public boolean isExpired() {
return false;
}
@Override
public void replyToInteraction(CommandContext context) {
// Task loaded from database, this will never be called
}
@Override
public void insert(CommandContext context) {
if (TaskManager.getInstance().hasTask(getId())) {
throw new RuntimeException("Cannot insert a task that already exists");
}
TaskManager.getInstance().insert(context, this);
}
@Override
public void save() {
if (!TaskManager.getInstance().hasTask(getId())) {
throw new RuntimeException("Cannot save a task that doesn't exist yet");
}
TaskManager.getInstance().getTaskService().persistTask(this);
}
@Override
public void delete() {
TaskManager.getInstance().getTaskService().unpersistTask(this);
TaskManager.getInstance().removeTask(this);
}
@Override
public String toPayload() {
JSONObject payload = new JSONObject(this);
return payload.toString();
}
}
@@ -0,0 +1,48 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import java.time.OffsetDateTime;
import java.util.Objects;
public abstract class AbstractTask implements Task {
private final OffsetDateTime creationTime;
public AbstractTask() {
creationTime = OffsetDateTime.now();
}
@Override
public OffsetDateTime getCreationTime() {
return creationTime;
}
@Override
public void insert(CommandContext context) {
TaskManager.getInstance().insert(context, this);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AbstractTask that = (AbstractTask) o;
return Objects.equals(getId(), that.getId());
}
@Override
public int hashCode() {
return Objects.hashCode(getId());
}
@Override
public String toString() {
return String.format("Task{id=%s, lockId=%s, class=%s, name=%s, creationTime=%s}", getId(), getLockId(), getClass().getSimpleName(), getName(), getCreationTime());
}
}
@@ -0,0 +1,27 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.interactions.components.buttons.Button;
public abstract class GenericCustomIdTask extends AbstractTask {
private final String customId;
public GenericCustomIdTask(String id) {
this.customId = id;
}
@Override
public String getId() {
return customId;
}
@Override
public Button createButton() {
return null;
}
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.UUID;
public abstract class GenericRandomIdTask extends GenericCustomIdTask {
public GenericRandomIdTask() {
super(UUID.randomUUID().toString());
}
}
@@ -0,0 +1,147 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
public interface IStepTask {
/**
* Defines the type of input expected for a step.
*/
enum StepType {
/**
* Expects a string input from the user.
*/
STRING,
/**
* Expects a valid role ID for the current guild.
* The role must exist in the guild where the command was executed.
*/
ROLE,
/**
* Expects a valid emoji.
*/
EMOJI
}
class StepResult {
private enum ResultType {
SUCCESS, FAILURE
}
private final ResultType type;
private final String message;
private final Task nextStep;
/**
* Represents the result of a step execution. Each step can either succeed, fail, or transition to another step.
*/
private StepResult(ResultType type, String message, Task nextStep) {
this.type = type;
this.message = message;
this.nextStep = nextStep;
}
/**
* Indicates that the step was completed successfully, with an optional follow-up step.
*
* @param message the success message
* @param nextStep the next step in the workflow, or null if this is the final step
* @return a successful result
*/
public static StepResult success(String message, Task nextStep) {
return new StepResult(ResultType.SUCCESS, message, nextStep);
}
/**
* Indicates that the step was completed successfully.
*
* @param message the success message
* @return a successful result without a next step
*/
public static StepResult success(String message) {
return new StepResult(ResultType.SUCCESS, message, null);
}
/**
* Indicates that the step has failed.
*
* @param message the failure message
* @return a failed result
*/
public static StepResult failure(String message) {
return new StepResult(ResultType.FAILURE, message, null);
}
/**
* Moves directly to the next step without displaying a message.
*
* @param nextTask the next task in the workflow
* @return a successful result that advances to the next step
*/
public static StepResult next(Task nextTask) {
return new StepResult(ResultType.SUCCESS, null, nextTask);
}
/**
* Gets the message associated with the step result.
*
* @return the result message, or null if there is no message
*/
public String getMessage() {
return message;
}
/**
* Gets the next step in the task workflow.
*
* @return the next step, or null if this is the last step
*/
public Task getNextStep() {
return nextStep;
}
/**
* Checks whether the step execution was successful.
*
* @return true if the step was successful, false otherwise
*/
public boolean isSuccess() {
return type == ResultType.SUCCESS;
}
}
/**
* Gets the type of step input expected.
*
* @return the step type
*/
StepType getStepType();
/**
* Provides a general description of the step. This will be displayed before asking the question.
*
* @return the step description
*/
String getStepDescription();
/**
* Provides the specific question for this step.
*
* @return the step question
*/
String getStepQuestion();
/**
* Executes the step using the provided answer.
*
* @param answer the user's input, validated against the expected step type
* @return the result of executing this step
*/
StepResult executeStep(Object answer);
}
@@ -0,0 +1,169 @@
package me.bobert.netneutrality.core.tasks.impl;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.MessageContext;
import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.AbstractTask;
import me.bobert.netneutrality.core.tasks.IStepTask;
import me.bobert.netneutrality.core.tasks.TaskManager;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import net.fellbaum.jemoji.Emoji;
import net.fellbaum.jemoji.EmojiManager;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
/**
* Represents a task that consists of multiple sequential steps. The task starts with an initial message (question)
* and expects a user response. Each step can lead to another step, forming a workflow.
*/
public abstract class StepTask extends AbstractTask implements IStepTask {
private final Object stepLock = new Object();
private CommandContext commandContext;
private Guild guild;
private String id = UUID.randomUUID().toString();
@Override
public String getId() {
return id;
}
@Override
public boolean isInsertable() {
return true;
}
public Guild getGuild() {
return guild;
}
@Override
public void replyToInteraction(CommandContext context) {
synchronized (stepLock) {
this.commandContext = context;
}
if (context.isFromGuild()) {
guild = context.getGuild();
}
if (guild == null && getStepType() == StepType.ROLE) {
throw new IllegalArgumentException("Step task is of type role but it was inserted in a non-guild context");
}
context.replyEphemeral("Please follow the instructions sent in DM.");
context.sendMessageInPrivate(getStepDescription() + "\n\n" + getStepQuestion(),
asyncContext -> assignId(asyncContext.getMessageId()));
}
@Override
public Lifecycle execute(TaskContext context) {
synchronized (stepLock) {
if (!context.getDiscordUser().equals(commandContext.getDiscordUser())) {
return Lifecycle.KEEP;
}
}
if (context instanceof MessageContext messageContext) {
Message message = messageContext.getMessage();
String input = message.getContentRaw();
Object answer = validateInput(context, input);
if (answer == null) {
return Lifecycle.KEEP;
}
StepResult result = executeStep(answer);
if (!result.isSuccess()) {
context.replyEphemeral(result.getMessage());
return Lifecycle.KEEP;
}
if (result.getMessage() != null) {
context.replyEphemeral(result.getMessage());
}
if (result.getNextStep() != null) {
result.getNextStep().insert(commandContext);
}
return Lifecycle.REMOVE_SELF;
}
throw new IllegalArgumentException("Step task is not of type MessageContext");
}
@Override
public boolean isExpired() {
// is it really needed to expire this type of task?
return getCreationTime().plusMinutes(60).isBefore(OffsetDateTime.now());
}
@Override
public Button createButton() {
return null;
}
/**
* Validates the user input against the expected step type.
*
* @param context the task execution context
* @param input the user-provided input
* @return the parsed input if valid, or null if invalid
*/
private Object validateInput(TaskContext context, String input) {
switch (getStepType()) {
case ROLE -> {
try {
Role role = guild.getRoleById(input);
if (role == null) {
context.replyEphemeral("The role doesn't exist");
return null;
}
return role;
} catch (NumberFormatException e) {
context.replyEphemeral("Not a valid role ID");
return null;
}
}
case EMOJI -> {
Optional<Emoji> parsed = EmojiManager.getEmoji(input);
if (parsed.isPresent()) {
return parsed.get().getEmoji();
}
context.replyEphemeral("Not a valid emoji");
return null;
}
case STRING -> {
return input;
}
default -> throw new IllegalStateException("Unknown step type: " + getStepType());
}
}
private void assignId(String newId) {
if (newId == null || newId.isBlank()) {
throw new IllegalArgumentException("Task ID cannot be null or empty.");
}
String oldId = id;
synchronized (this) {
this.id = newId;
TaskManager.getInstance().activateTask(this, oldId);
}
}
}
@@ -13,6 +13,7 @@ import me.bobert.netneutrality.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.components.buttons.ButtonStyle; import net.dv8tion.jda.api.components.buttons.ButtonStyle;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle;
public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> { public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
private final AudioTrack track; private final AudioTrack track;
@@ -0,0 +1,39 @@
package me.bobert.netneutrality.thread;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.TrackScheduler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class MusicManagerCleanupThread implements Runnable {
private static final Logger logger = LogManager.getLogger(MusicManagerCleanupThread.class);
@Override
public void run() {
try {
for (MusicManager manager : MusicManager.getManagers()) {
processManager(manager);
}
} catch (InterruptedException e) {
logger.error("Interrupted while running MusicManagerCleanupThread", e);
}
}
private void processManager(MusicManager manager) throws InterruptedException {
TrackScheduler scheduler = manager.getScheduler();
// Context is still attached after bot is disconnected, the bot might have been kicked from the channel
if (scheduler.getContext() != null && !manager.isConnected()) {
manager.disconnect();
manager.getQueueManager().clearQueue();
}
}
}