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
196 changed files with 3098 additions and 9254 deletions

No files matched your search

+4 -7
View File
@@ -1,4 +1,5 @@
NETNEUTRALITY_TAG=latest NETNEUTRALITY_TAG=latest
NETNEUTRALITY_BOT_ENV=dev NETNEUTRALITY_BOT_ENV=dev
NETNEUTRALITY_BOT_TOKEN= NETNEUTRALITY_BOT_TOKEN=
NETNEUTRALITY_BOT_PREFIX=$ NETNEUTRALITY_BOT_PREFIX=$
@@ -11,17 +12,13 @@ MYSQL_PORT=3306
MYSQL_USER=java MYSQL_USER=java
MYSQL_PASSWORD=password MYSQL_PASSWORD=password
MYSQL_DATABASE=netneutrality MYSQL_DATABASE=netneutrality
MYSQL_ROOT_PASSWORD=password
MARIADB_AUTO_UPGRADE=1
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=dev-player
NETNEUTRALITY_PO_TOKEN= NETNEUTRALITY_PO_TOKEN=
NETNEUTRALITY_VISITOR_DATA= NETNEUTRALITY_VISITOR_DATA=
NETNEUTRALITY_OAUTH_TOKEN= NETNEUTRALITY_CIPHER_URL=http://localhost:8001
NETNEUTRALITY_CIPHER_URL=http://ejs-api:8001
NETNEUTRALITY_CIPHER_PASSWORD= NETNEUTRALITY_CIPHER_PASSWORD=
NETNEUTRALITY_WEBPO_URL=http://webpo:8080
NETNEUTRALITY_WEBPO_PASSWORD=
NETNEUTRALITY_SENTRY_DSN= NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST= GITLAB_HOST=
+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=gha
cache-to: type=gha,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
-178
View File
@@ -1,178 +0,0 @@
---
sessionId: session-260910-075746-nw8q
---
# Requirements
### Overview & Goals
Refactor `CommandProcessor` to break its tightly coupled `dispatch()` method into composable, extensible pieces so that new preconditions, permission strategies, and result handlers can be added without modifying the processor itself.
### Scope
**In Scope**
- Decompose `CommandProcessor.dispatch()` into a pipeline of single-responsibility components
- Eliminate `instanceof` checks for `InteractionContext` / `SlashCommandContext` inside the processor
- Preserve all existing behavior and keep the existing test suite green
**Out of Scope**
- Changes to the `Command` interface or individual command implementations
- Changes to `CommandContext` subclass hierarchy
- Changes to `CommandContainer`, `CommandRegistry`, or `CommandExecutionResult`
# Technical Design
### Current Implementation
`CommandProcessor.dispatch()` (~80 lines, `CommandProcessor.java:48-129`) performs **five concerns** sequentially:
1. **Command lookup** — resolve command from registry
2. **Precondition checks** — guild-only, slash-only, via `instanceof` checks against `InteractionContext`
3. **Permission check** — `canExecute()` with `instanceof SlashCommandContext`
4. **Usage/argument validation** — `checkUsage()` + `checkOptions()`, again branching on `instanceof InteractionContext`
5. **Execution & result handling** — execute command, handle TASK/ERROR/WARN/NOACTION results, catch exceptions
The `instanceof` checks (lines 66, 197, 220) tightly couple the processor to specific context subtypes, making it impossible to add new context types without modifying the processor.
### Key Decisions
1. **Introduce a `CommandPrecondition` interface** — each check (guild-only, slash-only, permissions) becomes a standalone, testable precondition that can be composed into an ordered list. This replaces the inline if-chains and eliminates instanceof checks from the processor.
2. **Move context-type-specific logic into the context itself** — instead of `instanceof` checks in the processor, add polymorphic methods to `CommandContext` (e.g., `supportsSlashOnly()`, `canSkipPermissionCheck()`, `validateOptions()`). Each context subclass overrides as needed.
3. **Extract result handling into a `ResultHandler`** — the if-chain for TASK/ERROR/WARN/NOACTION becomes a dedicated component, making it easy to add new result types.
4. **Keep changes incremental** — refactor in place without changing the public API of `CommandProcessor` (constructor + `dispatch()` remain the same). The pipeline is internal.
### Proposed Changes
#### 1. `CommandPrecondition` interface
```java
// New file: core/command/precondition/CommandPrecondition.java
public interface CommandPrecondition {
/** Returns null if passed, or an error message to reply with. */
String check(CommandContext context, Command command);
}
```
Concrete implementations:
- `GuildOnlyPrecondition` — replaces lines 60-64
- `SlashCommandOnlyPrecondition` — replaces lines 66-69, uses `context.supportsSlashOnly()` instead of instanceof
- `PermissionPrecondition` — replaces `canExecute()` (lines 215-229), uses `context.canSkipPermissionCheck()` instead of instanceof
#### 2. Polymorphic methods on `CommandContext`
Add to `CommandContext`:
- `boolean isInteraction()` — default `false`, overridden to `true` in `InteractionContext`
- `boolean canSkipPermissionCheck()` — default `false`, overridden in `SlashCommandContext`
This eliminates all `instanceof` checks from the processor.
#### 3. `CommandResultHandler`
```java
// New file: core/command/CommandResultHandler.java
public class CommandResultHandler {
void handle(CommandContext context, CommandExecutionResult result, TaskManager taskManager);
}
```
Encapsulates the TASK/ERROR/WARN/NOACTION/message reply logic from lines 100-120.
#### 4. Refactored `CommandProcessor.dispatch()`
The method becomes:
```java
public void dispatch() {
CommandContainer container = resolveCommand();
if (container == null) return;
Command command = container.getCommand();
context.setCommand(command);
configureSentry(command);
for (CommandPrecondition precondition : preconditions) {
String error = precondition.check(context, command);
if (error != null) { context.replyEphemeral(error); return; }
}
if (!usageValidator.validate(context, container)) return;
try {
CommandExecutionResult result = command.execute(context);
resultHandler.handle(context, result, taskManager);
} catch (...) { /* existing error handling */ }
}
```
### Architecture Diagram
```mermaid
graph LR
D[dispatch] --> R[resolveCommand]
R --> P[Preconditions Loop]
P --> GuildOnly
P --> SlashOnly
P --> Permission
P --> UV[UsageValidator]
UV --> E[command.execute]
E --> RH[ResultHandler]
```
### File Structure
| File | Action |
|------|--------|
| `core/command/precondition/CommandPrecondition.java` | **New** — interface |
| `core/command/precondition/GuildOnlyPrecondition.java` | **New** |
| `core/command/precondition/SlashCommandOnlyPrecondition.java` | **New** |
| `core/command/precondition/PermissionPrecondition.java` | **New** |
| `core/command/CommandResultHandler.java` | **New** |
| `core/command/UsageValidator.java` | **New** — extracted from `checkUsage()`/`checkOptions()` |
| `core/command/CommandProcessor.java` | **Modified** — delegate to above components |
| `core/context/CommandContext.java` | **Modified** — add `isInteraction()`, `canSkipPermissionCheck()` |
| `core/context/InteractionContext.java` | **Modified** — override `isInteraction()` |
| `context/command/SlashCommandContext.java` | **Modified** — override `canSkipPermissionCheck()` |
| `CommandProcessorTest.java` | **Modified** — update construction, add unit tests for new components |
### Risks
- **Behavioral regression** — the refactor must preserve exact reply messages and ordering. Mitigated by the existing 1000+ line test suite.
- **Precondition ordering matters** — guild-only must run before permission check. The list order is explicit and documented.
# Testing
### Validation Approach
- All existing `CommandProcessorTest` tests must pass unchanged (behavior preservation).
- New unit tests for each precondition, the result handler, and the usage validator in isolation.
### Key Scenarios
- Preconditions short-circuit in order (guild-only before permissions)
- Custom precondition can be added to the list and is invoked
- ResultHandler correctly dispatches TASK to TaskManager, logs ERROR/WARN
- UsageValidator handles all subcommand/group/option combinations
### Edge Cases
- Null command from container
- Empty precondition list (all checks pass)
- InteractionContext skips option validation in UsageValidator
# Delivery Steps
### ✓ Step 1: Add polymorphic methods to CommandContext hierarchy
CommandContext subclasses expose their capabilities without requiring instanceof checks.
- Add `isInteraction()` (default `false`) and `canSkipPermissionCheck()` (default `false`) to `CommandContext.java`
- Override `isInteraction()` to return `true` in `InteractionContext.java`
- Override `canSkipPermissionCheck()` to return `true` in `SlashCommandContext.java`
- Verify existing tests still compile and pass
### ✓ Step 2: Extract preconditions into CommandPrecondition interface and implementations
Precondition checks become standalone, composable components.
- Create `CommandPrecondition` interface in `core/command/precondition/`
- Implement `GuildOnlyPrecondition` — checks `command.isGuildOnly()` and `context.isFromGuild()`
- Implement `SlashCommandOnlyPrecondition` — checks `command.isSlashCommandOnly()` and `context.isInteraction()`
- Implement `PermissionPrecondition` — replaces `canExecute()`, uses `context.canSkipPermissionCheck()` instead of instanceof
- Add unit tests for each precondition
### ✓ Step 3: Extract UsageValidator and CommandResultHandler
Usage validation and result handling become dedicated classes.
- Extract `checkUsage()` and `checkOptions()` into `UsageValidator.java`, replacing `instanceof InteractionContext` with `context.isInteraction()`
- Extract result-handling if-chain (TASK/ERROR/WARN/NOACTION + reply) into `CommandResultHandler.java`
- Add unit tests for both classes
### ✓ Step 4: Refactor CommandProcessor to use the new components
CommandProcessor.dispatch() becomes a thin orchestrator delegating to the extracted components.
- Replace inline precondition checks with a `List<CommandPrecondition>` loop
- Replace `checkUsage()`/`checkOptions()` calls with `UsageValidator`
- Replace result-handling if-chain with `CommandResultHandler`
- Remove the now-unused private methods (`canExecute`, `checkUsage`, `checkOptions`)
- Keep constructor backward-compatible or provide a builder/factory for custom precondition lists
- Run full `CommandProcessorTest` suite to confirm behavioral equivalence
-302
View File
@@ -1,302 +0,0 @@
---
sessionId: session-260826-092009-17wb
---
# Requirements
### Overview & Goals
Replace the singleton/static-heavy bootstrap of the bot with an explicit, deterministic **lifecycle-managed application** driven by a small hand-rolled `ApplicationContext` and a `Lifecycle` contract. Dependencies are wired through **constructors** (including for reflectively-loaded commands and tasks), and **interfaces** are introduced for the key services so the code becomes testable, maintainable, and mockable — without adopting Spring Boot or any DI framework.
The current design (documented in `docs/architecture.md`) uses:
- `NetNeutrality` as a static singleton holding `instance` and `jda`, with a manual, order-sensitive `startup()`/`shutdown()` chain.
- Static singletons everywhere: `Settings.getInstance()`, `SessionManager` (static `getSessionFactory`/`shutdown`), `CommandManager` (static `init`/maps), `TaskManager.getInstance()`, `MusicManager` (static factory + static `playerManager`), `ThreadManager` (static executors), `ScheduledTaskManager` (static), `GuildService.get(...)` (static cache).
- `ReflectionHelper` instantiating commands/tasks via **no-arg constructors**, which forces them to reach out to singletons.
### Scope
**In Scope**
- A `Lifecycle` interface (`start()`/`stop()`/`priority()`) and an `ApplicationContext` that owns instances, wires them, and drives ordered startup/shutdown.
- Converting the core singletons to constructor-injected **instances**.
- Introducing **interfaces for key services** (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider) — decision confirmed with the user.
- Reworking `ReflectionHelper` + command/task base classes to use **constructor injection** (decision confirmed).
- Symmetric, deterministic startup/shutdown replacing `NetNeutrality.startup()/shutdown()` and the JVM shutdown hook.
- **Reorganizing the package layout** into `bootstrap` / `framework` / `platform` / `feature` / `data` / `shared` (see the Package Structure tab), done in the same effort as the DI refactor so imports are touched once. This includes renaming the entry point `NetNeutrality` → `App`.
**Out of Scope**
- Introducing Spring Boot, Guice, or Dagger (explicitly rejected — manual context chosen).
- Full interface-per-service/repository abstraction (only key services get interfaces).
- Dropping the `O*`/`G*` entity name prefixes (optional cosmetic churn, deferred).
- Behavioral/feature changes to commands, tasks, music, or the web API — this is a structural refactor only.
- Database schema / Flyway migration changes.
### User Stories
- As a maintainer, I want services created and wired in one explicit place so the dependency graph and startup order are obvious and not implicit.
- As a maintainer, I want symmetric `start()`/`stop()` for every component so shutdown reliably tears down what startup created, in reverse order.
- As a developer, I want commands/tasks to receive their dependencies via constructor so they no longer call `getInstance()` and can be unit-tested with mocks.
- As a developer, I want key services behind interfaces so I can mock them in tests (building on the existing JUnit 5 + Mockito setup).
### Functional Requirements
- The bot must start and shut down with identical externally-observable behavior to today (same ordered steps from `docs/architecture.md`).
- Startup order must be preserved: persistence/session → migrations → executors → command/task registries → music sources → JDA → web API → presence.
- Shutdown must run in reverse of startup: cancel scheduled tasks → disconnect music players → shut down executors → shut down JDA → release the Hibernate registry.
- Commands and tasks discovered by `ReflectionHelper` must continue to auto-load ("drop a class in the package" model) but now receive dependencies via constructor.
- No public command/task behavior changes.
### Non-Functional Requirements
- **No new runtime dependencies** (manual container only).
- **Incremental & low-risk**: migrate one area at a time using temporary static shims that delegate to the context, so the build stays green throughout.
- **Compatibility**: fat-JAR packaging and `main` entry point (`me.bobert.netneutrality.NetNeutrality`) remain unchanged.
- **Testability**: instance + interface-based services must be mockable with Mockito.
# Technical Design
### Current Implementation
- **Bootstrap** — `NetNeutrality` (`src/main/java/me/bobert/netneutrality/NetNeutrality.java`): static `instance`, static `jda`, `main()` builds `Settings` implicitly, constructor calls `startup()` which chains `SessionManager.getSessionFactory()` → `DatabaseMigration.migrate()` → `CommandManager.init()` → `MusicManager.initPlayerManager()` → `TaskManager.getInstance().init()` → `initJDA()` → `CommandManager.registerSlashCommands(jda)` → `new EventsHandler()` → `new WebApi(port)` → presence → shutdown hook. `shutdown()` runs `ScheduledTaskManager.cancelAll()` → `MusicManager.disconnectPlayers()` → `ThreadManager.shutdownExecutor()` → `disconnect()` → `SessionManager.shutdown()`.
- **Singletons/statics**: `Settings` (`utils/Settings.java`), `SessionManager` (`core/db/SessionManager.java`, all-static), `CommandManager` (`core/command/CommandManager.java`, static `commands` map + static `init`), `TaskManager` (`core/tasks/TaskManager.java`, `getInstance()`), `MusicManager` (`music/MusicManager.java`, static `managers` map + static `playerManager` + static `get`/`initPlayerManager`/`disconnectPlayers`), `ThreadManager` (`core/thread/ThreadManager.java`, static executors), `ScheduledTaskManager`, `GuildService` (`db/service/GuildService.java`, static `services` cache).
- **Reflective instantiation** — `ReflectionHelper.loadClassesFromPackage(...)` calls `s.getConstructor().newInstance()` (no-arg), used by `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()`.
- **Consumers of singletons** — ~110 call sites across commands (`command/**`), tasks (`tasks/**`), contexts (`context/**`, `core/context/**`), HTTP handlers (`http/server/**`), `EventsHandler`, repositories/services (`db/**`), etc.
### Key Decisions (confirmed with user)
1. **Manual `ApplicationContext` + `Lifecycle`** — no DI framework; the context owns instances, wires them via constructors, and drives ordered `start()`/`stop()`.
2. **Constructor injection for commands/tasks** — `ReflectionHelper` resolves a constructor that accepts the context (or specific dependencies) and passes them in; commands/tasks store deps as final fields instead of calling `getInstance()`.
3. **Lifecycle + key service interfaces** — introduce a `Lifecycle` interface plus interfaces for the main services (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider). Repositories and minor helpers stay concrete.
4. **Package restructure to `bootstrap`/`framework`/`platform`/`feature`/`data`/`shared`** — separate code by reason-of-change (engine vs. cross-cutting infra vs. concrete features vs. persistence model vs. leaf helpers), consolidate each feature vertical (e.g. music) into one package, and dissolve the catch-all `utils`. Performed right after the DI wiring, via the IDE's safe "Move Class" refactor, so the ~110 import updates happen once. Full details in the Package Structure tab.
### Proposed Changes
**1. Lifecycle contract** — new `me.bobert.netneutrality.bootstrap.Lifecycle`:
```java
public interface Lifecycle {
void start() throws Exception;
default void stop() {}
default int priority() { return 0; } // lower = started earlier, stopped later
}
```
**2. Application context** — new `me.bobert.netneutrality.bootstrap.ApplicationContext`:
```java
public final class ApplicationContext {
private final List<Lifecycle> components = new ArrayList<>();
public <T> T register(T component) {
if (component instanceof Lifecycle l) components.add(l);
return component;
}
public void start() throws Exception {
components.sort(Comparator.comparingInt(Lifecycle::priority));
for (Lifecycle c : components) c.start();
}
public void stop() {
var reversed = new ArrayList<>(components);
Collections.reverse(reversed);
reversed.forEach(Lifecycle::stop);
}
}
```
The context also exposes typed getters for wired services so `ReflectionHelper`/`EventsHandler`/contexts can resolve dependencies.
**3. Convert singletons to instances behind interfaces** (priority values in parentheses set start order):
- `SettingsProvider` ← `Settings` (config; constructed first, not itself a `Lifecycle`).
- `SessionProvider`/persistence ← `SessionManager` instance implementing `Lifecycle` (10): `start()` builds the `SessionFactory` + runs `DatabaseMigration.migrate()`; `stop()` destroys the registry.
- `ExecutorProvider` ← `ThreadManager` instance implementing `Lifecycle` (20): `stop()` = current `shutdownExecutor()`.
- `CommandRegistry` ← `CommandManager` instance implementing `Lifecycle` (30): `start()` = `init()`; `registerSlashCommands(jda)` called after JDA is ready.
- `TaskManager` interface ← impl implementing `Lifecycle` (30): `start()` = current `init()`.
- Scheduled tasks ← `ScheduledTaskManager` instance implementing `Lifecycle`: `stop()` = `cancelAll()`.
- `MusicService` ← new factory owning the shared `AudioPlayerManager` + per-guild `MusicManager` map, implementing `Lifecycle` (40): `start()` = `initPlayerManager()`; `stop()` = `disconnectPlayers()`. Per-guild `MusicManager` becomes a plain instance created by the factory.
- `GuildServiceProvider` ← registry owning the guild cache (replaces static `GuildService.get`).
- `WebApi` becomes a `Lifecycle` (60): `start()` binds the HTTP server; `stop()` stops it.
- JDA is built by the context (replacing static `jda`) and exposed via a getter/provider.
**4. Rework reflective instantiation** — extend `ReflectionHelper` so `loadClassesFromPackage` accepts a factory/`ApplicationContext` and instantiates via a constructor that takes the needed dependencies (falling back to no-arg for classes that declare it). `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` pass the context so commands/tasks are constructor-injected.
**5. Migrate consumers off `getInstance()`** — command/task base classes (`core/tasks/impl/AbstractTask.java`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand.java`) and contexts (`core/context/AbstractContext.java`, `context/**`) take dependencies via constructor. `EventsHandler` is constructed with the context and passes services when building contexts. During migration, keep **temporary static shims** (e.g. `Settings.getInstance()` delegating to the context instance) so untouched call sites keep compiling; remove them at the end.
**6. New bootstrap** — `NetNeutrality.main()` builds the `ApplicationContext`, constructs and `register()`s services in dependency order, calls `context.start()`, and installs **one** shutdown hook calling `context.stop()`. Static `instance`/`jda` are removed.
### Data Models / Contracts
Key new interfaces (illustrative signatures):
```java
interface SettingsProvider { String get(String key); String get(String key, String def); String getPrefix(); }
interface SessionProvider extends Lifecycle { org.hibernate.SessionFactory getSessionFactory(); }
interface ExecutorProvider extends Lifecycle { java.util.concurrent.ExecutorService getExecutor(String name); java.util.concurrent.ScheduledExecutorService getScheduledExecutor(); }
interface CommandRegistry extends Lifecycle { CommandContainer getCommandContainer(String name); void registerSlashCommands(JDA jda); }
interface TaskManager extends Lifecycle { Task getTask(String id); void insert(CommandContext ctx, Task task); void handle(TaskContext ctx); /* ... */ }
interface MusicService extends Lifecycle { MusicManager get(net.dv8tion.jda.api.entities.Guild guild); }
interface GuildServiceProvider { GuildService get(String guildId); }
```
### File Structure
New DI classes are created directly in their **target** packages (from the Package Structure tab) so they are not moved twice.
- **Added**: `bootstrap/Lifecycle.java`, `bootstrap/ApplicationContext.java`; service interfaces `platform/config/SettingsProvider.java`, `platform/persistence/SessionProvider.java`, `platform/concurrency/ExecutorProvider.java`, `framework/command/CommandRegistry.java`, `framework/task/TaskManager.java` [interface] + impl, `feature/music/MusicService.java`, `data/service/GuildServiceProvider.java`.
- **Moved / renamed (package restructure)**: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data`; `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`; `utils` dissolved into `platform.config` / `platform.persistence` / `bootstrap` / `shared.util`; `NetNeutrality` → `App`. See the Package Structure tab.
- **Modified**: `App.java` (bootstrap), `ReflectionHelper.java` (constructor injection), all converted managers/services, command/task base classes, `EventsHandler.java`, `WebApi.java`, HTTP handlers, `getInstance()` call sites, and `pom.xml` / `Dockerfile` / `startup.sh` for the `mainClass` rename.
- **Docs**: update `docs/architecture.md` (startup/shutdown + package map) and `docs/utilities-and-bootstrap.md` (bootstrap) to reflect the context and new layout.
### Architecture Diagram
```mermaid
graph TD
Main["NetNeutrality.main()"] --> Ctx[ApplicationContext]
Ctx -->|constructs & wires| Settings[SettingsProvider]
Ctx -->|register + start/stop| Session[SessionProvider]
Ctx -->|register + start/stop| Exec[ExecutorProvider]
Ctx -->|register + start/stop| Cmd[CommandRegistry]
Ctx -->|register + start/stop| Tasks[TaskManager]
Ctx -->|register + start/stop| Music[MusicService]
Ctx -->|register + start/stop| Web[WebApi]
Ctx -->|builds| JDA[JDA]
Cmd -->|constructor-injects| Refl[ReflectionHelper]
Tasks -->|constructor-injects| Refl
Refl -->|ctor deps| Impl[Commands / Tasks]
JDA -->|events| Events[EventsHandler]
Events -->|builds contexts with deps| Cmd
Events --> Tasks
```
### Risks
- **Large call-site surface (~110 usages)** — mitigated by temporary static shims delegating to context instances, migrating area-by-area, keeping the build green.
- **Startup ordering regressions** — mitigated by encoding order via `Lifecycle.priority()` and mirroring the exact sequence from `docs/architecture.md`.
- **Reflective constructor resolution** — commands/tasks must expose a consistent injectable constructor; mitigate by supporting a no-arg fallback and validating all classes load at startup.
- **Per-guild `MusicManager` vs shared factory** — clarify that the shared `AudioPlayerManager` and cache move to the `MusicService`, while per-guild `MusicManager` stays a lightweight instance.
- **`SessionManager.System.exit(1)` on DB failure** — preserve fail-fast behavior inside the new `SessionProvider.start()`.
# Package Structure
### Current Layout Problems
The tree today mixes three organizing principles at once: abstraction-vs-impl (`core.command`↔`command`, `core.context`↔`context`, `core.tasks`↔`tasks`), infra-vs-data (`core.db`↔`db`), and no split at all (`music`, `http`, `utils`, `hooks`). Consequences:
- `core` is simultaneously "the engine" and "misc" — it holds the framework sub-packages **and** loose classes (`LogHandler`, `BotState`).
- A single feature is scattered: music lives in `core.command.impl.MusicCommand`, `command.music`, `context.MusicChannelContext`, `tasks.music`, and `music/` — five places.
- `utils` is a catch-all mixing config (`Settings`), reflection (`ReflectionHelper`), JPA glue (`JpaUtils`), and formatting (`MessageFunction`, `UserFunction`).
- Naming drift: `db.entities` (plural) vs. the singular convention elsewhere.
### Guiding Principle
Separate code by the reason it changes:
- **framework** — the reusable bot engine (command/task/context machinery); rarely changes, knows nothing about specific commands.
- **platform** — cross-cutting technical services (persistence, concurrency, config, logging, event dispatch, web); changes when tech/tooling changes.
- **feature** — concrete commands + tasks + contexts + domain logic, grouped **by feature vertical**; changes most often.
- **data** — the persistence model (entities, repositories, services).
- **shared** — genuinely cross-feature leaf helpers.
### Target Layout
```
me.bobert.netneutrality
├── App.java // was NetNeutrality (main + bootstrap entry)
├── bootstrap/ // ApplicationContext, Lifecycle, ReflectionHelper (composition root)
├── framework/ // the reusable engine (was core, minus the misc)
│ ├── command/ // Command, CommandProcessor, CommandRegistry, options, build, impl bases
│ ├── context/ // Context hierarchy, reply/
│ └── task/ // Task, TaskManager, StepTask/PersistableTask, persistence/, trait/, impl bases
├── platform/ // cross-cutting technical services (horizontals)
│ ├── config/ // Settings / SettingsProvider
│ ├── persistence/ // SessionProvider, DatabaseMigration, Repository base, JpaUtils
│ ├── concurrency/ // ExecutorProvider, ScheduledTaskManager, BaseThreadFactory
│ ├── logging/ // LogHandler
│ ├── event/ // EventsHandler (was hooks)
│ └── web/ // WebApi, handlers, WebModule (was http)
├── feature/ // concrete features, one vertical per package
│ ├── music/ // MusicService/MusicManager, QueueManager, scheduler, source/, + music commands/tasks/contexts
│ ├── moderation/ // blacklist, purge, admin commands + related services
│ ├── reaction/ // reaction commands + emoji-role tasks
│ ├── git/ // report-issue command + task
│ ├── crypto/
│ └── help/
├── data/ // the persistence model (was the "db" half of the split)
│ ├── entity/ // O* entities (entities → entity, singular)
│ ├── repository/
│ └── service/ // GuildService, GuildServiceProvider, BlacklistService, …
├── shared/util/ // MiscUtils, MessageFunction, UserFunction, GuildUtils, ContextUtils
└── exceptions/ // unchanged (already coherent)
```
### Key Moves
- `core` → `framework`, now containing **only** the engine (no `LogHandler`, no `BotState`, no `db`, no `thread`).
- `core.db` (infra) → `platform.persistence`; `db` (model) → `data` — resolves the "`db` means two things" problem. `db.entities` → `data.entity`.
- `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
- `utils` dissolved: config → `platform.config`, JPA glue → `platform.persistence`, reflection → `bootstrap`, formatting/misc → `shared.util`.
- All music code consolidates under `feature.music`; the other verticals group their command/task/context/service triplets similarly.
- `LogHandler` → `platform.logging`; `BotState` → `bootstrap` (composition-root-adjacent state).
### Sequencing & Rationale
The repackage is done **immediately after the DI wiring, in the same effort**: the DI refactor already renames managers (`CommandManager`→`CommandRegistry`, `SessionManager`→`SessionProvider`, `ThreadManager`→`ExecutorProvider`) and touches imports at ~110 call sites, so combining them avoids editing the same imports twice. Moves use the IDE's safe **"Move Class"** refactor (references update automatically), one subsystem at a time, building after each. New DI classes are created directly in their target packages rather than moved later.
### Lower-Risk Variant (optional fallback)
If full feature-first grouping feels too large in one pass, a high-value subset fixes ~80% of the confusion with far fewer moves: rename `core` → `framework`, extract `platform` (evict `LogHandler`, `BotState`, `core.db`, `core.thread`, `hooks`, `http`), and dissolve `utils` — while leaving the concrete `command`/`task`/`context` packages where they are. The trade-off is that a single feature stays spread across those layer packages.
### Risks
- **Move touches nearly every import** — mitigated by IDE "Move Class" (auto-updates references), one subsystem per step, and a build after each move.
- **Main-class rename `NetNeutrality` → `App`** — must update the Shade `mainClass` in `pom.xml`, the `Dockerfile`/`startup.sh` entry, and docs atomically, or the fat JAR won't launch.
- **Feature-first churn** — grouping music/moderation/etc. is the largest move; the lower-risk variant above is the fallback if needed.
# Testing
### Validation Approach
The refactor is behavior-preserving, so validation focuses on: (a) the project compiles and the fat JAR builds after each stage, (b) existing tests still pass, and (c) new unit tests prove services are now mockable/instance-based. Use the existing Maven + JUnit 5 + Mockito setup (`./mvnw test`); Checkstyle runs in the `test` phase and must stay green.
### Key Scenarios
- **Build & Checkstyle** — `./mvnw package` succeeds after every stage (shade fat JAR, main class unchanged).
- **ApplicationContext ordering** — a unit test registers fake `Lifecycle` components with different `priority()` values and asserts `start()` runs ascending and `stop()` runs descending.
- **Command/task loading** — assert `CommandManager`/`TaskManager` still discover and instantiate all implementations via `ReflectionHelper` with constructor injection (no `getInstance()` calls at runtime).
- **Existing tests** — `CommandProcessorTest`, `SettingsTest`, and any others continue to pass; update them to use injected instances/mocks instead of wrestling with singletons.
### Edge Cases
- Missing `NETNEUTRALITY_BOT_TOKEN` → startup still fails fast with a clear error.
- DB connection failure inside `SessionProvider.start()` → preserves current fail-fast (Sentry capture in non-dev + exit).
- A command/task class that only declares a no-arg constructor → `ReflectionHelper` still loads it via the fallback path.
- Shutdown with services that never fully started → `stop()` must be null-safe (mirroring current guards like `jda != null`, `registry != null`).
### Test Changes
- **Add**: `ApplicationContextTest` (ordering + symmetric start/stop), a `ReflectionHelper` constructor-injection test.
- **Update**: `SettingsTest` and `CommandProcessorTest` to construct instances/mocks of the new interfaces instead of relying on static singletons.
- **Skip**: no integration test against a live Discord gateway or real MySQL (out of scope).
# Delivery Steps
### ✓ Step 1: Introduce Lifecycle contract and ApplicationContext bootstrap
The app boots through an `ApplicationContext` that drives ordered start/stop, while behavior stays identical via temporary shims.
- Add `me.bobert.netneutrality.bootstrap.Lifecycle` interface with `start()`, `stop()` (default no-op), and `priority()` (default 0, lower = started earlier / stopped later).
- Add `me.bobert.netneutrality.bootstrap.ApplicationContext` that registers components, sorts by `priority()`, starts ascending and stops descending, and exposes typed getters for wired services.
- Refactor `NetNeutrality.main()`/`startup()` to build the context and register the existing (still static) steps as thin `Lifecycle` adapters, install a single shutdown hook calling `context.stop()`, and remove static `instance`/`jda` (temporarily bridged where needed).
- Add `ApplicationContextTest` asserting ascending start and descending stop ordering.
### ✓ Step 2: Convert foundational services to instances behind interfaces
Configuration, persistence, and executors are constructor-injected instances behind interfaces, wired into the context with correct lifecycle order.
- Introduce `SettingsProvider` implemented by `Settings` (instance, constructed first); keep `Settings.getInstance()` as a temporary shim delegating to the context instance.
- Convert `core/db/SessionManager` into a `SessionProvider` instance implementing `Lifecycle` (priority ~10): `start()` builds the `SessionFactory` and runs `DatabaseMigration.migrate()`, `stop()` destroys the registry; preserve fail-fast on DB errors.
- Convert `core/thread/ThreadManager` into an `ExecutorProvider` instance implementing `Lifecycle` (priority ~20) with `stop()` = current `shutdownExecutor()`.
- Update `JpaUtils`/`Repository`/`db/**` call sites andhihims to use the injected providers.
### ✓ Step 3: Convert domain managers (command, task, scheduled, music, guild) to instances behind interfaces
The command/task/music/guild managers are instance-based `Lifecycle` components behind interfaces, registered in the context.
- Introduce `CommandRegistry` (impl = `CommandManager`, priority ~30): `start()` = `init()`; `registerSlashCommands(jda)` invoked after JDA is ready.
- Introduce a `TaskManager` interface + impl (priority ~30) implementing `Lifecycle` with `start()` = current `init()`; convert `ScheduledTaskManager` to an instance whose `stop()` = `cancelAll()`.
- Introduce `MusicService` factory owning the shared `AudioPlayerManager` and per-guild `MusicManager` map (priority ~40): `start()` = `initPlayerManager()`, `stop()` = `disconnectPlayers()`; make per-guild `MusicManager` a plain instance created by the factory.
- Introduce `GuildServiceProvider` owning the guild cache, replacing static `GuildService.get(...)`.
- Keep static shims on each so untouched call sites keep compiling.
### ✓ Step 4: Enable constructor injection for reflectively-loaded commands and tasks
Commands and tasks receive their dependencies via constructor instead of calling `getInstance()`.
- Extend `ReflectionHelper.loadClassesFromPackage` to accept the `ApplicationContext` and instantiate via a constructor taking the required dependencies, with a no-arg fallback for classes that declare it.
- Update `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` to pass the context.
- Update command/task base classes (`core/tasks/impl/AbstractTask`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand`) and contexts (`core/context/AbstractContext`, `context/**`, `EventsHandler`) to store injected dependencies as final fields.
- Add a `ReflectionHelper` test proving constructor injection and no-arg fallback both work.
### ✓ Step 5: Remove static shims, finalize interfaces, and update tests/docs
All singletons/`getInstance()` accessors are gone; services are wired only through the context, and tests/docs reflect the new model.
- Migrate remaining `getInstance()`/static call sites (commands, tasks, HTTP handlers in `http/server/**`, `LogHandler`, `BotState`) to injected interfaces and delete the temporary static shims and singleton fields.
- Convert `WebApi` into a `Lifecycle` component (priority ~60) with `start()`/`stop()` for the HTTP server.
- Update `SettingsTest` and `CommandProcessorTest` to use injected instances/mocks of the new interfaces; ensure `./mvnw package` (including Checkstyle) is green.
- Update `docs/architecture.md` (startup/shutdown lifecycle) and `docs/utilities-and-bootstrap.md` (bootstrap) to describe the `ApplicationContext` + `Lifecycle` design.
### ✓ Step 6: Reorganize packages into bootstrap/framework/platform/feature/data/shared
The codebase is regrouped by reason-of-change into the target layout (see the Package Structure tab), with all references updated and the build green.
- Rename the entry point `NetNeutrality` → `App`, and update the Shade `mainClass` in `pom.xml`, plus the `Dockerfile`/`startup.sh` launch entry.
- Using the IDE's safe "Move Class" refactor, one subsystem per step: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data` (`db.entities` → `data.entity`); `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
- Dissolve `utils`: `Settings`/`SettingsProvider` → `platform.config`, `JpaUtils` → `platform.persistence`, `ReflectionHelper` → `bootstrap`, formatting/misc helpers → `shared.util`; move `LogHandler` → `platform.logging` and `BotState` → `bootstrap`.
- Consolidate each feature vertical (music, moderation, reaction, git, crypto, help) into `feature.*`, pulling its commands/tasks/contexts/services together.
- Build and run tests after each subsystem move; update `docs/architecture.md` "Package map" to the new layout.
-95
View File
@@ -1,95 +0,0 @@
---
sessionId: session-260905-173932-36te
---
# Requirements
### Overview & Goals
Improve the `MusicManager` class for better maintainability, testability, and separation of concerns.
### Scope
**In Scope:**
- Refactor `MusicManager` to reduce responsibilities
- Extract vote-skip logic into a dedicated service
- Fix synchronization strategy
- Improve error handling
- Eliminate magic numbers
**Out of Scope:**
- Changing `QueueManager` or `TrackScheduler` internals (beyond reducing circular refs)
- Changing external behavior or command interfaces
# Technical Design
### Current Issues
1. **Vote-skip business logic in MusicManager** (lines 113–145): The `voteSkip()` method contains complex voting logic (threshold calculation, member counting, skip decisions) that doesn't belong in a player manager. Magic number `2.1` is used as a vote divisor with no explanation.
2. **Circular dependencies**: `MusicManager` → `TrackScheduler` → `QueueManager` → `TrackScheduler` → `MusicManager`. This makes testing and reasoning about the code difficult.
3. **Overly broad `synchronized` methods**: Nearly every public method is `synchronized` on `this`, even simple getters like `getCurrentlyPlaying()` and `isCurrentlyPlaying()`. This creates unnecessary contention. Methods like `getQueueSize()` aren't synchronized at all, creating inconsistency.
4. **Poor error handling in `connect()`** (line 80): Throws a bare `RuntimeException` when `audioChannel` is null instead of using `IllegalArgumentException` or returning a `CommandExecutionResult`.
5. **`baseVolume` parsed from String without error handling** (line 44): `Integer.parseInt()` can throw `NumberFormatException` with no fallback.
### Proposed Changes
#### 1. Extract `VoteSkipService`
- New class: `me.bobert.netneutrality.music.VoteSkipService`
- Moves all vote-skip logic out of `MusicManager`
- Replace magic `2.1` with a named constant `VOTE_THRESHOLD_DIVISOR` or configurable guild setting
- `MusicManager.voteSkip()` delegates to the new service
#### 2. Reduce circular dependencies
- `TrackScheduler` and `QueueManager` should not need to call back into `MusicManager` for `disconnect()`. Instead, use a callback/listener interface (e.g., `MusicManagerCallback`) that `TrackScheduler` holds, breaking the direct circular reference.
#### 3. Fix synchronization
- Remove `synchronized` from simple delegating methods (`getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`) — `AudioPlayer` is already thread-safe.
- Keep `synchronized` only on methods that coordinate multiple state changes (`connect()`, `disconnect()`, `play()`).
- Make `getQueueSize()` consistent with the chosen strategy.
#### 4. Improve error handling
- `connect()`: Replace `RuntimeException` with `IllegalArgumentException("audioChannel must not be null")`
- `baseVolume`: Add try-catch with a sensible default (e.g., 50) and a warning log.
#### 5. Extract constants
- `VOTE_THRESHOLD_DIVISOR = 2.1` (or make configurable)
- `MIN_MEMBERS_FOR_VOTE = 3`
- `DEFAULT_VOLUME = 50`
### File Structure
| File | Action |
|------|--------|
| `music/MusicManager.java` | Modify — remove vote logic, fix sync, fix error handling |
| `music/VoteSkipService.java` | **New** — extracted vote-skip logic |
| `music/MusicManagerCallback.java` | **New** — interface to break circular dependency |
| `music/TrackScheduler.java` | Modify — use callback interface instead of direct `MusicManager` reference where possible |
| `music/QueueManager.java` | Modify — remove `getMusicManager()` call chain for disconnect |
# Delivery Steps
### ✓ Step 1: Extract VoteSkipService and constants
Vote-skip logic is isolated in a new service class and magic numbers are replaced with named constants.
- Create `VoteSkipService.java` in `me.bobert.netneutrality.music`
- Move the vote-skip logic from `MusicManager.voteSkip()` into `VoteSkipService.voteSkip(CommandContext, QueueManager, AudioChannel)`
- Define constants: `VOTE_THRESHOLD_DIVISOR`, `MIN_MEMBERS_FOR_VOTE`
- Update `MusicManager.voteSkip()` to delegate to the new service
- Add `DEFAULT_VOLUME` constant and wrap `baseVolume` parsing in try-catch with fallback
### ✓ Step 2: Fix synchronization and error handling
Synchronization is applied only where needed and error handling is improved.
- Remove `synchronized` from simple delegating methods: `getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`, `setVolume()`
- Keep `synchronized` on `connect()`, `disconnect()`, `play()`, `isNotConnectedTo()`
- Replace `RuntimeException` in `connect()` with `IllegalArgumentException`
- Ensure `getQueueSize()` follows the same synchronization strategy as other methods
### ✓ Step 3: Reduce circular dependencies between MusicManager, TrackScheduler, and QueueManager
The circular reference chain is broken using a callback interface.
- Create `MusicManagerCallback.java` interface with method `void onQueueEmpty()` (or similar) to replace direct `MusicManager.disconnect()` calls from `QueueManager`
- Have `MusicManager` implement the callback interface
- Update `TrackScheduler` constructor to accept the callback interface instead of the full `MusicManager` where only disconnect/connection behavior is needed
- Update `QueueManager.clearQueue()` to use the callback instead of `scheduler.getMusicManager().disconnect()`
-213
View File
@@ -1,213 +0,0 @@
---
sessionId: session-260919-132150-1vmv
---
# Requirements
### Overview & Goals
The `TaskManager` subsystem in Net Neutrality manages the in-memory registry, execution dispatch, locking, persistence, and lifecycle transitions for Discord interaction tasks (buttons, modals, reactions, step-by-step flows, and scheduled background tasks).
This plan addresses several bugs, synchronization oversights, memory leak potentials, and architectural discrepancies between `docs/tasks.md` and `TaskManagerImpl.java`:
1. **Fix Parent-Child Lifecycle Propagation**: Correctly handle `REMOVE_SELF_AND_CHILDREN` when triggered by child tasks (`WithParent`).
2. **Synchronize DB Persistence on Removal**: Ensure `PersistableTask` entries are purged from the database via `TaskService.unpersistTask()` when removed.
3. **Prevent Memory Leaks in Pending Tasks**: Unify `pendingTasks` tracking and ensure `TaskCleanup` cleans up expired pending step tasks.
4. **Fix Concurrency & Lock Cleanup Hazards**: Eliminate race conditions where lock eviction during task execution allows duplicate concurrent runs.
5. **Optimize Lookup Performance**: Replace eager full-collection queries with short-circuiting streams for partial ID lookups.
6. **Improve Test Coverage**: Introduce thorough unit tests covering all task lifecycle paths, locking behavior, and persistence hooks.
### Scope
- **In Scope**:
- `me.bobert.netneutrality.core.tasks.TaskManager` interface.
- `me.bobert.netneutrality.core.tasks.TaskManagerImpl` implementation.
- `me.bobert.netneutrality.scheduled.cleanup.TaskCleanup` expiration handling.
- Interaction deletion error-handling callbacks on `InteractionHook`.
- Comprehensive unit test suite for `TaskManagerImpl`.
- **Out of Scope**:
- Refactoring concrete task business logic (e.g., individual music or reaction commands).
- Schema changes to the `OTask` entity or database migrations.
### Functional Requirements
- **FR-1**: When any child task implementing `WithParent` returns `Lifecycle.REMOVE_SELF_AND_CHILDREN`, `TaskManager` must remove the parent task and all associated child tasks from the registry.
- **FR-2**: When any `PersistableTask` is removed from `TaskManager` (via lifecycle completion, manual removal, or cancellation), `TaskManager` must call `taskService.unpersistTask()` so the task is deleted from persistent storage.
- **FR-3**: Pending step tasks must be tracked reliably, exposed for expiration checks, and cleaned up by `TaskCleanup` if abandoned.
- **FR-4**: Concurrent task invocations sharing the same `lockId` must be serialized safely without lock collisions or premature lock disposal.
- **FR-5**: `findTaskByPartialId` must short-circuit upon finding the first match without allocating full intermediate lists.
- **FR-6**: Interaction deletion via `Deletable` must gracefully handle expired hooks and Discord API errors asynchronously.
# Technical Design
### Current Implementation
`TaskManagerImpl` (`me.bobert.netneutrality.core.tasks.TaskManagerImpl`) implements `TaskManager` and `Lifecycle`. It maintains:
- `tasks`: `ConcurrentHashMap<String, Task>` for active tasks.
- `pendingTasks`: `CopyOnWriteArrayList<Task>` for unassigned step tasks.
- `deletableTasksContext`: `ConcurrentHashMap<String, CommandContext>` for tracking interaction hooks to delete.
- `tasksLock`: `ConcurrentHashMap<String, ReentrantLock>` for per-task/per-parent synchronization.
- `taskService`: `TaskService` for loading and persisting `PersistableTask` entities.
### Key Issues & Decisions
#### 1. Parent/Child Task Lifecycle Resolution
- **Issue**: `handleTaskLifecycle` only checks `if (task instanceof WithChildren)` for `REMOVE_SELF_AND_CHILDREN`. When a button task (which implements `WithParent<P>`, such as `LoadTrackFromPlaylistTask`) returns `REMOVE_SELF_AND_CHILDREN`, the check evaluates to `false`, leaving the parent and sibling buttons active in memory.
- **Decision**: In `handleTaskLifecycle`, check if the task implements `WithParent<?>`. If so, obtain the parent task (`taskWithParent.getParentTask()`) and remove the parent along with all of its child tasks (`parent.getChildTasks()`). If the task directly implements `WithChildren`, remove all its child tasks and itself.
#### 2. Automatic Persistence Synchronization
- **Issue**: `docs/tasks.md` specifies that persistable tasks removed after execution are deleted from the database. Currently `TaskManagerImpl.removeTask` only removes from `tasks` map and never calls `taskService.unpersistTask(persistableTask)`.
- **Decision**: In `removeTask(Task task)`, check `if (task instanceof PersistableTask persistableTask)` and invoke `taskService.unpersistTask(persistableTask)`.
#### 3. Pending Task Registry & Cleanup
- **Issue**: `pendingTasks` is stored in a `CopyOnWriteArrayList<Task>`. `getTasks()` ignores it, preventing `TaskCleanup` from pruning expired step tasks. `removeTask()` also ignores `pendingTasks`.
- **Decision**: Store `pendingTasks` in a `ConcurrentHashMap<String, Task>` (or unify with active tasks / pending lookup). Update `getTasks()` or provide a method to include pending tasks in cleanup routines. Update `removeTask(Task task)` to remove from `pendingTasks` as well as `tasks`.
#### 4. Concurrency & Lock Management
- **Issue**: `removeTask` calls `tasksLock.remove(task.getLockId())` immediately. If another thread is waiting or about to acquire the lock for a sibling task, it can get a new lock instance from `computeIfAbsent`, breaking mutual exclusion.
- **Decision**: Maintain lock instances safely; do not remove locks eagerly while in the middle of executing handlers. Persistable tasks with state should also participate in locking if needed, or maintain clear locking semantics.
#### 5. Lookup API Optimization
- **Issue**: `findTaskByPartialId` calls `findTasksByPartialId`, instantiating and populating a full `ArrayList` before taking the first element.
- **Decision**: Implement `findTaskByPartialId` with `.filter(...).findFirst()` directly on the stream.
### Architecture & Component Interaction Diagram
```mermaid
graph TD
subgraph Discord Events
BE[ButtonInteractionEvent] --> EH[EventsHandler]
ME[ModalInteractionEvent] --> EH
RE[MessageReactionAddEvent] --> EH
MSG[MessageReceivedEvent] --> EH
end
subgraph TaskManager Subsystem
EH -->|TaskContext| TM[TaskManagerImpl]
TM -->|Acquire Lock| TL[tasksLock Map]
TM -->|Lookup Task| TR[tasks & pendingTasks Maps]
TM -->|Execute| T[Task.execute]
T -->|Return Lifecycle| TM
TM -->|Cascade Cleanup| LC[handleTaskLifecycle]
LC -->|WithParent / WithChildren| RM[removeTask]
RM -->|Unpersist| TS[TaskService]
TS -->|Delete| DB[(Database / OTask)]
RM -->|Deletable| DH[InteractionHook.deleteOriginal]
end
subgraph Background Maintenance
TC[TaskCleanup] -->|Periodic Poll| TM
TM -->|Prune Expired| RM
end
```
### Proposed Code Changes & Contracts
#### `TaskManagerImpl.java`
```java
private void handleTaskLifecycle(Task task, Task.Lifecycle status) {
if (status == null) {
throw new IllegalArgumentException("Task lifecycle status cannot be null");
}
switch (status) {
case REMOVE_SELF -> removeTask(task);
case REMOVE_SELF_AND_CHILDREN -> {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent instanceof WithChildren parentWithChildren) {
for (Task child : parentWithChildren.getChildTasks()) {
removeTask(child);
}
}
removeTask(parent);
} else if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
removeTask(child);
}
removeTask(task);
} else {
removeTask(task);
}
}
case KEEP, PARENT, BUILTIN -> {
// Maintained in active registry
}
}
}
@Override
public void removeTask(Task task) {
if (task == null) return;
String id = task.getId();
boolean removed = (tasks.remove(id) != null);
removed |= (pendingTasks.remove(task) != null);
if (removed) {
logger.debug("Removing task: {}", id);
if (task instanceof PersistableTask persistableTask) {
taskService.unpersistTask(persistableTask);
}
}
}
```
### File Structure Changes
- **Modified**: `src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java`
- **Modified**: `src/main/java/me/bobert/netneutrality/scheduled/cleanup/TaskCleanup.java`
- **Added**: `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java`
# Testing
### Validation Approach
Automated unit tests will be created in `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` utilizing JUnit 5, AssertJ, and Mockito to validate all functional requirements without requiring live Discord connections or database instances.
### Key Scenarios
1. **Task Registration & Lookup**:
- Insert standard `Task`, verify `getTask(id)` and `hasTask(id)` return `true`.
- Insert duplicate task ID, verify rejected with user notification.
2. **Parent / Child Lifecycle (`REMOVE_SELF_AND_CHILDREN`)**:
- Create parent task implementing `WithChildren` and child task implementing `WithParent`.
- Execute child task returning `REMOVE_SELF_AND_CHILDREN`.
- Verify parent task and all sibling child tasks are removed from the registry.
3. **Persistable Task DB Synchronization**:
- Insert `PersistableTask`, verify `TaskService.persistTask` is called.
- Remove `PersistableTask` via lifecycle completion or `removeTask()`, verify `TaskService.unpersistTask` is invoked.
4. **Step Task Flow**:
- Insert `StepTask` into pending registry.
- Reassign ID via `activateTask(task, oldId)`, verify task moves from pending to active registry under new ID.
5. **Partial ID Search**:
- Query `findTaskByPartialId` and verify it matches correct instance without scanning beyond first match.
6. **Task Expiration & Cleanup**:
- Mock expired active and pending tasks, trigger `TaskCleanup.run()`, verify expired tasks are pruned from both collections.
7. **Concurrency & Locking**:
- Simulate concurrent executions for tasks sharing a `lockId`, confirming mutual exclusion.
# Delivery Steps
### ✓ Step 1: Fix Task Lifecycle Hierarchy and DB Persistence Synchronization
`TaskManagerImpl` correctly propagates parent-child lifecycle actions and synchronizes task deletions with `TaskService`.
- Update `handleTaskLifecycle` in `TaskManagerImpl.java` to support `REMOVE_SELF_AND_CHILDREN` for tasks implementing `WithParent`, resolving the parent task and removing both the parent and all sibling child tasks.
- Modify `removeTask(Task task)` in `TaskManagerImpl.java` to invoke `taskService.unpersistTask(persistableTask)` when `task instanceof PersistableTask`, ensuring persisted records in the database are purged when tasks finish.
- Refactor `deleteInteraction(Task task)` in `TaskManagerImpl.java` to cleanly handle `Deletable` logic for both standalone and parented tasks, adding failure logging to `InteractionHook.deleteOriginal().queue()`.
### ✓ Step 2: Refactor Pending Task Management and Concurrency Locking
Pending tasks and active task locks are managed safely without race conditions or memory leaks.
- Refactor `pendingTasks` in `TaskManagerImpl.java` to use a `ConcurrentHashMap<String, Task>` keyed by task ID for O(1) lookups and consistent state management.
- Update `removeTask(Task task)`, `getTask(String id)`, and `getTasks()` in `TaskManagerImpl.java` to include pending tasks so they can be retrieved, removed, and cleaned up when expired.
- Update `TaskCleanup.java` to ensure expired pending tasks are identified and cleaned up.
- Improve lock management in `TaskManagerImpl.handle(TaskContext context)` so that lock eviction does not interfere with concurrently waiting threads acquiring new lock instances.
### ✓ Step 3: Optimize Task Query Performance and Diagnostic Logging
Task lookup methods use short-circuiting stream pipelines, and diagnostic logging is corrected.
- Optimize `findTaskByPartialId` in `TaskManagerImpl.java` to use `.findFirst()` directly rather than allocating a full list via `findTasksByPartialId`.
- Fix typos in log messages and warnings across `TaskManagerImpl.java` (e.g. `"Aquiring lock"`, `"childs"`).
- Ensure consistent ephemeral fallback messages and handling when unacknowledged interactions or missing tasks are encountered.
### ✓ Step 4: Add Comprehensive Unit Tests for Task Management
A complete unit test suite validates `TaskManagerImpl` functionality across all task types, lifecycles, and concurrency scenarios.
- Create `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` using JUnit 5 and Mockito.
- Implement tests for standard task registration (`insert`), lookup (`getTask`, `findTaskByPartialId`), and removal (`removeTask`).
- Implement tests for parent-child tasks (`WithChildren`, `WithParent`), verifying lock inheritance and `REMOVE_SELF_AND_CHILDREN` cascading removals.
- Implement tests for `StepTask` lifecycle (pending insertion, ID reassignment via `activateTask`, and expiration).
- Implement tests for `PersistableTask` lifecycle, verifying automatic database persistence on insert and unpersisting on removal.
- Implement tests verifying thread synchronization and lock acquisition during task execution.
-188
View File
@@ -1,188 +0,0 @@
---
sessionId: session-260923-111902-5uwc
---
# Requirements
### Overview & Goals
Replace the hand-rolled `com.sun.net.httpserver` stack in `me.bobert.netneutrality.http.server` with Javalin, so the bot's embedded API needs far fewer custom classes and gains correct status codes, JSON handling and error responses. Additionally simplify the outbound `WebModule` HTTP client.
### Scope
**In scope**
- `WebApi` becomes a thin Javalin-based `Lifecycle` component (same priority 70, same `NETNEUTRALITY_API_PORT` setting).
- Delete `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler`; their logic moves into small route-registration units.
- Standardise the API layer on **Gson** — remove `org.json` (`JSONObject`/`JSONArray`) usage from the HTTP server package.
- Correct HTTP semantics: 404 for unknown guilds/paths, 405 for wrong methods, 500 on exceptions, UTF-8 safe bodies.
- Simplify `WebModule` (outbound OkHttp wrapper): extract the static cache, remove duplicated/unused members.
**Out of scope**
- Changing the public URL shape of existing endpoints (`/`, `/actions/*`, `/guilds/*`) — paths and payload keys stay backwards compatible.
- Authentication/authorisation for the API.
- Migrating `org.json` usage elsewhere in the project (e.g. task classes using `WebModule.getAsObject()`).
### Functional Requirements
- `GET /` → HTML liveness page (unchanged text).
- `GET /actions/status` → `{"health":...,"ping":...}`.
- `POST /actions/shutdown` → runs the shutdown hook and exits (accept `GET` too for compatibility if desired).
- `GET /actions/settings/current/{guildId}` → `{"status":"ok","settings":{...}}`, or 404 + `{"status":"error"}` for an unknown guild.
- `POST /actions/settings/reload/{guildId}` → reloads the guild setting cache, `{"status":"true"}`; 404 for an unknown guild.
- `GET /guilds/owner` → array of `{guild_id, owner_id}`.
- `GET /guilds/channels/{guildId}` and `GET /guilds/roles/{guildId}` → arrays of id/name objects; 404 for an unknown guild.
- Requests arriving before JDA is ready must return 503 rather than throwing an NPE.
### Non-Functional Requirements
- Startup/shutdown stays deterministic through `ApplicationContext` — `stop()` must release the port.
- Shaded jar must still run (`maven-shade-plugin`); Javalin/Jetty service files need merging.
- No behavioural regression for existing consumers of the endpoints.
# Technical Design
### Current Implementation
- `WebApi.java` (92 lines) implements `Lifecycle` (priority 70), creates an `HttpServer`, registers three contexts and a fixed pool built from `BaseThreadFactory("web-api")`. Constructed in `App.java:154`.
- `AbstractHandler` offers `sendStringResponse`/`sendJsonResponse`; both hardcode status 200 and use `str.length()` instead of byte length (breaks non-ASCII).
- `ActionHandler` / `GuildHandler` route via `if (path.startsWith(...))` chains and parse ids with `getRequestURI().toString().split("/")`; they mix Gson and `org.json`, never check the HTTP method, and call `jda.getGuildById()` twice in places.
- `WebModule` is an unrelated *outbound* OkHttp wrapper with a builder, a static LRU cache and a `CachedResponse` record; it builds a brand-new `OkHttpClient` per request and has three redundant timeout setters plus unused `elapsedTime`/`errorMessage` accessors.
### Key Decisions
1. **Javalin** (6.x, JDK 17+ / Java 25 compatible) as the embedded server — removes routing, method matching, error mapping and content-type handling code.
2. **Gson only** in the HTTP layer: register a `GsonJsonMapper` on the Javalin config so `ctx.json(obj)` works with plain records/maps; `org.json` disappears from `http/server`.
3. **Route groups instead of handler classes**: `ActionRoutes` and `GuildRoutes` expose a single `static void register(Javalin app, deps...)`, so `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler` are all deleted (4 classes → 2 tiny registrars).
4. **Guild lookup guard**: one private helper resolves `{guildId}` → `Guild` or throws `NotFoundResponse`, replacing repeated null checks.
5. **WebModule**: keep the builder API used by callers, but move the cache into a package-private `ResponseCache`, share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()`.
### Proposed Changes
**`http/server/WebApi.java`**
```java
public class WebApi implements Lifecycle {
private Javalin app;
@Override public int priority() { return 70; }
@Override public void start() {
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
app = Javalin.create(cfg -> cfg.jsonMapper(new GsonJsonMapper()));
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
GuildRoutes.register(app, jdaSupplier);
app.exception(Exception.class, (e, ctx) -> { log.error(...); ctx.status(500).json(Map.of("status","error")); });
app.start(port);
}
@Override public void stop() { if (app != null) { app.stop(); app = null; } }
}
```
**`http/server/GuildRoutes.java`** — `/guilds/owner`, `/guilds/channels/{guildId}`, `/guilds/roles/{guildId}` mapped to `List<Map<String,String>>` and returned via `ctx.json(...)`.
**`http/server/ActionRoutes.java`** — `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
**`http/server/GsonJsonMapper.java`** — ~15 lines implementing Javalin's `JsonMapper` over a shared `Gson`.
**`http/WebModule.java`** — shared client, cache extracted, redundant setters/accessors dropped; public builder methods used by callers remain.
### Data Models / Contracts
Small records replace ad-hoc JSON building, serialized by Gson with the existing key names:
```java
record GuildOwner(String guild_id, String owner_id) {}
record NamedEntity(String channel_id, String channel_name) {}
record StatusResponse(String health, String ping) {}
```
### File Structure
```
src/main/java/me/bobert/netneutrality/http/
WebModule.java (modified)
server/WebApi.java (rewritten, Javalin)
server/GsonJsonMapper.java (new)
server/ActionRoutes.java (new)
server/GuildRoutes.java (new)
server/AbstractHandler.java (deleted)
server/RootHandler.java (deleted)
server/ActionHandler.java (deleted)
server/GuildHandler.java (deleted)
pom.xml (+ io.javalin:javalin, shade transformer)
```
### Architecture Diagram
```mermaid
graph LR
App-->ApplicationContext
ApplicationContext-->WebApi
WebApi-->Javalin
WebApi-->GsonJsonMapper
Javalin-->ActionRoutes
Javalin-->GuildRoutes
ActionRoutes-->GuildServiceProvider
GuildRoutes-->JDA
```
### Risks
- **Shaded jar**: Jetty/Javalin rely on `META-INF/services`; the shade plugin needs `ServicesResourceTransformer`, otherwise startup fails only in the packaged build.
- **Jar size / SLF4J binding**: Jetty logs through SLF4J; project already has log4j + slf4j — check for duplicate bindings.
- **JDA not ready**: `jdaSupplier.get()` may be null; guard with a 503 mapping.
- `System.exit(0)` in the shutdown route must run after the response is flushed.
# Testing
### Validation Approach
Build with `./mvnw -q verify` (checkstyle included), then start the app/`WebApi` on an ephemeral port and exercise every endpoint with `curl`, comparing payload shape against the pre-refactor behaviour.
### Key Scenarios
- `GET /` returns 200 and the HTML liveness text.
- `GET /actions/status` returns 200 `application/json` with `health` and `ping`.
- `GET /guilds/owner` returns a JSON array with `guild_id`/`owner_id` keys.
- `GET /guilds/channels/{id}` and `/guilds/roles/{id}` return arrays with `channel_id`/`channel_name`.
- `GET /actions/settings/current/{id}` returns `{"status":"ok","settings":{...}}`.
### Edge Cases
- Unknown guild id → 404 with `{"status":"error"}` (previously 200).
- Unknown path → 404 (previously echoed the path with 200).
- Wrong method on a route → 405.
- Non-ASCII content in settings values round-trips correctly (old `str.length()` bug).
- `Lifecycle.stop()` frees the port — a second `start()` on the same port succeeds.
### Test Changes
- Add a lightweight JUnit test starting `WebApi` on port 0 with a mocked `Supplier<JDA>` and `GuildServiceProvider`, hitting the routes with Javalin's test client or `java.net.http.HttpClient`.
- Add a `WebModule` test covering cache hit/expiry after the cache extraction.
# Delivery Steps
### ✓ Step 1: Add Javalin dependency and Gson JSON mapper
The project builds with Javalin available and a Gson-backed JSON mapper ready for use.
- Add `io.javalin:javalin` (6.x) to `pom.xml` dependencies.
- Add `ServicesResourceTransformer` to the `maven-shade-plugin` configuration so Jetty/Javalin service files survive shading.
- Create `http/server/GsonJsonMapper.java` implementing Javalin's `JsonMapper` over a shared `Gson` instance.
- Verify `./mvnw -q package` succeeds and the shaded jar still starts.
### ✓ Step 2: Rewrite WebApi as a Javalin Lifecycle component
`WebApi` starts and stops a Javalin server while keeping its Lifecycle contract and the root endpoint.
- Replace the `HttpServer`/`ExecutorService` fields with a `Javalin app` field.
- Build the instance in `start()` with the `GsonJsonMapper`, reading `NETNEUTRALITY_API_PORT` from `SettingsProvider` as today.
- Register `GET /` returning the liveness HTML and delete `RootHandler.java`.
- Add global `exception` and `error` handlers returning 500/404 JSON, plus a 503 guard when `jdaSupplier.get()` is null.
- Implement `stop()` via `app.stop()`; keep `priority() == 70` and the `App.java` construction site unchanged.
### ✓ Step 3: Migrate guild endpoints to GuildRoutes
All `/guilds/*` endpoints are served by Javalin route lambdas with proper status codes and Gson serialization.
- Create `http/server/GuildRoutes.register(Javalin, Supplier<JDA>)`.
- Map `GET /guilds/owner`, `GET /guilds/channels/{guildId}`, `GET /guilds/roles/{guildId}` using path params instead of `split("/")`.
- Return `List<record>` payloads via `ctx.json(...)`, preserving the existing `guild_id`/`owner_id`/`channel_id`/`channel_name` keys.
- Resolve guilds through a shared helper that throws `NotFoundResponse` for unknown ids (single `getGuildById` call).
- Delete `GuildHandler.java`.
### ✓ Step 4: Migrate action endpoints to ActionRoutes
All `/actions/*` endpoints run on Javalin, with `org.json` fully removed from the HTTP server package.
- Create `http/server/ActionRoutes.register(Javalin, Supplier<JDA>, GuildServiceProvider, Runnable)`.
- Map `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
- Replace `JSONObject`/`JSONArray` with Gson-serialized maps/records keeping the same response keys.
- Ensure the shutdown route flushes its response before running the hook and exiting.
- Delete `ActionHandler.java` and `AbstractHandler.java`, and add endpoint tests starting `WebApi` on port 0 with mocked JDA/guild services.
### ✓ Step 5: Simplify the outbound WebModule client
`WebModule` keeps its builder API but sheds duplicated state and its inlined cache.
- Extract the static LRU cache and `CachedResponse` into a package-private `ResponseCache` class in `http/`.
- Share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()` instead of constructing a client per build.
- Collapse the redundant `timeout`/`connectTimeout`/`readTimeout`/`writeTimeout` setters and remove unused accessors (`getErrorMessage`, `elapsedTime` exposure) after checking call sites.
- Add a unit test covering cache hit, expiry and POST bypass; verify existing `WebModule` callers still compile.
-249
View File
@@ -1,249 +0,0 @@
---
sessionId: session-260923-114918-ce7w
---
# Requirements
### Overview & Goals
Replace the in-process **Lavaplayer** audio stack with a remote **Lavalink v4** node driven by the official `dev.arbjerg:lavalink-client`. Audio decoding, source resolution and voice sending move out of the bot process to the Lavalink node, which can then be extended with server-side plugins (YouTube source, LavaSrc, etc.) without touching the bot.
All dependency/plugin examples from the Lavalink docs are Gradle-based and will be **translated to Maven** (`pom.xml` `<repositories>` / `<dependencies>`); the Lavalink node itself is configured via a YAML file mounted into the container, not via any build script.
### Scope
**In Scope**
- Swap `dev.arbjerg:lavaplayer` + `dev.lavalink.youtube:v2` for `dev.arbjerg:lavalink-client` in `pom.xml`.
- Rewrite the `me.bobert.netneutrality.music` package against Lavalink: `MusicServiceImpl`, `MusicManager`, `TrackScheduler`, `QueueManager`, `TrackInfo`, `TrackLoadingHandler`, `StuckTrackRecoveryHandler`; delete `AudioPlayerSendHandler`.
- Route voice connections through Lavalink's `JDAVoiceUpdateListener` / `DirectAudioController` instead of `Guild#getAudioManager()`.
- Add a `lavalink` service plus `lavalink/application.yml` to `docker-compose.yml`, wired to the existing `ejs-api` (yt-cipher) container.
- Update `MessageFunction`, `LoadPlaylistTask`, `LoadTrackFromPlaylistTask` and the music command classes to the new track type.
- Update the affected tests and the docs under `docs/`.
**Out of Scope**
- Porting `music/source/UdioAudioSourceManager` to a Lavalink server plugin — captured as a documented follow-up. Udio playback is temporarily unavailable; the existing code stays in the repo as the porting reference.
- Any change to command UX, queue semantics, DJ rules or vote-skip behaviour.
- Audio filters/equalizer, node load balancing across multiple nodes, and session resuming (Lavalink supports these; not enabled here).
### User Stories
- As a bot operator, I want audio to be served by a separate Lavalink node so that I can add source plugins and restart the bot without interrupting the audio pipeline's configuration.
- As a user, I want `/play`, `/skip`, `/queue`, `/volume`, `/stop`, `/resume`, `/shuffle` and vote-skip to behave exactly as before the migration.
- As a maintainer, I want YouTube cipher/poToken configuration to live in the node's `application.yml` rather than in Java code.
### Functional Requirements
- All 13 commands in `command/music/` keep their current signatures, replies and `CommandExecutionResult` semantics.
- `SourceFinder` keeps classifying URLs and prefixing searches with `ytsearch:`; identifiers are passed to Lavalink's `loadItem` unchanged.
- Queue ordering, duplicate rejection, auto-start on first enqueue, auto-disconnect on empty queue and the two-strike stuck-track recovery are preserved.
- If the Lavalink node is unreachable at startup, the bot still starts; music commands reply with a clear error instead of crashing.
- Udio URLs reply with a "temporarily unsupported" message rather than failing silently.
### Non-Functional Requirements
- The bot no longer ships native audio encoders: the `jdave-api` / `jdave-native-*` dependencies and the `AudioModuleConfig` DAVE wiring in `App.initJDA()` are removed, since DAVE is handled by the node (Lavalink ≥ 4.2.0).
- New settings are read through the existing `SettingsProvider` and documented in `bot.properties.example`.
- Checkstyle (`checkstyle.xml`, bound to the `test` phase) must keep passing.
# Technical Design
### Current Implementation
- `MusicServiceImpl` (priority-40 `Lifecycle`, registered in `App.buildContext()` line 140) owns a `DefaultAudioPlayerManager` and registers YouTube (with `setRemoteCipher(...)`), SoundCloud, Udio and HTTP source managers in `start()`; `stop()` clears queues and calls `player.destroy()`.
- `MusicManager` creates an `AudioPlayer` per guild, sets `AudioPlayerSendHandler` on `guild.getAudioManager()`, opens/closes the voice connection itself, and calls `playerManager.loadItemOrdered(this, identifier, new TrackLoadingHandler(...))`.
- `TrackScheduler extends AudioEventAdapter` and receives `onTrackStart/End/Exception/Stuck` callbacks directly from the player.
- `QueueManager` holds a `Deque<TrackInfo>` and drives playback with `player.startTrack(track.makeClone(), false)` / `player.stopTrack()`.
- `StuckTrackRecoveryHandler` relies on `AudioTrack#makeClone`, `getPosition()` and `setPosition()`.
- `com.sedmelluq.*` types appear in 14 main sources and 5 tests (`QueueManagerTest`, `TrackInfoTest`, `TrackSchedulerTest`, `VoteSkipServiceTest`, `UdioAudioSourceManagerTest`).
- `pom.xml` already declares the `https://maven.lavalink.dev/releases` repository (lines 136–140).
- `docker-compose.yml` runs `db`, `bot` and `ejs_signature_api` (yt-cipher) on the `netneutrality` network; there is no Lavalink node.
### Key Decisions
1. **Official client** — `dev.arbjerg:lavalink-client` (v4-only). `VoiceDispatchInterceptor` is verified present in the bundled JDA 6.4.1 jar, so `JDAVoiceUpdateListener` attaches cleanly.
2. **Central event dispatcher** — `MusicServiceImpl` holds the single subscription to the client's event flux and routes each event to the owning guild's `TrackScheduler`. One `Disposable` to dispose in `stop()`.
3. **Synchronous facade** — `MusicManager` subscribes to the returned `Mono`s internally; command classes keep their current synchronous signatures. Failures are reported via `LogHandler` and the attached `MusicChannelContext`.
4. **Lavalink `Track` everywhere** — `dev.arbjerg.lavalink.protocol.v4.Track` replaces `AudioTrack` in `TrackInfo`, `QueueManager`, `MessageFunction` and the playlist tasks. `Track` is an immutable data class, so `makeClone()` disappears and `setPosition` becomes `player.setPosition(...)` on the Lavalink player.
5. **Node in compose** — a `lavalink` service with a mounted `application.yml`; YouTube cipher/poToken config moves out of `MusicServiceImpl.start()` into that YAML.
6. **Udio deferred** — `music/source/**` is excluded from compilation-relevant wiring and the `Udio` branch of `SourceFinder.getIdentifier` returns `null` with a friendly reply; the classes remain for the future plugin port.
### Proposed Changes
**Maven (`pom.xml`)** — translated from the docs' Gradle snippets:
```xml
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavalink-client</artifactId>
<version>3.4.0</version>
</dependency>
```
resolved from the already-present `arbjergDev-releases` repository. Removed: `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2`, `club.minnced:jdave-*`.
**`MusicServiceImpl`**
- Field `LavalinkClient client = new LavalinkClient(Helpers.getUserIdFromToken(token))`.
- `start()` registers one node (`client.addNode(...)` with URI, password and name from settings) and opens the dispatcher subscription:
`client.on(TrackStartEvent.class)`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`, `WebSocketClosedEvent` → look up `managers.get(event.getGuildId())` → forward to its `TrackScheduler`.
- `stop()` disposes the subscriptions, clears queues, destroys players and closes the client.
- Exposes `LavalinkClient getClient()` in place of `getPlayerManager()`.
**`App`**
- `initJDA()` adds `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` and drops `.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))`.
- Ordering: the `LavalinkClient` must exist before JDA is built, which the existing priorities already guarantee (music = 40, JDA = 60).
- Dev log levels switch from `com.sedmelluq.discord` / `dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
**`MusicManager`**
- Holds `LavalinkClient client` and resolves `Link link = client.getOrCreateLink(guildId)`.
- `connect(AudioChannel)` → `jda.getDirectAudioController().connect(channel)` + `setSelfDeafened`; `disconnect()` → `getDirectAudioController().disconnect(guild)` and `link.destroyPlayer()`.
- `isConnected()` / `isNotConnectedTo(...)` read `guild.getSelfMember().getVoiceState().getChannel()` — **`Guild#getAudioManager()` must not be used** with Lavalink.
- `play(...)` → `link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))`.
- `getCurrentlyPlaying()` returns the cached `Track` from the last `TrackStartEvent` (the REST player state is not synchronously readable).
- `setVolume` / `stopTrack` → `link.getPlayer().flatMap(p -> p.setVolume(v).setEncodedTrack(null))` subscribed internally.
**`TrackScheduler`**
- No longer extends `AudioEventAdapter`; exposes `onTrackStart(Track)`, `onTrackEnd(Track, AudioTrackEndReason)`, `onTrackException`, `onTrackStuck(Track, long)` called by the dispatcher.
- The existing `endReason` branching maps 1:1 onto Lavalink's `AudioTrackEndReason` (`FINISHED`, `LOAD_FAILED`, `STOPPED`, `REPLACED`, `CLEANUP`).
**`QueueManager`**
- `player.getPlayingTrack() == null` becomes a `volatile Track currentTrack` maintained by scheduler events (avoids a blocking REST call inside the `synchronized (queue)` blocks).
- `startTrack(track, noInterrupt)` becomes `link.createOrUpdatePlayer().setTrack(track).setNoReplace(...)`; `makeClone()` calls are dropped since `Track` is immutable.
**`TrackLoadingHandler`** — implements lavalink-client's `AbstractAudioLoadResultHandler`: `ontrackLoaded` / `onPlaylistLoaded` / `onSearchResultLoaded` / `noMatches` / `loadFailed(LoadFailed)`. `onSearchResultLoaded` takes the first result, matching today's `ytsearch:` behaviour.
**`StuckTrackRecoveryHandler`** — re-queues the same immutable `Track` and restores position via `player.setPosition(lastPosition)`; the second consecutive strike still skips.
### Data Models / Contracts
```java
// MusicManager (synchronous facade, unchanged call sites)
public synchronized void play(String query, CommandContext ctx, boolean silent);
public synchronized void stopTrack();
public synchronized void setVolume(int volume);
public synchronized Track getCurrentlyPlaying();
// TrackScheduler (invoked by the dispatcher)
void onTrackStart(Track track);
void onTrackEnd(Track track, AudioTrackEndReason endReason);
void onTrackStuck(Track track, long thresholdMs);
// TrackInfo
public record TrackInfo(Track track, Member requester) { }
```
New settings (read via `SettingsProvider`, documented in `bot.properties.example`):
`NETNEUTRALITY_LAVALINK_URI` (default `ws://lavalink:2333`), `NETNEUTRALITY_LAVALINK_PASSWORD`, `NETNEUTRALITY_LAVALINK_NAME`.
### File Structure
| Path | Change |
|---|---|
| `pom.xml` | swap lavaplayer/youtube-v2/jdave → `lavalink-client` |
| `docker-compose.yml` | add `lavalink` service |
| `lavalink/application.yml` | **new** — node + youtube-source plugin + yt-cipher config |
| `bot.properties.example` | new Lavalink settings |
| `music/MusicServiceImpl.java` | client lifecycle + event dispatcher |
| `music/MusicManager.java` | `Link`/`DirectAudioController` based |
| `music/TrackScheduler.java` | plain event sink |
| `music/QueueManager.java`, `TrackInfo.java`, `TrackLoadingHandler.java`, `StuckTrackRecoveryHandler.java` | `Track` type + REST player calls |
| `music/AudioPlayerSendHandler.java` | **deleted** |
| `music/SourceFinder.java` | Udio → unsupported |
| `App.java` | voice interceptor, drop DAVE wiring, log levels |
| `shared/util/MessageFunction.java`, `tasks/music/LoadPlaylistTask.java`, `tasks/music/LoadTrackFromPlaylistTask.java` | `Track` type |
| `src/test/.../music/*` | updated fixtures |
| `docs/music.md`, `docs/architecture.md`, `docs/README.md` | rewritten sections |
### Architecture Diagram
```mermaid
graph LR
JDA[JDA gateway] -->|voice state| VIL[JDAVoiceUpdateListener]
VIL --> C[LavalinkClient]
C -->|REST and WS| N[Lavalink node]
N --> V[Discord voice]
CMD[music commands] --> MM[MusicManager]
MM -->|Link ops| C
MM --> QM[QueueManager]
C -->|event flux| DISP[MusicServiceImpl dispatcher]
DISP --> TS[TrackScheduler]
TS --> QM
```
### Risks
- **Voice API confusion**: any remaining `guild.getAudioManager()` call silently breaks playback; all call sites (`MusicManager`, `MusicUtils`, `BotState`) must be swept.
- **Blocking on reactor**: subscribing inside `synchronized` blocks in `QueueManager` risks deadlock — hence the cached `currentTrack` instead of a REST read.
- **Node availability**: `MusicServiceImpl.start()` must not throw when the node is down, or `App.startup()` exits with code 1.
- **Feature regressions**: Udio support pauses; direct-MP3 and SoundCloud must be re-verified against the node's enabled sources.
- **DAVE**: removing JDave requires Lavalink ≥ 4.2.0 — the compose image must be pinned accordingly.
# Testing
### Validation Approach
- `./mvnw -q verify` must compile and pass Checkstyle plus the updated unit tests.
- Unit tests keep using mocks: `QueueManagerTest` and `TrackSchedulerTest` mock the Lavalink `Link`/`LavalinkPlayer` and assert on the interactions instead of on `AudioPlayer`.
- A grep sweep confirms no `com.sedmelluq` / `dev.lavalink.youtube` import survives outside `music/source/**`.
- Manual smoke run with `docker compose up` to confirm the bot reaches the node and plays a YouTube URL.
### Key Scenarios
- Enqueue on an empty queue starts playback immediately; enqueue while playing appends.
- `TrackEndEvent(FINISHED)` advances to the next track; an empty queue triggers `onQueueEmpty()` → disconnect.
- `skip(n)`, `shuffle`, `clearQueue`, `removeTracksFromUser` and `removeFromQueue(index)` return the same `CommandExecutionResult` messages as before.
- `ytsearch:` queries resolve through `onSearchResultLoaded` and queue the first hit.
- Volume from `GSettings.MUSIC_VOLUME` is applied on connect, with the 50 fallback on a malformed value.
### Edge Cases
- Lavalink node unreachable at startup → bot boots, music commands reply with an error.
- Node disconnects mid-playback → `WebSocketClosedEvent` is logged and the guild state is cleaned up.
- Stuck track: first event re-queues and seeks, second consecutive event skips.
- Udio URL → explicit "temporarily unsupported" reply.
- Duplicate track enqueue is still rejected by `TrackInfo` equality.
### Test Changes
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest`, `VoteSkipServiceTest` to the Lavalink `Track` type and mocked players.
- Keep `UdioAudioSourceManagerTest` as-is while the source classes remain in the repo for the future plugin port.
# Delivery Steps
### ✓ Step 1: Swap Maven dependencies and stand up a Lavalink node
The project builds against `lavalink-client` and `docker compose up` starts a reachable Lavalink v4 node.
- Replace `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2` and the four `club.minnced:jdave-*` entries in `pom.xml` with `dev.arbjerg:lavalink-client` (Gradle docs snippet translated to Maven, using the existing `arbjergDev-releases` repository).
- Add a `lavalink` service to `docker-compose.yml` on the `netneutrality` network, pinned to an image with DAVE support (≥ 4.2.0), with the bot depending on it.
- Create `lavalink/application.yml` mounted into the node: server port/password, the youtube-source plugin, and the remote cipher pointed at the existing `ejs-api` container plus poToken/visitorData.
- Add `NETNEUTRALITY_LAVALINK_URI`, `_PASSWORD` and `_NAME` to `bot.properties.example`.
### ✓ Step 2: Wire LavalinkClient into the bootstrap and JDA voice handling
The bot connects to the node on startup and routes Discord voice through Lavalink instead of JDA's audio manager.
- Rework `MusicServiceImpl` to own a `LavalinkClient`, registering the configured node in `start()` without throwing when it is unreachable.
- Dispose the client and its subscriptions in `stop()`, keeping the priority-40 `Lifecycle` contract used by `App.buildContext()`.
- Add `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` in `App.initJDA()` and remove the `AudioModuleConfig`/`JDaveSessionFactory` wiring.
- Update the dev-mode log levels in `App.main` from `com.sedmelluq.discord`/`dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
- Replace `getPlayerManager()` with `getClient()` and fix `BotState`'s usage.
### ✓ Step 3: Rebuild MusicManager on Links and the DirectAudioController
Per-guild connect, disconnect and player control go through Lavalink while command signatures stay unchanged.
- Replace the `AudioPlayer` field in `MusicManager` with a `Link` obtained from `client.getOrCreateLink(guildId)`.
- Implement `connect`/`disconnect` via `jda.getDirectAudioController()` and `link.destroyPlayer()`, deleting the `guild.getAudioManager()` calls and the `AudioPlayerSendHandler` class.
- Base `isConnected()` and `isNotConnectedTo(...)` on `getSelfMember().getVoiceState().getChannel()`, and update `MusicUtils.connect`/`checkIfConnected` accordingly.
- Keep the synchronous facade: `stopTrack`, `setVolume` and `play` subscribe to the returned `Mono`s internally and report failures through `LogHandler`.
- Apply the `GSettings.MUSIC_VOLUME` base volume on connect with the existing default-50 fallback.
### ✓ Step 4: Migrate queue, track loading and message rendering to the Lavalink Track type
Tracks are represented by `dev.arbjerg.lavalink.protocol.v4.Track` end to end and loading uses Lavalink's REST resolver.
- Change `TrackInfo` to wrap `Track` + `Member`, preserving equality-based duplicate rejection.
- Update `QueueManager` to drive playback with `createOrUpdatePlayer().setTrack(...)`/`setNoReplace(...)`, dropping `makeClone()` and replacing `player.getPlayingTrack()` checks with a scheduler-maintained `volatile Track currentTrack`.
- Reimplement `TrackLoadingHandler` on `AbstractAudioLoadResultHandler`, mapping `onSearchResultLoaded` to the first hit to preserve the `ytsearch:` behaviour.
- Update `MessageFunction`, `LoadPlaylistTask` and `LoadTrackFromPlaylistTask` to the new track type.
- Make `SourceFinder` return no identifier for Udio URLs and have `MusicManager.play` reply that Udio is temporarily unsupported.
### ✓ Step 5: Route Lavalink events to TrackScheduler and restore stuck-track recovery
Track lifecycle events from the node drive queue advancement, error reporting and stuck recovery exactly as before.
- Add a central dispatcher in `MusicServiceImpl` subscribing once to `TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent` and `WebSocketClosedEvent`, routing by guild id to the cached `MusicManager`.
- Convert `TrackScheduler` from an `AudioEventAdapter` subclass into a plain sink with `onTrackStart`/`onTrackEnd`/`onTrackException`/`onTrackStuck`, keeping the existing `FINISHED`/`LOAD_FAILED`/`STOPPED` branching and `MusicChannelContext` attachment.
- Rework `StuckTrackRecoveryHandler` to re-queue the immutable `Track` and seek with `player.setPosition(...)`, retaining the retry-then-skip rule.
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest` and `VoteSkipServiceTest` to mock `Link`/`LavalinkPlayer`, and verify `./mvnw verify` passes Checkstyle and the suite.
### ✓ Step 6: Update documentation and record the Udio plugin follow-up
The docs describe the Lavalink-based architecture and the deferred Udio work is explicit.
- Rewrite the Lavaplayer sections of `docs/music.md` and `docs/architecture.md` to cover the node, the client, the dispatcher and the new settings.
- Refresh the music mentions in `docs/README.md`, `docs/threading.md` and `docs/utilities-and-bootstrap.md`.
- Document how to run the node locally and how to add server-side plugins via `lavalink/application.yml`.
- Add a follow-up note stating that `music/source/UdioAudioSourceManager` and `UdioAudioTrack` remain only as the reference for a future Lavalink server plugin and that Udio playback is disabled until then.
@@ -1,132 +0,0 @@
---
sessionId: session-260906-073726-sbxb
---
# Requirements
### Overview & Goals
Improve the code quality, correctness, and maintainability of `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService` — the core music queue and playback scheduling subsystem.
### Scope
**In Scope:**
- Fix thread-safety issues (check-then-act race conditions in `QueueManager`)
- Simplify confusing queue insertion logic in `QueueManager.queue()`
- Separate concerns: extract command-result formatting from queue data operations
- Fix `TrackInfo` using wrong logger (`QueueManager.class` instead of `TrackInfo.class`)
- Clean up `TrackScheduler.onTrackStuck()` complexity
- Add unit tests for `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService`
- Expose skip-vote state through `TrackInfo` more cleanly (encapsulate the `Set<String>` skips)
**Out of Scope:**
- Changes to `MusicManager`, `MusicServiceImpl`, or audio source/loading classes
- New user-facing features or commands
- Changes to `MusicManagerCallback` interface
# Technical Design
### Current Implementation
**`QueueManager`** (`src/main/java/me/bobert/netneutrality/music/QueueManager.java`, 286 lines):
- Manages a `LinkedBlockingDeque<TrackInfo>` with manual `synchronized` blocks
- Multiple methods check state outside the synchronized block, then re-check inside (e.g., `clearQueue()` L81 vs L86, `skip()` L197 vs L203, `shuffle()` L229 vs L233, `removeFromQueue()` L254 vs L259) — the outer checks are racy and misleading
- `queue()` method (L135-158) has confusing branching: `addFirst` + `add` can result in the same track being added twice when `player.getPlayingTrack() == null && !queue.isEmpty()`
- `clearQueue()` calls `callback.onQueueEmpty()` before checking if queue is empty (L79)
- `removeTracksFromUser()` iterates the queue outside synchronized (L97), then again inside (L102)
- `skip(int)` silently resets `nbTrack` to 1 if it exceeds queue size (L209) — surprising behavior
- Mixes queue data operations with `CommandExecutionResult` formatting
**`TrackScheduler`** (`src/main/java/me/bobert/netneutrality/music/TrackScheduler.java`, 175 lines):
- `onTrackStuck()` (L129-173) has deeply nested logic with inline async recovery — hard to follow and test
- Circular dependency: creates `QueueManager` in constructor, which takes `this` back
- `context` field is accessed from multiple threads but only `attachContext`/`detachContext` are synchronized — `getContext()`, `getDj()` are not
**`TrackInfo`** (`src/main/java/me/bobert/netneutrality/music/TrackInfo.java`, 83 lines):
- Logger incorrectly uses `QueueManager.class` (L26) instead of `TrackInfo.class`
- `getSkips()` returns mutable `Set<String>` — breaks encapsulation
- Duplicate copyright block (L1-5 and L7-11)
**`VoteSkipService`** (`src/main/java/me/bobert/netneutrality/music/VoteSkipService.java`, 57 lines):
- Relatively clean, but directly calls `queueManager.skip()` mixing vote logic with queue mutation
- `info.addSkip()` uses `context.getGuildUser()` but `info.getSkips().size()` check uses raw set — no race protection
### Proposed Changes
#### QueueManager
1. **Remove all outer unsynchronized checks** — move all logic inside a single `synchronized` block per method to eliminate check-then-act races
2. **Rewrite `queue()` method** — simplify to: add to queue, if nothing playing start the head track. Remove the confusing `addFirst`/`add` dual-path
3. **Fix `clearQueue()`** — move `callback.onQueueEmpty()` call to after the queue is confirmed non-empty and cleared
4. **Fix `skip(int)`** — when `nbTrack > queue.size()`, skip all remaining tracks instead of silently resetting to 1
5. **Fix `removeTracksFromUser()`** — do all filtering inside synchronized block
6. **Return queue operation results as data** (e.g., number removed, track name) and let callers build `CommandExecutionResult` — or at minimum, document that this class returns command results
#### TrackScheduler
1. **Extract stuck-track recovery** into a dedicated `StuckTrackRecoveryHandler` class to reduce `onTrackStuck()` complexity
2. **Make `context` access thread-safe** — synchronize `getContext()` and `getDj()`, or use `volatile`
3. **Add `lastStuckTrack` reset** on track start or successful recovery to prevent stale state
#### TrackInfo
1. **Fix logger** — change to `TrackInfo.class`
2. **Remove duplicate copyright block**
3. **Encapsulate skips** — return `Collections.unmodifiableSet(skips)` from `getSkips()`, add `getSkipCount()` method
#### VoteSkipService
1. **Synchronize vote-skip check** — ensure `addSkip` + threshold check is atomic (or accept eventual consistency and document it)
### File Structure
All changes in `src/main/java/me/bobert/netneutrality/music/`:
- **Modified:** `QueueManager.java`, `TrackScheduler.java`, `TrackInfo.java`, `VoteSkipService.java`
- **New:** `StuckTrackRecoveryHandler.java`
- **New tests:** `src/test/java/me/bobert/netneutrality/command/music/QueueManagerTest.java`, `TrackSchedulerTest.java`, `TrackInfoTest.java`, `VoteSkipServiceTest.java`
# Testing
### Validation Approach
Add unit tests for all four classes using JUnit 5 and Mockito (already in project dependencies via the existing test classes).
### Key Scenarios
- **QueueManager:** queue/dequeue ordering, skip single/multiple, shuffle preserves current track, removeFromQueue bounds, clearQueue idempotency, removeTracksFromUser correctness
- **TrackInfo:** skip vote tracking, duplicate skip prevention, unmodifiable skips set, equals/hashCode
- **VoteSkipService:** threshold calculation, skip when below min members, vote counting, duplicate vote handling
- **TrackScheduler:** onTrackEnd triggers correct queue action per end reason, onTrackStuck recovery flow
### Edge Cases
- Queue with single track: skip, shuffle, remove
- Skip count exceeding queue size
- Empty queue operations
- Concurrent vote-skip from same user
# Delivery Steps
### ✓ Step 1: Fix TrackInfo issues
TrackInfo has correct logger, clean copyright, and encapsulated skip state.
- Fix logger from `QueueManager.class` to `TrackInfo.class` (line 26)
- Remove duplicate copyright block (lines 7-11)
- Change `getSkips()` to return `Collections.unmodifiableSet(skips)`
- Add `getSkipCount()` convenience method
- Add `TrackInfoTest.java` covering skip tracking, duplicate prevention, equals/hashCode, and unmodifiable set enforcement
### ✓ Step 2: Fix QueueManager thread-safety and queue logic
QueueManager has correct synchronization and simplified queue insertion.
- Remove all outer unsynchronized pre-checks in `clearQueue()`, `skip()`, `shuffle()`, `removeFromQueue()`, `removeTracksFromUser()` — consolidate into single synchronized blocks
- Rewrite `queue(AudioTrack, Member)` to eliminate the confusing `addFirst`/`add` dual-path: simply add to tail, then start playback if nothing is playing
- Fix `clearQueue()` to call `callback.onQueueEmpty()` only after confirming queue was non-empty
- Fix `skip(int)` to skip all remaining when `nbTrack > queue.size()` instead of silently resetting to 1
- Fix `removeTracksFromUser()` to do all filtering inside synchronized block
- Add `QueueManagerTest.java` covering queue ordering, skip, shuffle, remove, clear, and edge cases
### ✓ Step 3: Extract stuck-track recovery and fix TrackScheduler thread-safety
TrackScheduler is simplified with extracted recovery logic and thread-safe context access.
- Create `StuckTrackRecoveryHandler.java` encapsulating the retry-or-skip logic currently in `onTrackStuck()` (lines 129-173)
- Simplify `onTrackStuck()` to delegate to the new handler
- Reset `lastStuckTrack` in `onTrackStart()` to prevent stale state
- Synchronize `getContext()` and `getDj()` methods (or make `context` volatile)
- Add `TrackSchedulerTest.java` covering onTrackEnd behavior per end reason and stuck-track recovery delegation
### ✓ Step 4: Improve VoteSkipService robustness and add tests
VoteSkipService has documented concurrency behavior and full test coverage.
- Add documentation comment about the vote-then-check atomicity assumption
- Add `VoteSkipServiceTest.java` covering: threshold math, auto-skip below min members, vote accumulation, duplicate vote from same user, skip trigger when threshold exceeded
-91
View File
@@ -1,91 +0,0 @@
---
sessionId: session-260909-154047-1h6n
---
# Requirements
### Overview & Goals
Remove the singleton pattern from `Settings` so it becomes a plain instantiable class. All access should go through constructor injection of `SettingsProvider`, except for the single bootstrap site in `App.main()` which creates the instance.
### Scope
**In Scope:**
- Remove `static INSTANCE` field and `getInstance()` from `Settings`
- Remove static helper methods `getDiscordInvite()` and `getVersion()` from `Settings`
- Migrate all remaining `Settings.getInstance()` call sites to use injected `SettingsProvider`
- Update the test to construct `Settings` directly
**Out of Scope:**
- Changing the `SettingsProvider` interface
- Introducing a DI framework
- Changing how properties are loaded
# Technical Design
### Current Implementation
- `Settings` is a lazy singleton (`getInstance()`) implementing `SettingsProvider`
- `SettingsProvider` interface already exists and is injected into most classes via `ApplicationContext`
- `App.buildContext()` already registers the Settings instance as `SettingsProvider` in the context
- **Remaining direct singleton usages (9 call sites):**
- `App.java` — `VERSION` static field (line 51), `main()` bootstrap (lines 61-62), `startup()` (line 89), `buildContext()` (line 112), `initJDA()` (line 190)
- `CommandContext.java` — `isMessageFromCreator()` (line 134)
- `WebModule.java` — `createRequest()` (line 289) via `Settings.getVersion()`
- `ReportBug.java` — `executeTask()` (line 34) via `Settings.getDiscordInvite()`
- `SettingsTest.java` — `getMavenVersion()` test (line 17)
### Proposed Changes
#### 1. `Settings.java`
- Make constructor `public`
- Remove `static INSTANCE` field, `getInstance()`, `getDiscordInvite()`, `getVersion()`
- Keep `getMavenVersion()` as an instance method
- Add `getDiscordInvite()` as an instance method on `SettingsProvider` (or keep it as a convenience on `Settings`)
#### 2. `App.java`
- Create `Settings` via `new Settings()` at the top of `main()`; pass it into `App` constructor or store as a field
- Replace `VERSION` static field with an instance field initialized from the settings instance
- Thread the `SettingsProvider` through `startup()`, `buildContext()`, `initJDA()`
#### 3. `CommandContext.java`
- Add a `SettingsProvider` field, accept it via constructor parameter
- Update `isMessageFromCreator()` to use the injected provider
- Update subclasses (`MessageCommandContext`, `SlashCommandContext`, etc.) to pass it through
#### 4. `WebModule.java`
- Accept a version string or `SettingsProvider` in the `Builder`; replace `Settings.getVersion()` call
#### 5. `ReportBug.java`
- Accept `SettingsProvider` via constructor injection; replace `Settings.getDiscordInvite()` with `settings.get("NETNEUTRALITY_BOT_INVITE", ...)`
#### 6. `SettingsTest.java`
- Use `new Settings()` instead of `Settings.getInstance()`
### Risks
- `CommandContext` is abstract with multiple subclasses — all constructors need updating
- `WebModule.Builder` is used in many places including inside `Settings.getGitVersion()` itself — the version string approach (pass a `String` rather than the full provider) keeps this simpler
# Delivery Steps
### ✓ Step 1: Remove singleton from Settings class
Settings can be instantiated with `new Settings()` and no longer holds static state.
- Make the `Settings` constructor `public`
- Remove the `static INSTANCE` field and `getInstance()` method
- Remove static helpers `getDiscordInvite()` and `getVersion()`
- Move `getDiscordInvite()` logic to an instance method (or inline at call sites)
- Keep `getMavenVersion()` as-is (already instance method)
### ✓ Step 2: Migrate App.java bootstrap to explicit instantiation
App creates and threads a single Settings instance through its lifecycle methods.
- Create `new Settings()` at the start of `main()` and store as a field on `App`
- Replace `Settings.getInstance()` calls in `main()`, `startup()`, `buildContext()`, `initJDA()` with the field
- Replace the `VERSION` static field with an instance field or a local derived from the settings instance
### ✓ Step 3: Inject SettingsProvider into remaining call sites
CommandContext, WebModule, and ReportBug no longer reach for the singleton.
- Add `SettingsProvider` constructor parameter to `CommandContext`; update `isMessageFromCreator()` to use it
- Update `CommandContext` subclasses (`MessageCommandContext`, `SlashCommandContext`, etc.) to pass the provider through
- In `WebModule.Builder`, accept a version string parameter; replace `Settings.getVersion()` in `createRequest()`
- In `ReportBug`, add `SettingsProvider` constructor parameter; replace `Settings.getDiscordInvite()` with `settings.get(...)`
- Update `SettingsTest` to use `new Settings()` directly
-214
View File
@@ -1,214 +0,0 @@
---
sessionId: session-260905-145156-xyio
---
# Requirements
### Overview & Goals
Revise the package structure section of the lifecycle refactor plan to use a hybrid layout that keeps the new `bootstrap/` package but reverts the rest to the familiar old-style naming and organization.
### Scope
**In Scope**
- Reverting `framework/` → `core/` (engine: command, context, task machinery)
- Reverting `platform/` hierarchy → flat old-style packages: `db/` (persistence + entities + repositories + services), `hooks/` (events), `http/` (web API), `music/` (music domain)
- Reverting `feature/` vertical grouping → separate top-level `command/`, `tasks/`, `context/` packages with subcategory folders (e.g. `command/music/`, `tasks/scheduled/`)
- Restoring `utils/` for remaining helpers (Settings, JpaUtils, MusicUtils, ChannelMessagePurger)
- Keeping `bootstrap/` (ApplicationContext, Lifecycle, BotState, ReflectionHelper) — this is new and stays
- Keeping `exceptions/` — unchanged
- Keeping `shared/util/` (ContextUtils, GuildUtils, MessageFunction, MiscUtils, UserFunction)
**Out of Scope**
- Any behavioral or DI/lifecycle logic changes — this is purely a package restructure
- Changing the DI refactor plan's other sections (Lifecycle contract, ApplicationContext, constructor injection, interfaces)
### Target Layout
```
me.bobert.netneutrality
├── App.java
├── bootstrap/ # NEW — keep as-is
│ ├── ApplicationContext.java
│ ├── BotState.java
│ ├── Lifecycle.java
│ └── ReflectionHelper.java
├── core/ # was framework/ → revert to core/
│ ├── command/ # Command, CommandProcessor, CommandRegistry, CommandManager, options/, impl/
│ ├── context/ # Context hierarchy, abstract bases
│ └── tasks/ # Task, TaskManager, StepTask, PersistableTask, persistence/, trait/, impl/
├── command/ # concrete command implementations (was feature/*/command/)
│ ├── admin/
│ ├── basic/
│ ├── bot/
│ ├── crypto/
│ ├── custom/
│ ├── git/
│ ├── music/
│ └── react/
├── context/ # concrete context implementations (was framework/context/command/ + task/)
│ ├── command/
│ ├── tasks/
│ └── MusicChannelContext.java
├── tasks/ # concrete task implementations (was feature/*/task/)
│ ├── emoji/
│ ├── generic/
│ ├── git/
│ ├── help/
│ ├── music/
│ ├── reaction/
│ └── scheduled/
├── db/ # persistence model (was data/ + platform/persistence/)
│ ├── entities/ # O* entities
│ ├── repository/
│ ├── service/ # GuildService, GuildServiceProvider, BlacklistService, etc.
│ ├── GSettings.java
│ ├── Repository.java
│ ├── DatabaseMigration.java
│ ├── SessionManager.java # SessionProvider impl
│ └── JpaUtils.java
├── hooks/ # was platform/event/
│ └── EventsHandler.java
├── http/ # was platform/web/
│ ├── server/
│ └── WebModule.java
├── music/ # music domain (was feature/music/ minus commands/tasks)
│ ├── AudioPlayerSendHandler.java
│ ├── MusicManager.java
│ ├── MusicService.java
│ ├── QueueManager.java
│ ├── SourceFinder.java
│ ├── source/
│ ├── TrackInfo.java
│ ├── TrackLoadingHandler.java
│ └── TrackScheduler.java
├── utils/ # remaining helpers not in shared/util/
│ ├── Settings.java # SettingsProvider impl
│ ├── ChannelMessagePurger.java
│ ├── JpaUtils.java # (or in db/, see note)
│ └── MusicUtils.java
├── shared/util/ # NEW — keep as-is
│ ├── ContextUtils.java
│ ├── GuildUtils.java
│ ├── MessageFunction.java
│ ├── MiscUtils.java
│ └── UserFunction.java
├── exceptions/ # unchanged
└── core/thread/ OR thread/ # ThreadManager, ScheduledTaskManager, BaseThreadFactory
```
**Note:** `core/thread/` was the old location for threading. The old layout also had a top-level `thread/` for `MusicManagerCleanupThread`. Both can be restored as they were.
### Key Moves (current → proposed)
| Current (new) | Proposed (hybrid) |
|---|---|
| `framework/command/` | `core/command/` |
| `framework/context/` | `core/context/` |
| `framework/task/` | `core/tasks/` |
| `framework/NetNeutralityBot.java` | remove or merge into App |
| `platform/config/Settings.java` | `utils/Settings.java` |
| `platform/persistence/*` | `db/` (SessionManager, DatabaseMigration, JpaUtils, Repository) |
| `platform/concurrency/*` | `core/thread/` |
| `platform/logging/LogHandler.java` | `core/LogHandler.java` |
| `platform/event/EventsHandler.java` | `hooks/EventsHandler.java` |
| `platform/web/*` | `http/*` |
| `data/entity/` | `db/entities/` |
| `data/repository/` | `db/repository/` |
| `data/service/` | `db/service/` |
| `feature/music/command/*` | `command/music/` |
| `feature/music/*.java` (domain) | `music/` |
| `feature/moderation/command/*` | `command/admin/` |
| `feature/moderation/ChannelMessagePurger.java` | `utils/ChannelMessagePurger.java` |
| `feature/reaction/command/*` | `command/react/` |
| `feature/reaction/task/*` | `tasks/reaction/` |
| `feature/git/command/*` | `command/git/` |
| `feature/git/task/*` | `tasks/git/` |
| `feature/crypto/command/*` | `command/crypto/` |
| `feature/help/command/*` | `command/basic/` + `command/bot/` |
| `feature/help/task/*` | `tasks/help/` |
| `feature/custom/command/*` | `command/custom/` |
| `bootstrap/` | `bootstrap/` (no change) |
| `shared/util/` | `shared/util/` (no change) |
| `exceptions/` | `exceptions/` (no change) |
# Technical Design
### Current Implementation
The codebase currently uses the post-refactor layout with:
- `bootstrap/` — ApplicationContext, Lifecycle, BotState, ReflectionHelper
- `framework/` — command, context, task engine
- `platform/` — config, persistence, concurrency, logging, event, web
- `feature/` — music, moderation, reaction, git, crypto, help, custom (feature-vertical grouping)
- `data/` — entity, repository, service
- `shared/util/` — cross-cutting helpers
- `exceptions/`
All DI/lifecycle wiring (constructor injection, interfaces like `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, `CommandRegistry`, `TaskManager`, `MusicService`, `GuildServiceProvider`) remains intact — only package locations change.
### Key Decisions
1. **Revert engine package name**: `framework/` → `core/` — matches the original naming the team is familiar with.
2. **Flatten infrastructure**: dissolve `platform/` hierarchy back to `db/`, `hooks/`, `http/`, `core/thread/`, `utils/` — removes the extra nesting that added cognitive overhead.
3. **Separate top-level command/tasks/context**: undo feature-vertical grouping in `feature/` — concrete implementations go back to `command/<category>/`, `tasks/<category>/`, `context/` as before.
4. **Keep bootstrap/ and shared/util/**: these are genuinely new and useful additions with no old equivalent.
5. **Restore `db/entities/`**: revert `data/entity/` back to `db/entities/` (plural, matching old convention).
6. **Service interfaces stay**: `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, etc. remain — they just live in their reverted package locations (e.g. `SettingsProvider` in `utils/` alongside `Settings`).
### Proposed Changes
This is a pure move/rename refactor using IDE safe "Move Class" operations. No logic changes. The DI wiring, lifecycle priorities, constructor injection, and all interfaces remain exactly as they are — only their package declarations and import statements change.
### Risks
- **Import churn** — same risk as the original restructure; mitigated by IDE "Move Class" refactor, one subsystem at a time, build after each.
- **Interface co-location** — service interfaces (e.g. `SessionProvider`) now live alongside their impls in `db/` or `utils/` rather than in a separate `platform/` package. This is acceptable for this project's size.
# Delivery Steps
### ✓ Step 1: Move engine packages from framework/ back to core/
The `framework/` package is renamed back to `core/` with its original subpackage names.
- Move `framework/command/` → `core/command/` (all engine classes: Command, CommandProcessor, CommandRegistry, CommandManager, CommandCategory, CommandContainer, CommandExecutionResult, CommandOptionsBuilder, impl/, options/)
- Move `framework/context/` → `core/context/` (Context, AbstractContext, CommandContext, TaskContext, InteractionContext, MessageContext, NoMessageContext, AsyncContext)
- Move `framework/task/` → `core/tasks/` (Task, TaskManager interface + impl, StepTask, PersistableTask, BuiltInTask, ScheduledTask, TaskCleanup, persistence/, trait/, impl/, generic/)
- Move `framework/NetNeutralityBot.java` into `core/` or merge into App if it's just a shell
- Move `platform/logging/LogHandler.java` → `core/LogHandler.java`
- Move `platform/concurrency/*` → `core/thread/` (ThreadManager/ExecutorProvider, ScheduledTaskManager, BaseThreadFactory)
- Update all imports; verify build with `./mvnw compile`
### ✓ Step 2: Move persistence and infrastructure packages back to db/, hooks/, http/, utils/
Infrastructure packages are flattened back to their original top-level locations.
- Move `data/entity/` → `db/entities/` (restore plural naming)
- Move `data/repository/` → `db/repository/`
- Move `data/service/` → `db/service/` (GuildService, GuildServiceProvider, GuildSettingService, BlacklistService, TaskService)
- Move `data/GSettings.java` → `db/GSettings.java`
- Move `platform/persistence/` contents → `db/` (SessionManager/SessionProvider, DatabaseMigration, Repository, JpaUtils, SessionProvider interface)
- Move `platform/config/Settings.java` + `SettingsProvider` → `utils/`
- Move `platform/event/EventsHandler.java` → `hooks/EventsHandler.java`
- Move `platform/web/` → `http/` (WebApi, server/, WebModule)
- Move `feature/moderation/ChannelMessagePurger.java` → `utils/ChannelMessagePurger.java`
- Move `feature/music/MusicUtils.java` → `utils/MusicUtils.java` (if it exists)
- Delete empty `platform/` and `data/` directories
- Update all imports; verify build with `./mvnw compile`
### ✓ Step 3: Restore music domain package and split feature/ into command/, tasks/, context/
Feature-vertical grouping is dissolved back into separate top-level packages.
- Move `feature/music/` domain classes (MusicManager, MusicService, QueueManager, AudioPlayerSendHandler, SourceFinder, source/, TrackInfo, TrackLoadingHandler, TrackScheduler, MusicChannelContext) → `music/`
- Move concrete context classes from `framework/context/command/` → `context/command/` and `framework/context/task/` → `context/tasks/`; move MusicChannelContext → `context/MusicChannelContext.java`
- Move `feature/music/command/*` → `command/music/`
- Move `feature/moderation/command/*` → `command/admin/`
- Move `feature/reaction/command/*` → `command/react/`
- Move `feature/git/command/*` → `command/git/`
- Move `feature/crypto/command/*` → `command/crypto/`
- Move `feature/help/command/*` → `command/basic/` and `command/bot/` (matching old categorization)
- Move `feature/custom/command/*` → `command/custom/`
- Move `feature/reaction/task/*` → `tasks/reaction/`
- Move `feature/git/task/*` → `tasks/git/`
- Move `feature/help/task/*` → `tasks/help/`
- Move `feature/music/task/*` → `tasks/music/`
- Move scheduled tasks → `tasks/scheduled/`
- Delete empty `feature/` directory
- Update all imports; verify full build with `./mvnw package`
- Update `docs/architecture.md` package map table to reflect the new hybrid layout
@@ -1,99 +0,0 @@
---
sessionId: session-260910-054543-zckm
---
# Requirements
### Overview & Goals
Analyze and fix thread-safety issues and improve the `ExecutorProvider` / `ThreadManager` infrastructure and all code that interacts with it.
### Scope
#### In Scope
- Fix thread-safety bugs in `ThreadManager`, `TaskManagerImpl`, `ChannelMessagePurger`, `MusicManager`
- Improve executor pool configuration (bounded pools, proper shutdown)
- Clean up inconsistent synchronization patterns
#### Out of Scope
- Rewriting the task/command architecture
- Changing the `QueueManager` synchronization (it's consistent and correct, just verbose)
# Technical Design
### Current Issues Found
#### 1. `ThreadManager` (`core/thread/ThreadManager.java`)
- **Unbounded cached thread pools** — `newCachedThreadPool()` creates unlimited threads; under load (e.g., many guilds purging simultaneously) this can exhaust memory.
- **Shutdown bug (line 66-69)** — If `awaitTermination` throws `InterruptedException` on the *first* executor, the `catch` block exits the loop and remaining executors only get `shutdownNow()` without a prior `shutdown()` call. Each executor should be shut down independently.
- **No daemon threads** — `BaseThreadFactory` doesn't set `thread.setDaemon(true)`, so leaked threads can prevent JVM exit.
#### 2. `TaskManagerImpl` (`core/tasks/TaskManagerImpl.java`)
- **Null values in `ConcurrentHashMap` (line 178-184)** — `computeIfAbsent` returns `null` for `PersistableTask`/`BuiltInTask`, but `ConcurrentHashMap` does **not** permit null values. This will throw `NullPointerException` at runtime.
- **Mixed synchronization** — `getTasks()` is `synchronized` on `this`, but all mutations use `ConcurrentHashMap` operations (no `synchronized`). The `synchronized` keyword on `getTasks()` provides no actual protection since `insert()`, `removeTask()`, etc. don't synchronize on `this`.
- **`hasTask()` race (line 93-95)** — Check-then-act across two collections (`tasks` and `pendingTasks`) without synchronization.
- **`handle()` lock cleanup race (line 191-194)** — After acquiring the lock, it checks `tasks.containsKey()` and removes from `tasksLock`, but another thread could be waiting on the same lock and will find it removed.
#### 3. `ChannelMessagePurger` (`utils/ChannelMessagePurger.java`)
- **Double unlock bug** — `purgeMessages()` acquires `lock` via `tryLock()` (line 68) and releases it in `finally` (line 79). But `safePurge()` also calls `lock.unlock()` (line 154), causing an `IllegalMonitorStateException` or releasing a lock that a *different* thread now holds.
- **Non-thread-safe static `HashMap`** (line 40) — `instances` is a plain `HashMap` accessed from multiple threads via `computeIfAbsent`. Should be `ConcurrentHashMap`.
- **Static mutable `ExecutorProvider` binding** — The `volatile` field + `bind()` pattern works but is fragile; a null check race exists between `canLockBeAcquired` callers and `purgeMessages`.
#### 4. `MusicManager` (`music/MusicManager.java`)
- **Inconsistent synchronization** — `isConnected()`, `connect()`, `disconnect()`, `play()` are `synchronized`, but `isCurrentlyPlaying()` (line 114), `getCurrentlyPlaying()` (line 110), `stopTrack()` (line 138), `resume()` (line 142) are not. If these are called from event threads concurrently with `disconnect()`, they can see stale state.
#### 5. `BaseThreadFactory` (`core/thread/BaseThreadFactory.java`)
- Threads are not set as daemon threads, which can prevent clean JVM shutdown if an executor leaks.
### Proposed Changes
#### `ThreadManager`
- Replace `newCachedThreadPool()` with `newFixedThreadPool()` or a bounded `ThreadPoolExecutor` with a reasonable max (e.g., `availableProcessors() * 2`).
- Fix shutdown loop: wrap each executor's `shutdown()`/`awaitTermination()` in its own try-catch so one failure doesn't skip the rest.
- Consider adding a `getExecutorStatus()` method for observability.
#### `BaseThreadFactory`
- Set `thread.setDaemon(true)` to prevent JVM hang on unclean shutdown.
#### `TaskManagerImpl`
- Fix `computeIfAbsent` null return: use a sentinel or simply don't store an entry for tasks that don't need locking (use `get()` + null check instead).
- Remove misleading `synchronized` on `getTasks()` or make all task mutations consistently synchronized.
- Guard `hasTask()` with proper synchronization or accept eventual consistency and document it.
- Fix lock cleanup race in `handle()` — don't remove from `tasksLock` inside the lock-held section; let `removeTask()` handle cleanup.
#### `ChannelMessagePurger`
- Remove the `lock.unlock()` call on line 154 inside `safePurge()` — the `finally` block in `purgeMessages()` already handles it.
- Change `instances` from `HashMap` to `ConcurrentHashMap`.
#### `MusicManager`
- Either synchronize all public methods that touch `player`/`AudioManager` state, or remove `synchronized` from methods where JDA already guarantees thread safety and document the threading model.
# Delivery Steps
### ✓ Step 1: Fix ThreadManager and BaseThreadFactory
ThreadManager uses bounded pools and has correct shutdown; BaseThreadFactory produces daemon threads.
- Replace `Executors.newCachedThreadPool()` in `ThreadManager.getExecutor()` with a bounded `ThreadPoolExecutor` (core=0, max=availableProcessors*2, 60s keepAlive, LinkedBlockingQueue).
- Fix `stop()` shutdown loop: wrap each executor's `shutdown()`+`awaitTermination()` in its own try-catch so an `InterruptedException` on one executor doesn't skip the rest.
- In `BaseThreadFactory.newThread()`, add `thread.setDaemon(true)`.
- Verify the `getScheduledExecutor()` pool size is still appropriate.
### ✓ Step 2: Fix TaskManagerImpl thread-safety bugs
TaskManagerImpl has no null-in-ConcurrentHashMap violations and consistent synchronization.
- Replace the `computeIfAbsent` that returns null (line 178-184) with a pattern that avoids storing null: check task type first, only call `computeIfAbsent` when a lock is actually needed.
- Remove the misleading `synchronized` keyword from `getTasks()`, or switch to a consistent locking strategy across all mutation methods.
- Fix the lock cleanup race in `handle()`: do not remove from `tasksLock` at line 193; let `removeTask()` be the sole owner of lock cleanup.
- Document the threading contract for `hasTask()` (check-then-act across two collections is inherently racy).
### ✓ Step 3: Fix ChannelMessagePurger concurrency bugs
ChannelMessagePurger has no double-unlock and uses thread-safe collections.
- Remove the redundant `lock.unlock()` call on line 154 in `safePurge()` — the `finally` block in `purgeMessages()` (line 79) already releases the lock.
- Change the static `instances` field (line 40) from `HashMap` to `ConcurrentHashMap`.
- Remove the `synchronized` keyword from `countAllChannelMessages()` and `safePurge()` since the `ReentrantLock` already provides mutual exclusion.
### ✓ Step 4: Harmonize MusicManager synchronization
MusicManager has a consistent and documented threading model.
- Add `synchronized` to `isCurrentlyPlaying()`, `getCurrentlyPlaying()`, `stopTrack()`, and `resume()` to match the existing pattern on `connect()`/`disconnect()`/`play()`.
- Alternatively, if JDA's `AudioPlayer` is already thread-safe, remove `synchronized` from all methods and add a class-level Javadoc explaining the threading model.
- Investigate which approach is correct by checking Lavaplayer's `AudioPlayer` thread-safety guarantees, then apply consistently.
-4
View File
@@ -20,10 +20,6 @@ NETNEUTRALITY_VISITOR_DATA=
NETNEUTRALITY_CIPHER_URL=http://localhost:8001 NETNEUTRALITY_CIPHER_URL=http://localhost:8001
NETNEUTRALITY_CIPHER_PASSWORD= NETNEUTRALITY_CIPHER_PASSWORD=
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=main
NETNEUTRALITY_SENTRY_DSN= NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST= GITLAB_HOST=
GITLAB_TOKEN= GITLAB_TOKEN=
+6 -48
View File
@@ -23,66 +23,24 @@ services:
- netneutrality - netneutrality
depends_on: depends_on:
- netneutrality-db - netneutrality-db
- lavalink
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
environment: environment:
- MYSQL_HOST=${MYSQL_HOST:-netneutrality-db} MYSQL_HOST: netneutrality-db
- MYSQL_PORT=${MYSQL_PORT:-3306} MYSQL_PORT: 3306
- MYSQL_USER=${MYSQL_USER:-java} MYSQL_USER: java
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-password} MYSQL_PASSWORD: password
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality} MYSQL_DATABASE: netneutrality
- API_PORT=${NETNEUTRALITY_API_PORT:-9000}
# Lavalink >= 4.2.0 is required: the node handles DAVE end-to-end encryption,
# so the bot no longer ships native encoders.
lavalink:
image: ghcr.io/lavalink-devs/lavalink:4.2
container_name: lavalink
restart: unless-stopped
networks:
- netneutrality
depends_on:
- ejs-api
- webpo
volumes:
- ./lavalink/application.yml:/opt/Lavalink/application.yml:ro
environment:
- SERVER_PASSWORD=${NETNEUTRALITY_LAVALINK_PASSWORD:-youshallnotpass}
- NETNEUTRALITY_CIPHER_URL=${NETNEUTRALITY_CIPHER_URL:-http://ejs-api:8001}
- NETNEUTRALITY_CIPHER_PASSWORD=${NETNEUTRALITY_CIPHER_PASSWORD:-}
- NETNEUTRALITY_OAUTH_TOKEN=${NETNEUTRALITY_OAUTH_TOKEN:-}
- NETNEUTRALITY_POT_URL=${NETNEUTRALITY_POT_URL:-http://webpo:8080}
- NETNEUTRALITY_PO_TOKEN=${NETNEUTRALITY_PO_TOKEN:-}
- NETNEUTRALITY_VISITOR_DATA=${NETNEUTRALITY_VISITOR_DATA:-}
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:
- netneutrality - netneutrality
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD # environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
# - API_TOKEN=test # - API_TOKEN=test
webpo:
image: ghcr.io/ashton045/webpo-generator
container_name: webpo-generator
environment:
- HOST=${HOST:-::}
- PORT=${PORT:-8080}
- WORKERS=${WORKERS:-1}
- QUEUE_SIZE=${QUEUE_SIZE:-32}
- MAX_PENDING_REQUESTS=${MAX_PENDING_REQUESTS:-256}
- REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30000}
- CACHE_SIZE=${CACHE_SIZE:-100}
- CACHE_TTL=${CACHE_TTL:-300000}
- VISITOR_TTL=${VISITOR_TTL:-600000}
- API_TOKEN=
restart: unless-stopped
volumes: volumes:
netneutrality-db: null netneutrality-db: null
netneutrality-lavalink-plugins: null
networks: networks:
netneutrality: null netneutrality: null
-37
View File
@@ -1,37 +0,0 @@
# Net Neutrality — Technical Documentation
This directory contains detailed technical documentation for the **Net Neutrality** Discord bot
(`me.bobert:netneutrality`, version `3.0.0`).
It is intended for developers working on the codebase. It documents every major system and
subsystem, how they fit together, and the end-to-end flows that tie them.
## Table of contents
| Document | Subsystem |
|---|---|
| [architecture.md](architecture.md) | High-level architecture, startup/shutdown lifecycle, tech stack |
| [events.md](events.md) | Event handling & routing (`EventsHandler`) |
| [contexts.md](contexts.md) | Contexts — the abstraction over Discord events |
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
| [music.md](music.md) | Music — Lavalink node & client, queue, event dispatch |
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
| [threading.md](threading.md) | Threading & scheduling infrastructure |
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
## How to read this documentation
Start with [architecture.md](architecture.md) for the big picture and the startup sequence.
Then read [events.md](events.md) and [contexts.md](contexts.md), because almost everything else
is triggered through a **Context** produced by the **EventsHandler**. From there, the two primary
execution paths are [commands.md](commands.md) and [tasks.md](tasks.md).
## Conventions
- Class names are given fully qualified on first mention, then by simple name.
- Database entity classes are prefixed with `O` (e.g. `OGuild`), and are documented in
[persistence.md](persistence.md).
- Environment variables / configuration keys use the `NETNEUTRALITY_*` and `MYSQL_*` prefixes and
are documented in [utilities-and-bootstrap.md](utilities-and-bootstrap.md).
-162
View File
@@ -1,162 +0,0 @@
# Architecture Overview
Net Neutrality is a Java 25 Discord bot built on top of [JDA](https://github.com/discord-jda/JDA).
It is packaged as a single fat JAR (via the Maven Shade plugin) whose entry point is
`me.bobert.netneutrality.App`.
## Big picture
Discord sends **events** over the gateway. A single JDA `EventListener`
(`me.bobert.netneutrality.hooks.EventsHandler`) receives every event and turns it into a
**Context** — a high-level abstraction over that event. Each context is then routed to one of three
execution paths:
```
┌──────────────────────────────┐
Discord Gateway ───────▶│ EventsHandler │
(JDA events) │ event class → Context factory│
└───────────────┬──────────────┘
│ builds a Context
┌─────────────────────────┼───────────────────────────┐
▼ ▼ ▼
CommandContext TaskContext (no Context)
│ │ lambda /
▼ ▼ consumer runs
CommandProcessor TaskManager.handle() directly
.dispatch() (lookup task by id) (e.g. GuildJoin)
│ │
▼ ▼
Command.execute() Task.execute()
```
1. **Command flow** — the context is a `CommandContext`; it is handed to `CommandProcessor` which
resolves and runs a `Command`. See [commands.md](commands.md).
2. **Task flow** — the context is a `TaskContext`; it is handed to `TaskManager` which looks up a
`Task` by its **id** and runs it. See [tasks.md](tasks.md).
3. **Lambda flow** — for simple events (e.g. `GuildJoinEvent`, `GuildLeaveEvent`) a consumer runs
directly and no context is routed onward. See [events.md](events.md).
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
[music](music.md) (a remote Lavalink node), the [web API](web-api.md), and the [threading/scheduling](threading.md)
infrastructure.
## Technology stack
| Concern | Library | Version |
|---|---|---|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
| Audio engine | Lavalink v4 node (`ghcr.io/lavalink-devs/lavalink`) | 4.2 |
| Audio client | `dev.arbjerg:lavalink-client` | 3.4.0 |
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
| DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 |
| Error tracking | `io.sentry:sentry` | 8.41.0 |
| GitLab integration | `org.gitlab4j:gitlab4j-api` | 6.2.0 |
| HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 |
| JSON | `org.json:json` + `com.google.code.gson:gson` | — |
| Classpath scanning | `org.reflections:reflections` | 0.10.2 |
| Emoji parsing | `net.fellbaum:jemoji` | 1.7.6 |
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
| Testing | JUnit Jupiter 6 + Mockito 5 | — |
The HTTP **server** for the internal API uses the JDK built-in
`com.sun.net.httpserver.HttpServer` (no external framework); see [web-api.md](web-api.md).
## Startup lifecycle
The entry point is `App.main()` followed by the `App()` constructor which calls
`startup()`. Bootstrap is driven by a hand-rolled
[`ApplicationContext`](utilities-and-bootstrap.md#applicationcontext--lifecycle) that owns wired
instances and starts every [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle)
component in ascending priority order.
1. **Print banner / read env** — reads `NETNEUTRALITY_BOT_ENV` (default `dev`) via the early-bootstrap
`Settings.getInstance()` singleton (the only remaining process-wide settings access point; it is
also registered into the context as `SettingsProvider` for constructor injection).
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
(environment, release = `VERSION`, traces sample rate 1.0).
3. **Log levels** — in `dev`, raise JDA and Lavalink client logging to `DEBUG`.
4. `startup()`:
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
3. `context.start()` — start every `Lifecycle` component in ascending priority order.
4. Register a JVM shutdown hook calling `shutdown()` → `context.stop()`.
### Lifecycle priorities (startup order)
| Priority | Component | What `start()` does |
|---|---|---|
| 10 | `SessionManager` (`SessionProvider`) | Build Hibernate `SessionFactory` + run Flyway migrations. Binds itself into `JpaUtils`. |
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
| 40 | `MusicServiceImpl` (`MusicService`) | Create the shared `LavalinkClient`, register the node and subscribe to its events. |
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
| 70 | `WebApi` | Start the internal HTTP server (`NETNEUTRALITY_API_PORT`, default 9000). |
Commands and tasks that need shared services declare them as constructor parameters
(`MusicService`, `TaskManager`, `GuildServiceProvider`, `SettingsProvider`, `CommandRegistry`, …).
`ReflectionHelper` picks the richest public constructor whose parameter types all resolve from the
context (falling back to a no-arg constructor when none match).
### Gateway intents
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
`MESSAGE_CONTENT`. It also installs `new JDAVoiceUpdateListener(music.getClient())` as the voice
dispatch interceptor, so voice server/state updates are forwarded to Lavalink instead of JDA's own
audio stack. DAVE (end-to-end encrypted voice) is handled by the node, which is why the bot no
longer ships native audio encoders.
## Shutdown lifecycle
`App.shutdown()` (invoked by the JVM shutdown hook, `ShutdownCommand`, or the HTTP
`/actions/shutdown` endpoint) calls `context.stop()`, which stops every registered `Lifecycle`
component in the **exact reverse** of start order. Failures in one component are logged and do not
block the others. Effectively this means:
1. `WebApi.stop()` — stop the HTTP server and its executor.
2. Post-JDA adapter — no-op.
3. JDA adapter — `jda.shutdown()`.
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
5. `MusicServiceImpl.stop()` — dispose the event subscriptions, destroy the players and close the Lavalink client.
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
7. `ThreadManager.stop()` — shut down the shared executors.
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
## Configuration & build
- **Config sources** — `Settings` loads `bot.properties` and overlays environment variables (env
vars win). See [utilities-and-bootstrap.md](utilities-and-bootstrap.md#settings). An example file
lives at `bot.properties.example`.
- **Build** — `./mvnw package` produces `target/netneutrality.jar` (Shade fat JAR, main class
`me.bobert.netneutrality.App`). Checkstyle (`checkstyle.xml`) runs in the `test` phase
and fails the build on violations.
- **Run** — the preferred method is Docker Compose (`docker compose build && docker compose up -d`),
which also provisions the MariaDB database. See `README.md` and `docker-compose.yml`.
## Package map
| Package | Contents |
|---|---|
| `me.bobert.netneutrality` | Entry point (`App`) |
| `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` |
| `…core.command` | Command engine (registry, processor, manager, options, impl bases) |
| `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) |
| `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) |
| `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) |
| `…core` | `LogHandler`, `NetNeutralityBot` |
| `…command.*` | Concrete command implementations (`admin`, `basic`, `bot`, `crypto`, `custom`, `git`, `music`, `react`) |
| `…context.command` / `…context.tasks` | Concrete context implementations (slash, message, button, modal, emoji, etc.) |
| `…context` | `MusicChannelContext` |
| `…tasks.*` | Concrete task implementations (`git`, `help`, `music`, `reaction`) |
| `…db` | Session/migration, `Repository`, `JpaUtils`, `GSettings` |
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
| `…hooks` | `EventsHandler` |
| `…http` | Internal web API and HTTP client module |
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
| `…shared.util` | Cross-cutting helpers |
| `…exceptions` | Custom exception hierarchy |
-169
View File
@@ -1,169 +0,0 @@
# Commands
**Packages:** `me.bobert.netneutrality.framework.command` (framework),
`me.bobert.netneutrality.feature.*.command` (concrete commands)
A **Command** is what a user executes on Discord. It receives a
[`CommandContext`](contexts.md#commandcontext) and runs logic based on it. There are two primary
kinds:
- **Slash commands** — first-class Discord application commands. Their structure (options,
subcommands, groups) has rules defined by Discord and must be configured **procedurally** via
`CommandOptionsBuilder`.
- **Message (prefix) commands** — a plain message beginning with the configured prefix
(`NETNEUTRALITY_BOT_PREFIX`, default `$`).
A core goal of the framework is that **both kinds are authored the same way**: a command declares its
metadata and options once, and the framework adapts it to either interaction style. Most of that
adaptation logic lives in `CommandProcessor`.
## The `Command` contract
`Command` (interface) defines:
- Metadata — `getCommandName()`, `getDescription()`, `getCategory()`
([`CommandCategory`](#commandcategory)).
- Type — `getType()` (`SLASH` by default).
- Permissions — `needAdmin()`, `canBeExecutedBy(...)`, guild-only / slash-only constraints.
- Option declaration — `registerCommandOptions(CommandOptionsBuilder)` (procedural option setup).
- Execution — `execute(CommandContext) : CommandExecutionResult`.
### Base implementations (`core.command.impl`)
| Class | Adds |
|---|---|
| `AbstractCommand` | Default `Command` implementation; type defaults to `SLASH`; basic permission logic. |
| `AdminCommand` | Sets `needAdmin() = true` and category `ADMINISTRATION`. |
| `MusicCommand` | Guild-only; verifies the guild's music role and music channel before execution. |
| `MessageMenuCommand` | Discord **message** context-menu command; skips option registration; wraps the target `Message`. |
| `UserMenuCommand` | Discord **user** context-menu command; skips option registration; wraps the target `User`. |
## Unified options
To let one command definition serve both slash and message input, values are read through a wrapper
interface:
- `CommandOption` (interface) — uniform accessors: `getAsString()`, `getAsInt()`, `getAsMember()`,
`getAsBoolean()`, … .
- `SlashCommandOption` — wraps JDA's `OptionMapping` (Discord already parsed the value).
- `MessageCommandOption` — wraps a value parsed manually from the message text.
### Procedural option configuration — `CommandOptionsBuilder`
`CommandOptionsBuilder` implements JDA's `SlashCommandData` and acts as a proxy over JDA's internal
`CommandDataImpl`. A command builds its argument surface in `registerCommandOptions(builder)` by
adding options, subcommands, and subcommand groups. The same declaration is used both to:
1. register the slash command with Discord (see [registration](#registration-lifecycle)), and
2. drive the message-command parser (which tries to match tokens in the message against the declared
`OptionData`).
## Command management
### `CommandManager`
Responsible for **discovery** and **registration**.
- **Discovery** — at `init()`, uses [`ReflectionHelper`](utilities-and-bootstrap.md#reflectionhelper)
to scan for `Command` implementations and instantiate them.
- **Storage** — commands are kept in a `ConcurrentHashMap<String, CommandContainer>` keyed by command
name.
- **Slash registration** — `registerSlashCommands(JDA)` converts each command's declaration into JDA
`SlashCommandData` and pushes it to Discord's API.
### `CommandContainer`
Wraps a `Command` instance together with its JDA metadata (`CommandDataImpl`). It also builds the
"usage" string shown by the help command.
### `CommandCategory`
Enum grouping commands for help/display purposes (e.g. `ADMINISTRATION`, `UTILITY`, `MUSIC`,
`CRYPTO`, and an unlisted/hidden group for owner/debug commands).
### `CommandExecutionResult`
The value returned by `execute(...)`. It encodes the outcome and any response payload:
- `success(...)` — normal success (optionally with a message/embed to send).
- `error(...)` / `warning(...)` — failure/advisory outcomes with a message.
- `none()` — nothing to send synchronously (the command handles its own async response, e.g. music).
- May also carry a follow-up **`Task`** so a command can install an interactive
[task](tasks.md) (e.g. buttons) as part of its result.
## The dispatch flow — `CommandProcessor`
`CommandProcessor` is constructed with the `CommandContext` and driven by `dispatch()`:
1. **Resolution** — match the invoked name against a `CommandContainer` in `CommandManager`.
2. **Validation**
- command enabled?
- guild-only / slash-only constraints satisfied?
- **permissions** — `canBeExecutedBy(...)` and, for admin commands, `needAdmin()` verified against
the bot owner (`NETNEUTRALITY_BOT_OWNER`) and the member's guild-admin status.
3. **Usage parsing (`checkUsage`)** — detect whether a subcommand or subcommand group is being
invoked and record the active subcommand on the context.
4. **Option parsing**
- **Slash** — Discord already parsed the arguments; map each `OptionMapping` to a
`SlashCommandOption`.
- **Message** — `MessageCommandContext` tokenizes the message and matches tokens against the
declared `OptionData` (resolving mentions to members/roles, parsing integers/booleans, etc.).
5. **Execution** — call `command.execute(context)`.
6. **Result handling** — act on the returned `CommandExecutionResult`: send success/error/warning
responses, or, if a `Task` was returned, register it with the [`TaskManager`](tasks.md).
Errors are surfaced through the exception hierarchy
([`CommandExecutionException`](utilities-and-bootstrap.md#exceptions), and
`GracefulContextError` for user-facing, non-logged failures).
## Registration lifecycle
```
App.startup()
├─ CommandManager.init() // reflective discovery → ConcurrentHashMap
└─ CommandManager.registerSlashCommands(jda) // push SlashCommandData to Discord
```
At runtime, an incoming command event becomes a `CommandContext` in the
[`EventsHandler`](events.md), which calls `new CommandProcessor(context).dispatch()`.
## Command catalog
Concrete commands live under `me.bobert.netneutrality.feature.*.command`, grouped by feature package:
| Command | Package | Type | Description |
|---|---|---|---|
| `BlacklistCommand` | `admin` | Slash | Manage the server's word blacklist. |
| `ReactionCommand` | `admin` | Slash | Manage emoji-reaction roles on the server. |
| `HelpCommand` | `basic` | Slash | List every command with its usage. |
| `PingCommand` | `basic` | Slash | Reply with the gateway ping. |
| `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). |
| `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. |
| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the GitLab report flow). |
| `PlayCommand` | `music` | Slash | Search/queue and start playback. |
| `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. |
| `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. |
| `ClearQueueCommand` / `ShuffleCommand` | `music` | Slash | Clear / shuffle the queue. |
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
### Representative commands
**`PingCommand` (simplest slash command)** — extends `AbstractCommand`, overrides `execute` to return
`CommandExecutionResult.success(...)` containing the JDA gateway ping. It illustrates the minimal
execution path (no options, synchronous response).
**`ConfigureCommand` (complex slash command)** — uses `registerCommandOptions` to declare several
`SubcommandData` (e.g. music-volume, error-logging). In `execute`, it switches on
`context.getSubcommand()` and reads typed values via `context.getOption(name, resolver)`, then
persists them through the [guild settings service](persistence.md#service-layer).
**`PlayCommand` (music)** — extends `MusicCommand` (so it enforces guild-only + music role/channel).
It declares a single required `STRING` query option, delegates loading to
[`MusicManager`](music.md), and returns `CommandExecutionResult.none()` because the audio subsystem
produces the user-facing response asynchronously (often installing a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with choice buttons).
-154
View File
@@ -1,154 +0,0 @@
# Contexts
**Packages:** `me.bobert.netneutrality.framework.context` (framework),
`me.bobert.netneutrality.framework.context` (concrete implementations)
A **Context** is a high-level abstraction over a Discord event. It hides the differences between the
many JDA event types (slash command, message, button, modal, reaction, …) behind a single, uniform
API for reading who/where the event came from and for replying to it.
Contexts are created either by the [`EventsHandler`](events.md) when Discord triggers an event, or by
another context further down a flow (for example, a reply produces an [`AsyncContext`](#asynccontext)
that can be edited later). A context can be handled by one of three flows depending on its type and
the event it wraps: the [command processor](commands.md), the [task manager](tasks.md), or a simple
lambda. **The id of a context only matters for tasks** — the task manager uses `getId()` to locate
the `Task` that should run (see [tasks.md](tasks.md)).
## Type hierarchy
```
Context (interface)
├── InteractionContext (interface) ── reply/defer/ack via IReplyCallback + InteractionHook
├── NoMessageContext (interface) ── defaults for events without a triggering message
│ └── MessageContext (interface) ── adds a triggering Message (prefix commands, replies)
│
└── AbstractContext (abstract, implements Context) ── stores JDA/Guild/Channel/Member/User
├── CommandContext (abstract) ── command flow; holds CommandOptions + subcommand metadata
│ ├── SlashCommandContext
│ ├── MessageCommandContext
│ ├── MessageMenuContext
│ └── UserMenuContext
├── TaskContext (abstract) ── task flow; abstract getId(); contextData state map
│ ├── ButtonTaskContext
│ ├── EmojiTaskContext
│ ├── MessageTaskContext
│ └── ModalTaskContext
├── AsyncContext ── wraps the result of a previous reply (edit/follow-up)
└── MusicChannelContext ── synthetic context targeting a guild's music channel
```
## Core framework types
### `Context` (interface)
The root contract for every context. Notable methods:
- `getJDA()`, `getGuild()`, `getChannel()`, `getGuildUser()` (`Member`), `getDiscordUser()` (`User`)
- `reply(...)` — unified send; overloads support plain text, embeds, message components, an
`ephemeral` flag, and an async callback that receives an [`AsyncContext`](#asynccontext) for
follow-up edits.
- `defer(ephemeral, consumer)` — signals "thinking…" and later completes the response.
- `ack(ephemeral)` — minimal acknowledgement of the event.
- `getInteractionHook()` — the JDA `InteractionHook` when the underlying event is an interaction.
### `AbstractContext` (abstract class)
Base implementation that stores the common JDA entities (`JDA`, `Guild`, `MessageChannel`, `Member`,
`User`) and exposes them via the `Context` getters. Its constructor also sets the **Sentry user**
(id + effective name) so any error captured while handling this context is attributed to the right
Discord user:
```java
if (user != null) {
io.sentry.protocol.User sentryUser = new io.sentry.protocol.User();
sentryUser.setId(user.getId());
sentryUser.setUsername(user.getEffectiveName());
Sentry.setUser(sentryUser);
}
```
### `InteractionContext` (interface)
Behavioral mix-in for events that are JDA `Interaction`s (slash commands, buttons, modals, context
menus). It implements `reply`/`defer`/`ack` in terms of `IReplyCallback` and `InteractionHook`, so
these contexts can reply ephemerally, defer, and edit the original response.
### `NoMessageContext` / `MessageContext` (interfaces)
- `NoMessageContext` — provides sensible defaults for events that have **no** triggering message
(e.g. reactions, interactions).
- `MessageContext extends NoMessageContext` — adds access to a triggering `Message`, used by prefix
commands and by replies that drive step tasks.
## Command contexts
### `CommandContext`
Abstract base for the [command flow](commands.md). It carries the parsed
[`CommandOption`s](commands.md#unified-options) and the active subcommand / subcommand-group metadata,
and exposes type-safe option access, e.g. `getOption(name, resolver)`. Concrete subclasses adapt a
specific JDA event to this API.
| Class | Wraps | Notes |
|-------------------------|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| `SlashCommandContext` | `SlashCommandInteractionEvent` | Options already parsed by Discord; maps `OptionMapping` → `SlashCommandOption`. Interaction-based replies. |
| `MessageCommandContext` | `MessageReceivedEvent` (prefix) | Contains the **manual parser** that turns the raw message string into `CommandOption`s (mentions, integers, booleans, …). Message-based replies. |
| `MessageMenuContext` | `MessageContextInteractionEvent` | "Apps → …" action on a message; exposes the target `Message`. |
| `UserMenuContext` | `UserContextInteractionEvent` | "Apps → …" action on a user; exposes the target `User`. |
## Task contexts
### `TaskContext`
Abstract base for the [task flow](tasks.md). Two things distinguish it:
- **`getId()` (abstract)** — the id used by `TaskManager` to find the matching `Task`. How the id is
derived depends on the concrete subtype (see the table below and [tasks.md](tasks.md#the-task-id)).
- **`contextData` map** — a small state bag used to carry values between steps of a multi-step flow.
| Class | Wraps | `getId()` derived from |
|----------------------|------------------------------------|------------------------------------------------------------|
| `ButtonTaskContext` | `ButtonInteractionEvent` | `getComponentId()` of the clicked button |
| `ModalTaskContext` | `ModalInteractionEvent` | modal `customId` |
| `EmojiTaskContext` | `MessageReactionAddEvent` | composite `guildId:messageId` |
| `MessageTaskContext` | `MessageReceivedEvent` (no prefix) | the id of the message being replied to (drives step tasks) |
## Reply mechanism & `AsyncContext`
### `AsyncContext`
`me.bobert.netneutrality.framework.context.AsyncContext` wraps the **result** of a previous reply — either an
`InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
expressed the same way regardless of the original trigger:
```java
context.reply("Initial response", async -> {
// 'async' is an AsyncContext for the message just sent
async.edit("Updated response");
});
```
Under the hood:
- **Interactions** — edits/follow-ups go through `hook.editOriginal()` / `hook.sendMessage()`.
- **Messages** — edits/follow-ups go through `message.editMessage()` / `message.reply()`.
This is what makes the command and task code path-agnostic: authors write against `Context` /
`AsyncContext` and never branch on "was this a slash command or a text message?".
### `MusicChannelContext`
A synthetic `AbstractContext` that targets a guild's configured **music channel** rather than the
channel an event came from. The music subsystem uses it to post now-playing / queue messages into the
correct channel even when playback state changes without a fresh user interaction. See
[music.md](music.md).
## End-to-end summary
| Event | Context | Flow | Async result wraps |
|---------------------------------|-------------------------|---------|--------------------|
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command | `InteractionHook` |
| `MessageReceivedEvent` (prefix) | `MessageCommandContext` | Command | `Message` |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task | `InteractionHook` |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task | `Message` |
| `ModalInteractionEvent` | `ModalTaskContext` | Task | `InteractionHook` |
-102
View File
@@ -1,102 +0,0 @@
# Event Handling & Routing
**Package:** `me.bobert.netneutrality.platform.event`
**Primary class:** `EventsHandler`
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
(`jda.addEventListener(new EventsHandler())` in `App.startup()`). It is the funnel through
which **every** gateway event passes. Its job is to translate a raw JDA event into a
[Context](contexts.md) and route that context to the correct subsystem.
## Listener registry
Listeners are held in a static, thread-safe map keyed by the JDA event class:
```java
private static final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners
= new ConcurrentHashMap<>();
```
Each entry is a **factory function**: given an event of that type, it returns a `Context` (or `null`).
The map is populated once in a `static {}` block.
### `addListener` vs `addGenericListener`
- `addListener(Class<T>, Function<? super T, Context>)` — registers a factory that produces a
`Context` to be routed onward (command or task flow).
- `addGenericListener(Class<T>, Consumer<T>)` — registers a factory that runs a side-effecting
consumer and returns `null`, so nothing is routed onward. This is the **lambda flow** for simple
events.
## Registered events
| JDA event | Produces | Flow |
|---|---|---|
| `MessageReceivedEvent` (prefix match) | `MessageCommandContext` | Command |
| `MessageReceivedEvent` (no prefix) | `MessageTaskContext` | Task |
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command |
| `UserContextInteractionEvent` | `UserMenuContext` | Command |
| `MessageContextInteractionEvent` | `MessageMenuContext` | Command |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task |
| `ModalInteractionEvent` | `ModalTaskContext` | Task |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task |
| `GuildJoinEvent` | — (consumer) | Lambda |
| `GuildLeaveEvent` | — (consumer) | Lambda |
### Message routing detail
For `MessageReceivedEvent` the factory:
1. Ignores messages authored by bots and webhook messages (returns `null`).
2. Reads the configured prefix (`NETNEUTRALITY_BOT_PREFIX`, default `$`).
3. If the trimmed message starts with the prefix → **`MessageCommandContext`** (command flow).
4. Otherwise → **`MessageTaskContext`** (task flow). This lets ordinary messages participate in the
task system, e.g. as a reply that advances a [step task](tasks.md#33-step-tasks).
### Guild lifecycle (lambda flow)
- `GuildJoinEvent` — resolves `GuildService.get(guild)`; if debug logging is enabled for that guild,
sends a notice to the debug channel.
- `GuildLeaveEvent` — `GuildService.get(guild).deleteGuild()` removes the guild's stored data.
## Dispatch algorithm
```java
@Override
public void onEvent(@Nonnull GenericEvent event) {
handleEvent((Event) event);
}
private void handleEvent(Event event) {
Class<?> eventClass = event.getClass();
while (eventClass != null) {
if (listeners.containsKey(eventClass)) {
Context context = listeners.get(eventClass).apply(event);
if (context instanceof CommandContext c) handleCommand(c);
if (context instanceof TaskContext t) handleTask(t);
break;
}
eventClass = eventClass.getSuperclass(); // walk up the class hierarchy
}
}
```
Key points:
- **Superclass walk** — if the exact event class is not registered, the handler walks up the
superclass chain until it finds a match. This means a listener registered for a base event type
also catches subclasses.
- **Routing by context type** — the resulting object is routed purely by its runtime type:
a `CommandContext` goes to `CommandProcessor.dispatch()`, a `TaskContext` goes to
`TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
## Extending the event system
To handle a new event type:
1. Add an `addListener(SomeEvent.class, SomeContext::new)` (or `addGenericListener(...)` for a
consumer-only side effect) to the `static {}` block.
2. If it should run the command or task flow, make the produced context extend
[`CommandContext`](contexts.md#commandcontext) or [`TaskContext`](contexts.md#taskcontext)
respectively.
-170
View File
@@ -1,170 +0,0 @@
# Music
**Package:** `me.bobert.netneutrality.music`
**Engine:** a remote [Lavalink](https://lavalink.dev) v4 node driven by
[`dev.arbjerg:lavalink-client`](https://github.com/lavalink-devs/Lavalink-Client) 3.4.0.
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
sources, queueing, scheduling, and streaming audio into a Discord voice channel. Since the
migration to Lavalink, **decoding, source resolution and voice sending happen on the node**, not in
the bot process. The bot only sends REST/WebSocket commands and reacts to the node's events, which
means new sources (YouTube, LavaSrc, …) are added by configuring server-side plugins rather than by
changing Java code.
## Key classes
| Class | Responsibility |
|---|---|
| `MusicServiceImpl` | Owns the shared `LavalinkClient`, registers the node, and dispatches its event flux. |
| `MusicManager` | Central per-guild coordinator; resolves the guild's `Link` and exposes a synchronous facade. |
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
| `TrackScheduler` | Event sink for `onTrackStart` / `onTrackEnd` / `onTrackException` / `onTrackStuck`; caches the current track. |
| `TrackLoadingHandler` | `AbstractAudioLoadResultHandler`; handles track/playlist/search/no-match/failure results. |
| `TrackInfo` | Value object pairing a Lavalink `Track` with its requester (`Member`). |
| `SourceFinder` | Identifies the source/type of a query (YouTube, SoundCloud, HTTP, …). |
| `StuckTrackRecoveryHandler` | Retry-then-skip logic for tracks the node reports as stuck. |
`MusicServiceImpl` keeps a `Map<String, MusicManager>` of managers keyed by guild id, so each guild
has its own link, scheduler, and queue.
## Startup
`MusicServiceImpl` is a priority-40 [`Lifecycle`](architecture.md#startup-lifecycle) component. Its
constructor creates the `LavalinkClient` (the bot user id is derived from the token) and `start()`
registers the configured node. **A node that is unreachable never prevents the bot from booting**:
the client retries in the background and music commands reply with an error in the meantime.
Because the client must exist before JDA is built, `App.initJDA()` (priority 60) installs
`new JDAVoiceUpdateListener(music.getClient())` as the gateway's voice dispatch interceptor. JDA's
`AudioManager` is therefore **not** used for playback; voice connections are opened and closed
through `JDA#getDirectAudioController()`.
### Settings
| Setting | Default | Meaning |
|---|---|---|
| `NETNEUTRALITY_LAVALINK_URI` | `ws://lavalink:2333` | WebSocket URI of the node |
| `NETNEUTRALITY_LAVALINK_PASSWORD` | — | Node password; music is disabled when missing |
| `NETNEUTRALITY_LAVALINK_NAME` | `main` | Node name used in logs |
## Audio pipeline (end-to-end)
```
/play query
│
▼
MusicManager.play(query, ctx)
│ SourceFinder identifies the source and builds the identifier
▼
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
│
├─ ontrackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
├─ onPlaylistLoaded / onSearchResultLoaded ──▶ install a LoadPlaylistTask (choice buttons)
├─ noMatches ──▶ reply "nothing found"
└─ loadFailed ──▶ reply / log error
│
▼
QueueManager: if nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
│
▼
Lavalink node decodes and streams the audio to the Discord voice gateway
│
▼ (track ends)
node event ──▶ MusicServiceImpl dispatcher ──▶ TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
```
- **Event dispatch** — `MusicServiceImpl` holds a single subscription per event type
(`TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`,
`WebSocketClosedEvent`) and routes each event to the owning guild's `TrackScheduler`. All
subscriptions are disposed in `stop()`.
- **Synchronous facade** — every Lavalink operation returns a reactive `Mono`. `MusicManager`
subscribes internally so the 13 music commands keep their existing synchronous signatures;
failures are reported through `LogHandler` and the attached `MusicChannelContext`.
- **Current track** — the node's player state is only readable via REST, so the last
`TrackStartEvent` is cached on the scheduler and used by `getCurrentlyPlaying()` and by the queue
to decide whether playback must be started.
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
now-playing/queue displays and vote-skip. Duplicate rejection compares the encoded track payload.
- **Playlists / search** — playlist and search loads produce a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
- **Stuck tracks** — the first `TrackStuckEvent` re-queues the track and seeks back to the last
known position; a second consecutive event skips to the next track.
- **Output** — playback messages are posted through a
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
music channel.
## Running the node
`docker-compose.yml` defines a `lavalink` service (image `ghcr.io/lavalink-devs/lavalink:4.2`, the
minimum version that handles DAVE end-to-end encrypted voice) on the `netneutrality` network, with
`lavalink/application.yml` mounted read-only at `/opt/Lavalink/application.yml`. The bot depends on
it and the node depends on the existing `ejs-api` (yt-cipher) container.
```bash
docker compose up -d lavalink
docker compose logs -f lavalink # wait for "Lavalink is ready to accept connections"
```
### Adding a server-side plugin
Plugins are declared in `lavalink/application.yml` and downloaded by the node on boot into the
`netneutrality-lavalink-plugins` volume — no bot rebuild is required:
```yaml
lavalink:
plugins:
- dependency: "dev.lavalink.youtube:youtube-plugin:1.18.2"
snapshot: false
```
A plugin that is not on the default Lavalink repository can be pulled from any Maven repository by
adding `repository:` to the entry. The node currently runs a JitPack build of the SABR-enabled
YouTube fork ([`ashton045/youtube-source`](https://jitpack.io/#ashton045/youtube-source), branch
`feat/sabr-support`) — no local build of the fork is required, JitPack publishes a ready Lavalink
plugin jar:
```yaml
lavalink:
plugins:
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:17619654b1"
repository: "https://jitpack.io"
snapshot: false
```
The node logs `Failed to check for updates for youtube-plugin: Not Found` on boot; this is harmless,
JitPack publishes no `maven-metadata.xml` for commit-pinned versions.
Plugin-specific configuration goes under the top-level `plugins:` key. The YouTube plugin is
configured there with the remote cipher URL, poToken and visitorData, which used to live in
`MusicServiceImpl.start()`.
## Udio support (deferred)
Udio playback is **temporarily unavailable**. The previous implementation was a client-side
Lavaplayer `AudioSourceManager`, and Lavalink can only resolve sources that live on the server.
`SourceFinder` still recognises Udio URLs but returns no identifier, and `MusicManager.play` replies
that Udio is temporarily unsupported.
The original `UdioAudioSourceManager`, `UdioAudioTrack` and their test have been preserved verbatim
under [`docs/reference/udio-lavalink-plugin/`](reference/udio-lavalink-plugin) — they are **not
compiled** — as the reference for a future Lavalink **server plugin** port. That plugin is out of
scope of this migration and is tracked as a follow-up.
## Guild lifecycle & cleanup
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the channel is empty or the queue
has run out; the connected channel is read from the bot's gateway voice state.
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances.
- On shutdown, `MusicServiceImpl.stop()` disposes the event subscriptions, clears the queues,
destroys every player and closes the client
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
## Related commands
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
[commands.md](commands.md#command-catalog).
-128
View File
@@ -1,128 +0,0 @@
# Persistence & Database
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
`me.bobert.netneutrality.db` (entities, repositories, services).
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`.
## Stack
| Concern | Technology |
|------------|-------------------------------------------------------------------------------|
| ORM | Hibernate 7.1.9 (Jakarta JPA) |
| Engine | MariaDB 11 (per `docker-compose.yml`), MySQL Connector/J 9.7.0 driver, InnoDB |
| Pooling | HikariCP (via `hibernate-hikaricp`) |
| Migrations | Flyway 11.20.3 (`flyway-core` + `flyway-mysql`) |
Configuration is externalized via `Settings` (env vars / `bot.properties`):
`MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`.
## Session & migration lifecycle
Both are wired in at [startup](architecture.md#startup-lifecycle):
1. **`SessionManager.getSessionFactory()`** — lazily builds the Hibernate `SessionFactory`. It reads
the static config from `hibernate.cfg.xml` (dialect `MySQLDialect`, driver
`com.mysql.cj.jdbc.Driver`, the Hikari connection provider, and the mapped entity list) and
overrides the JDBC url/user/password from environment variables:
```java
settings.put(Environment.JAKARTA_JDBC_URL,
String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
settings.put(Environment.JAKARTA_JDBC_USER, dbUser);
settings.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
```
2. **`DatabaseMigration.migrate()`** — runs `flyway.migrate()` against the same database, applying the
versioned scripts under `src/main/resources/db/migration/` (starting with
`V1__initial_schema.sql`). The Flyway Maven plugin (`flyway-dev.sh`/`.ps1`) is available for local
development, using `FLYWAY_URL` / `FLYWAY_USER` / `FLYWAY_PASSWORD`.
3. **`SessionManager.shutdown()`** — destroys the service registry / releases the pool on shutdown.
Repositories obtain sessions through the factory; transactional access uses helpers in
[`JpaUtils`](utilities-and-bootstrap.md) (open session, run in a transaction, close).
## Entity catalog
Entities live in `me.bobert.netneutrality.data.entity` and are prefixed with `O`. `OGuild` is the hub
of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
| Entity | Table | Key | Relationships / notes |
|-----------------|------------------|--------------------------|--------------------------------------------------------------------------------------------------------|
| `OGuild` | `guilds` | `guild_id` (String) | Central entity; one-to-many with settings, blacklist, log messages, logging. |
| `OGuildSetting` | `guild_settings` | `id` (auto int) | Many-to-one `OGuild`; key/value guild settings. |
| `OBlacklist` | `blacklist` | `id` (auto int) | Many-to-one `OGuild`; blacklisted words. |
| `OLogging` | `logging` | `OLoggingId` (composite) | Composite key `guild_id` + `channel_id`; `@MapsId` to `OGuild`; debug/error log channel config. |
| `OLoggingId` | — (embeddable) | — | `@Embeddable`/`@IdClass` for the composite key. |
| `OLogMessage` | `log_message` | `id` (auto int) | Many-to-one `OGuild`; message logging history. |
| `OTask` | `tasks` | `id` (auto int) | [Persistable task](tasks.md#31-persistable-tasks) state; unique `task_id`, class name, JSON `payload`. |
| `OWebUser` | `web_users` | `id` (String) | Web dashboard user (tokens/expiry). *Present in schema & code but not listed in `hibernate.cfg.xml`.* |
### ER overview
```
┌───────────────┐
│ OGuild │ (guilds.guild_id)
└───────┬───────┘
┌───────────┬───────┼────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
OGuildSetting OBlacklist OLogging OLogMessage (…)
guild_settings blacklist logging log_message
(FK guild) (FK guild) (PK guild+ (FK guild)
channel)
OTask (tasks) OWebUser (web_users) ── standalone
```
`OTask` and `OWebUser` are not tied to the guild graph.
## Repository layer
A lightweight generic repository pattern is used.
- **`Repository<O>`** (interface) — generic CRUD: `findAll()`, `findById(String)`, `save(O)` (via
`session.merge()`), `deleteBy(String)`.
- **`BlacklistRepository`** — find-by-guild and case-insensitive word lookups.
- **`GuildRepository`** — CRUD for guilds.
- **`GuildSettingRepository`** — set/get setting values; removes rows that equal the default value.
- **`MessageRepository`** — message logging / edit history.
- **`TaskRepository`** — CRUD for `OTask` rows (used by the task persistence flow).
## Service layer
Services wrap repositories with business logic and **caching**.
- **`GuildService`** — gateway for guild operations; caches one `GuildService` per guild id
(`ConcurrentHashMap`); `findOrCreate` guarantees a guild row exists; `deleteGuild()` on leave;
`isDebugEnabled()` / debug-channel helpers used by [`EventsHandler`](events.md) and
[`LogHandler`](utilities-and-bootstrap.md#loghandler).
- **`BlacklistService`** — maintains a `blacklistCache` (guild id → set of words) for fast message
filtering.
- **`GuildSettingService`** — reads/writes guild settings backed by [`GSettings`](#gsettings); caches
values in `settingsCache` and reloads on change; used by the
[web API settings endpoints](web-api.md).
- **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON
for the `tasks` table and reconstructs them at startup via
[`TaskRegistry`/`TaskFactory`](tasks.md#31-persistable-tasks).
## GSettings
`GSettings` is an **enum** of the available per-guild configuration keys and their defaults:
| Setting | Default |
|-------------------------|---------|
| `MUSIC_ROLE` | (none) |
| `MUSIC_CHANNEL` | (none) |
| `MUSIC_VOLUME` | `30` |
| `MUSIC_MAXPLAYLISTSIZE` | `20` |
`GuildSettingService` looks up an `OGuildSetting` row by the enum `name()`; if no row exists for a
guild/setting, the enum's `defaultValue` is used. This is how `MusicCommand` obtains the guild's music
role/channel and how `ConfigureCommand` persists changes.
## Schema (`V1__initial_schema.sql`)
The initial migration creates the tables backing the entities above (`guilds`, `guild_settings`,
`blacklist`, `logging`, `log_message`, `tasks`, `web_users`), with `guild_id` foreign keys and
`ON DELETE CASCADE` from the guild-scoped tables to `guilds`. Subsequent schema changes should be
added as new `V2__…`, `V3__…` Flyway scripts in the same directory.
@@ -1,43 +0,0 @@
# Udio source — follow-up reference
These files are **not part of the build**. They are the original client-side Lavaplayer
implementation of the udio.com source, kept verbatim as the reference for a future port.
| File | Was |
|---|---|
| `UdioAudioSourceManager.java` | `me.bobert.netneutrality.music.source.UdioAudioSourceManager` |
| `UdioAudioTrack.java` | `me.bobert.netneutrality.music.source.UdioAudioTrack` |
| `UdioAudioSourceManagerTest.java` | its unit test |
## Why they were moved
The bot no longer decodes audio itself: after the [migration to Lavalink](../../music.md), source
resolution happens on the node. A Lavaplayer `AudioSourceManager` living in the bot process can
never be used by Lavalink, and these classes cannot even compile now that the `dev.arbjerg:lavaplayer`
dependency has been dropped from `pom.xml`.
## Status
**Udio playback is disabled.** `SourceFinder` still classifies Udio URLs, but
`SourceFinder.getIdentifier` returns no identifier for them and `MusicManager.play` replies that
Udio is temporarily unsupported.
## Follow-up
Port `UdioAudioSourceManager` / `UdioAudioTrack` into a standalone **Lavalink server plugin**
(its own build and published artifact), then enable it by adding the dependency to
`lavalink/application.yml`:
```yaml
lavalink:
plugins:
- dependency: "<group>:udio-plugin:<version>"
```
Once the node resolves `https://www.udio.com/songs/...` identifiers, the only bot-side change is to
restore `Udio` in the `SourceFinder.getIdentifier` switch and drop the "temporarily unsupported"
reply in `MusicManager.play`.
The scraping logic itself still applies: the manager extracts metadata and a direct playback URL
from the Next.js payload (`self.__next_f.push(...)`) of the track page, and the URL must be
re-fetched right before playback because it expires.
-315
View File
@@ -1,315 +0,0 @@
# Tasks
**Packages:** `me.bobert.netneutrality.framework.task` (framework),
`me.bobert.netneutrality.feature.*.task` (concrete tasks)
Tasks are one of the more advanced concepts in the bot. A **Task** is a unit of logic that is looked
up and executed by **id** when a matching Discord event arrives, as opposed to a
[command](commands.md) which is looked up by name.
The original motivation was **Discord interactions** (buttons). When a user clicks a button, Discord
sends a `ButtonInteractionEvent` whose id is `getComponentId()`. A `Task` with that same id was
inserted earlier (typically by the command that created the button), so the `TaskManager` can find
the task and execute it. The system has since grown to cover several task subtypes:
| Subtype | Trigger | Id source |
|----------------------------------------------|------------------------------------|----------------------------------------|
| [Button task](#button-emoji-and-modal-tasks) | `ButtonInteractionEvent` | button `componentId` |
| [Modal task](#button-emoji-and-modal-tasks) | `ModalInteractionEvent` | modal `customId` |
| [Emoji task](#button-emoji-and-modal-tasks) | `MessageReactionAddEvent` | composite `guildId:messageId` |
| [Step task](#33-step-tasks) | replied-to message | last-message id (assigned dynamically) |
| [Built-in task](#32-built-in-tasks) | button with a hardcoded id | hardcoded id |
| [Persistable task](#31-persistable-tasks) | any of the above, restored from DB | as stored |
| [Scheduled task](#34-scheduled-tasks) | timer | n/a (time-based, not id-based) |
## The `Task` id
The id is the **primary lookup key** used by `TaskManager`. How it is derived depends on the subtype:
- **Buttons** → the button's `componentId`.
- **Modals** → the modal's `customId`.
- **Emoji reactions** → a composite `guildId:messageId` (a reaction is tied to a specific message).
- **Step tasks** → start life with a random UUID, then re-key themselves to the DM/last-message id
once that message exists (see [step tasks](#33-step-tasks)).
## `Task` contract & lifecycle
`Task` (interface) defines `execute(TaskContext)` and returns a **`Lifecycle`** value that tells the
`TaskManager` what to do with the task afterwards:
| Lifecycle | Meaning |
|----------------------------|-------------------------------------------------------------------|
| `BUILTIN` | Task is static and stays registered indefinitely. |
| `KEEP` | Keep the task in memory after execution (can run again). |
| `REMOVE_SELF` | Remove this task after execution (one-shot). |
| `REMOVE_SELF_AND_CHILDREN` | Remove this task and all of its [child tasks](#trait-interfaces). |
### Task base classes (`core.tasks.impl`)
```
AbstractTask (implements Task)
├── ButtonTask ── executed from a ButtonTaskContext
├── EmojiTask ── executed from an EmojiTaskContext
├── ModalTask ── executed from a ModalTaskContext
├── GenericCustomIdTask ── task keyed by an explicit custom id
├── GenericRandomIdTask ── task keyed by a generated random id
├── AbstractBuiltInTask ── base for hardcoded, reflectively-loaded tasks
├── AbstractPersistableTask ── base for DB-backed tasks (implements PersistableTask)
└── AbstractStepTask ── base for multi-step Q&A flows (implements StepTask)
```
## `TaskManager`
`me.bobert.netneutrality.framework.task.TaskManager` is the registry, lookup, and execution
coordinator.
### Registration — `insert`
- Adds the task to the active map keyed by its id.
- If the task is a [`PersistableTask`](#31-persistable-tasks), it is also **saved to the database**
via [`TaskService`](persistence.md#service-layer).
- If the task is a [`StepTask`](#33-step-tasks) without a message id yet, it is held in a separate
`pendingTasks` collection until its id is assigned.
### Execution — `handle(TaskContext)`
1. Look up the task by `context.getId()`.
2. For ordinary (non-built-in, non-persistable) tasks, acquire a **`ReentrantLock`** so the same task
cannot be executed concurrently (prevents double-clicks / races). Tasks that share a
[parent](#trait-interfaces) share the same lock id.
3. Call `task.execute(context)`.
4. Apply the returned `Lifecycle` (keep / remove / remove-with-children); persistable tasks removed
here are also deleted from the DB.
5. If the task is [`Deletable`](#trait-interfaces), delete the original message/interaction.
6. Unless the task is [`NotAcknowledgeable`](#trait-interfaces), acknowledge the interaction.
### Startup — `init`
Called from `App.startup()`:
1. Reflectively load all [`BuiltInTask`](#32-built-in-tasks) implementations.
2. Reflectively load all [`ScheduledTask`](#34-scheduled-tasks) implementations and register them
with [`ScheduledTaskManager`](threading.md).
3. Load persistable tasks from the database (via `TaskService`) and reconstruct them (see
[persistence rules](#31-persistable-tasks)).
## Button, emoji and modal tasks
These are the "interaction" tasks and the original purpose of the subsystem:
- **`ButtonTask`** — executed when a button whose `componentId` matches the task id is clicked. The
matching context is a [`ButtonTaskContext`](contexts.md#task-contexts).
- **`ModalTask`** — executed when a modal is submitted; matched by the modal `customId`. A modal is
typically opened by another interaction (e.g. `ReportIssueTask` opens a title/description modal).
- **`EmojiTask`** — executed when a reaction is added to a specific message; matched by the composite
`guildId:messageId` id.
The generic helpers `GenericCustomIdTask` / `GenericRandomIdTask` let a command install an ad-hoc,
function-backed task under a chosen or generated id (e.g. the generic `AcceptButton` / `CancelButton`
under `tasks/generic`).
## 3.1 Persistable tasks
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
`core.tasks.persistence.TaskRegistry`, `core.tasks.persistence.TaskFactory`;
resource `src/main/resources/persistable-tasks.json`; entity
[`OTask`](persistence.md#entity-catalog).
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup. The
rules for *how* a persistable task can be defined and reconstructed live in `TaskRegistry` and
`TaskFactory`.
### The mapping file — `persistable-tasks.json`
The file declares, for each persistable task class, the constructor parameters and their types:
```json
{
"tasks": [
{
"class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask",
"params": ["guildId", "channelId", "messageId", "emojiMapping"],
"types": ["string", "string", "string", "map"]
}
]
}
```
Schema:
| Key | Meaning |
|----------|------------------------------------------------------------------|
| `tasks` | array of task mappings |
| `class` | fully-qualified class name; **must** implement `PersistableTask` |
| `params` | ordered list of payload field names |
| `types` | parallel list of types for those fields |
Supported types (`TaskFactory.ParamType`): `string` → `String`, `int` → `Integer`, `map` → `Map`.
### `TaskRegistry` — loading the mappings
`TaskRegistry.loadTaskConfig()` reads `persistable-tasks.json` from the classpath and, for each entry,
registers a `TaskCreator` (a functional interface `PersistableTask fromPayload(JSONObject payload)`)
keyed by class name:
```java
taskCreators.put(className, payload -> {
try {
return TaskFactory.createTaskInstance(className, params, types, payload);
} catch (Exception e) {
logger.error("Failed to create instance of '{}': {}", className, e.getMessage());
return null;
}
});
```
Entries missing `class`, `params`, or `types` are logged and skipped. Consumers later look up a
creator via `TaskRegistry.getTaskCreator(className)`.
### `TaskFactory` — reconstructing an instance
`TaskFactory.createTaskInstance(className, params, types, payload)`:
1. Validates that `params.length == types.length`.
2. Loads the class and verifies it implements `PersistableTask`.
3. For each declared parameter, converts the JSON `types[i]` to a `ParamType`, then extracts
`payload[params[i]]` as the corresponding Java type:
- `string` → `payload.getString(name)`
- `int` → `payload.getInt(name)`
- `map` → `payload.getJSONObject(name).toMap()`
4. Reflectively resolves the constructor whose parameter types match `types` and instantiates it with
the extracted values.
```java
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
```
> **Rule of thumb for adding a persistable task:** create a class that implements `PersistableTask`
> (usually by extending `AbstractPersistableTask`), give it a constructor whose parameters match the
> `params`/`types` you declare, ensure it serializes those same fields into its JSON payload, and add
> a matching entry to `persistable-tasks.json`. The payload is stored in the
> [`OTask.payload`](persistence.md#entity-catalog) column and reconstructed via the flow above on
> startup.
### Persistence round-trip
```
insert(task) ──▶ TaskService serializes payload ──▶ OTask row (task_id, class, payload JSON)
startup ──▶ TaskService reads OTask rows ──▶ TaskRegistry.getTaskCreator(class)
.fromPayload(payload) ──▶ TaskFactory ──▶ instance
```
## 3.2 Built-in tasks
**Classes:** `BuiltInTask`, `AbstractBuiltInTask`.
Built-in tasks are hardcoded tasks with hardcoded ids, conceptually similar to commands. They are
loaded reflectively at startup (in `TaskManager.init()`) and stay registered for the lifetime of the
bot (`Lifecycle.BUILTIN`).
A button that triggers a built-in task is created from its class:
```java
Button button = BuiltInTask.createButton(SomeBuiltInTask.class);
```
This produces a JDA `Button` whose component id equals the built-in task's hardcoded id, so clicking
it routes straight to that task. Examples: the help buttons `BotInfo`, `CommandHelp`, `ReportBug`
(`tasks/help`).
## 3.3 Step tasks
**Classes:** `StepTask` (interface), `AbstractStepTask`.
Step tasks ask the user a series of questions and run logic based on each answer. Because Discord has
no native "conversation" primitive, the abstraction tracks the **last message id** and only runs the
next step when *that* message is replied to.
Flow:
1. A step task is created (e.g. by a setup command) and sends a prompt message; initially it is keyed
by a random UUID and held in `pendingTasks`.
2. Once the prompt message exists, the task **re-keys itself to that message's id** and moves into the
active `tasks` map.
3. When the user replies to that message, the incoming `MessageReceivedEvent` becomes a
[`MessageTaskContext`](contexts.md#task-contexts) whose id is the replied-to message id — so
`TaskManager` finds the step task.
4. `executeStep` validates/processes the answer and returns a `StepResult`:
success, failure, or **next step** (which installs the following step task).
`AbstractStepTask` provides answer validation keyed to a step "type" (e.g. a role id, an emoji, …).
## 3.4 Scheduled tasks
**Classes:** `ScheduledTask`, coordinated by
[`ScheduledTaskManager`](threading.md#scheduledtaskmanager).
Scheduled tasks are time-based background jobs (as opposed to id-based interaction tasks). They are
loaded reflectively at startup and registered with `ScheduledTaskManager`, which schedules them at a
fixed interval derived from the task's `TimeFrame` (e.g. minutely / hourly / daily / custom) and
cancels them all on shutdown.
> **Status:** the scheduled-task abstraction is intended to eventually replace the ad-hoc
> [`ThreadManager`](threading.md) threads and is still evolving. The concrete scheduled tasks that
> exist today are listed in the [catalog](#concrete-task-catalog).
## Trait interfaces
Tasks can opt into extra behavior by implementing marker/behavior interfaces in
`core.tasks.trait`:
| Trait | Effect |
|----------------------|------------------------------------------------------------------------------------------|
| `Deletable` | After successful execution, `TaskManager` deletes the original message/interaction. |
| `NotAcknowledgeable` | Suppresses the default acknowledgement (the task sends its own response). |
| `WithButton` | The task can render itself as a JDA `Button` (`createButton()`). |
| `WithChildren` | The task tracks child tasks and can clean them up together (`REMOVE_SELF_AND_CHILDREN`). |
| `WithParent` | Links a child task to a parent; children share the parent's execution lock. |
## Concrete task catalog
| Task | Package | Base type | Description |
|-----------------------------|-------------|------------------------------|--------------------------------------------------------------|
| `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. |
| `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. |
| `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. |
| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a GitLab issue. |
| `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. |
| `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. |
| `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. |
| `LoadPlaylistTask` | `music` | `ButtonTask` (with children) | Present playlist/search results as choice buttons. |
| `LoadTrackFromPlaylistTask` | `music` | `ButtonTask` | Load one specific track from the results. |
| `EmojiRoleFirstStep` | `reaction` | `AbstractStepTask` | Ask for the target role id. |
| `EmojiRoleSecondStep` | `reaction` | `AbstractStepTask` | Ask for the emoji to map. |
| `EmojiRoleModify` | `reaction` | `AbstractStepTask` | Modify an existing emoji-role mapping. |
| `EmojiRoleRemoveStep` | `reaction` | `AbstractStepTask` | Remove an emoji-role mapping. |
| `MusicManagerCleanup` | `scheduled` | `ScheduledTask` | Evict unused per-guild music managers. |
| `MusicPlayerAutoDisconnect` | `scheduled` | `ScheduledTask` | Disconnect from voice when idle. |
| `TaskCleanup` | `scheduled` | `ScheduledTask` | Periodically remove expired non-persistable tasks. |
### Worked example — emoji-role setup (step → persistable)
1. `EmojiRoleFirstStep` sends a prompt: "reply with the role id"; `AbstractStepTask` validates it is a
real role in the guild.
2. `executeStep` returns `StepResult.next(new EmojiRoleSecondStep(...))`, carrying the chosen role.
3. `EmojiRoleSecondStep` asks for the emoji; on a valid answer it creates an
`EmojiReactionRoleTask`.
4. That `EmojiReactionRoleTask` is `insert`ed into `TaskManager` — because it is a
[persistable task](#31-persistable-tasks) it is written to the `tasks` table and will be restored
on the next startup via the [`persistable-tasks.json`](#the-mapping-file--persistable-tasksjson)
mapping.
### Worked example — GitLab bug report (modal)
1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields.
2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values.
3. The task connects to GitLab (using `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` from
configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
### Worked example — music results (parent/children)
1. `LoadPlaylistTask` posts a message with a button per result (children:
`LoadTrackFromPlaylistTask` / `AcceptButton`).
2. All children share the parent's lock id ([`WithParent`](#trait-interfaces)), so simultaneous
clicks are serialized.
3. Selecting a result can return `REMOVE_SELF_AND_CHILDREN` to tear down the whole interaction.
-46
View File
@@ -1,46 +0,0 @@
# Threading & Scheduling
**Package:** `me.bobert.netneutrality.platform.concurrency`
This subsystem centralizes asynchronous execution and recurring jobs.
## Classes
| Class | Responsibility |
|---|---|
| `ThreadManager` | Static registry of `ExecutorService` instances shared across the bot. |
| `BaseThreadFactory` | Custom `ThreadFactory` giving threads meaningful names and an uncaught-exception handler. |
| `ScheduledTaskManager` | Schedules and cancels recurring [scheduled tasks](tasks.md#34-scheduled-tasks). |
## `ThreadManager`
- `getScheduledExecutor()` — a `ScheduledThreadPool` sized to the number of CPU cores.
- `getExecutor(name)` — returns (and caches) a named thread pool for ad-hoc async work.
- `shutdownExecutor()` — shuts the executors down cleanly; called from
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
## `BaseThreadFactory`
Names created threads (e.g. `web-api-1`) so they are identifiable in logs and stack dumps, and installs
an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
`UncaughtExceptionHandler` so crashes on worker threads are still routed through
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used both by `ThreadManager` and by the
[Web API](web-api.md) server pool.
## `ScheduledTaskManager`
Coordinates [`ScheduledTask`](tasks.md#34-scheduled-tasks) instances (discovered reflectively at
startup by [`TaskManager.init()`](tasks.md#taskmanager)):
- `schedule(ScheduledTask task)` — schedules the task at a fixed delay/interval derived from its
`TimeFrame` (minutely / hourly / daily / custom), using the shared scheduled executor.
- `cancelAll()` — cancels every scheduled task; called first in
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect` (which reads the
bot's gateway voice state rather than JDA's audio manager, see [music.md](music.md)), and
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
> threads created via `ThreadManager`, giving a single, uniform way to express recurring background
> work. It is still evolving (see [tasks.md §3.4](tasks.md#34-scheduled-tasks)).
-165
View File
@@ -1,165 +0,0 @@
# Utilities, Bootstrap, Logging & Exceptions
This document covers the cross-cutting glue of the bot: the entry points, configuration, logging /
error tracking, the shared utility helpers, and the custom exception hierarchy.
## Bootstrap
**Package:** `me.bobert.netneutrality` / `me.bobert.netneutrality.bootstrap`
| Class | Role |
|---|---|
| `App` | The application: holds the process instance and the `JDA` handle, builds the `ApplicationContext`, runs `context.start()` / `context.stop()`, and is the JAR main class (`main()`). See [architecture.md](architecture.md). |
| `bootstrap.ApplicationContext` | Hand-rolled container: `register(T)`, `get(Class<T>)`, ordered `start()` / `stop()` for `Lifecycle` components. |
| `bootstrap.Lifecycle` | Optional component contract: `start() throws Exception`, default `stop()`, default `priority()`. |
| `NetNeutralityBot` | Currently an **empty placeholder** class under `framework` (reserved name; no logic yet). |
| `core.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
The full startup and shutdown sequences are documented in
[architecture.md](architecture.md#startup-lifecycle).
### `ApplicationContext` + `Lifecycle`
Domain services are constructed once in `App.buildContext()`, registered with the context,
and resolved later by type:
- `register(component)` — indexes the concrete class; if the component implements `Lifecycle` it
participates in ordered start/stop.
- `get(Class<T>)` — returns the first registered instance assignable to `T` (interfaces work).
- `start()` — sorts `Lifecycle` components by ascending `priority()` and starts them.
- `stop()` — stops them in the exact reverse order; individual failures are logged and swallowed so
remaining components still tear down.
Constructor injection for reflectively loaded commands/tasks is handled by
[`ReflectionHelper`](#reflectionhelper): the richest public constructor whose parameter types all
resolve via `context.get(type)` is preferred; otherwise the no-arg constructor is used.
**Interfaces used for injection** (implemented by the concrete managers registered above):
| Interface | Typical implementor |
|---|---|
| `platform.config.SettingsProvider` | `utils.Settings` |
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
| `feature.music.MusicService` | `music.MusicServiceImpl` |
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
static access points left are:
- `Settings.getInstance()` — early bootstrap only (`main()`, Sentry, token validation) before the
context exists; the same instance is registered as `SettingsProvider` for everything else.
- `App.getInstance()` — application root used by a couple of shutdown entry points
(`ShutdownCommand`). Prefer constructor injection of the needed collaborators where possible.
- `JpaUtils.bind(SessionProvider)` / `LogHandler.bind(GuildServiceProvider)` /
`ChannelMessagePurger.bind(ExecutorProvider)` — single well-defined static bind points for deep
utility graphs that are not constructor-injected (repositories, static log helpers).
## Configuration
### `Settings` / `SettingsProvider`
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements
`me.bobert.netneutrality.platform.config.SettingsProvider`.
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties`
file and overlays **environment variables** on top (environment variables win).
- **Early bootstrap:** `Settings.getInstance()` (process-wide, created once before the context).
- **Runtime injection:** depend on `SettingsProvider` and let `ApplicationContext` supply the same
instance. Prefer this path in commands, tasks, handlers, and services.
- `Settings.getVersion()` / `Settings.getDiscordInvite()` — thin static helpers used by the banner /
invite strings; they delegate to the bootstrap instance.
An example configuration file is `bot.properties.example`. Key configuration values:
| Key | Purpose |
|---|---|
| `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. |
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). |
| `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). |
| `NETNEUTRALITY_BOT_OWNER` | Bot owner user id (used for owner-only commands / permissions). |
| `NETNEUTRALITY_BOT_INVITE` | Invite link surfaced by help/info. |
| `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). |
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). |
| `NETNEUTRALITY_LAVALINK_URI` / `_PASSWORD` / `_NAME` | Connection settings for the [Lavalink node](music.md#settings). |
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. |
## Utility helpers
**Package:** `me.bobert.netneutrality.shared.util` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.platform.*`
| Class | Purpose |
|---|---|
| `Settings` | Configuration (see above). |
| `ReflectionHelper` | Classpath scanning / reflective instantiation (see below). |
| `ContextUtils` | Boilerplate helpers for building/handling [contexts](contexts.md). |
| `GuildUtils` | Guild-related helpers (lookups, permission checks). |
| `JpaUtils` | Session/transaction helpers for the [persistence](persistence.md) layer. Bound once to a `SessionProvider` during bootstrap (`JpaUtils.bind`). |
| `MusicUtils` | Voice-channel connectivity and DJ/permission checks for [music](music.md). Callers pass the guild `MusicManager` (no static music lookup). |
| `MiscUtils` | Assorted small helpers. |
| `ChannelMessagePurger` | Bulk message deletion (used by the purge command). Bound once to an `ExecutorProvider` during bootstrap. |
| `MessageFunction` | Standardized message/embed formatting. Help text builders take a `CommandRegistry` rather than a static command manager. |
| `UserFunction` | Standardized user/string formatting. |
### `ReflectionHelper`
Built on the `org.reflections` library, `ReflectionHelper` discovers and instantiates implementation
classes in specific packages. It is the mechanism behind the bot's "drop a class in the right package
and it's picked up" model, used to auto-load:
- [`Command`](commands.md) implementations (`CommandManager` / `CommandRegistry.start()`),
- [`BuiltInTask`](tasks.md#32-built-in-tasks) and [`ScheduledTask`](tasks.md#34-scheduled-tasks)
implementations (`TaskManager.start()`).
When given an `ApplicationContext`, `newInstance(Class, ApplicationContext)` selects the public
constructor with the most parameters that can all be resolved via `context.get(parameterType)`. If
no such constructor exists it falls back to the no-arg constructor. That is how music/admin/react
commands receive `MusicService`, `GuildServiceProvider`, `TaskManager`, etc. without static
singletons.
## Logging & error tracking
### `LogHandler`
**Class:** `me.bobert.netneutrality.platform.logging.LogHandler`.
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
1. **Log4j2** (configured by `log4j2.properties`; SLF4J is bridged via `log4j-slf4j2-impl`),
2. **Sentry** (when `NETNEUTRALITY_SENTRY_DSN` is configured), and
3. optionally a per-guild **Discord debug channel** (e.g. `sendMessageToDebugChannel(guild, msg)`),
gated by [`GuildService.isDebugEnabled()`](persistence.md#service-layer) /
[`OLogging`](persistence.md#entity-catalog) configuration.
The Sentry **user** context is attached automatically when a
[`AbstractContext`](contexts.md#abstractcontext-abstract-class) is created, so captured errors are
attributed to the Discord user that triggered them.
## Exception hierarchy
**Package:** `me.bobert.netneutrality.exceptions`
```
RuntimeException
└── ContextException ── carries a Context (guild/channel/user)
├── CommandExecutionException ── thrown during command processing
└── TaskExecutionException ── thrown during task execution
GracefulContextError ── user-facing, non-logged failure → ephemeral reply
ExecutorException ── Thread.UncaughtExceptionHandler → routes crashes to LogHandler
```
- **`ContextException`** — base runtime exception that carries the [`Context`](contexts.md) in which
it occurred, so downstream handling can respond in the right channel and attribute the error.
- **`CommandExecutionException`** / **`TaskExecutionException`** — specializations thrown by the
[command](commands.md) and [task](tasks.md) flows respectively.
- **`GracefulContextError`** — signals an expected, user-facing problem (e.g. bad input); when caught
it produces an ephemeral message to the user instead of a full error log/Sentry event.
- **`ExecutorException`** — an `UncaughtExceptionHandler` installed by
[`BaseThreadFactory`](threading.md#basethreadfactory) so exceptions escaping worker threads are still
captured by [`LogHandler`](#loghandler).
-51
View File
@@ -1,51 +0,0 @@
# HTTP Web API
**Package:** `me.bobert.netneutrality.platform.web` (client module) and
`me.bobert.netneutrality.platform.web.server` (server).
**Server:** JDK built-in `com.sun.net.httpserver.HttpServer` (no external web framework).
The bot exposes a small internal HTTP API for health monitoring, control, and guild introspection —
intended for an external dashboard/companion, not for end users in Discord.
## Server startup
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
It receives `SettingsProvider`, `GuildServiceProvider`, and suppliers for the live `JDA` /
shutdown callback via its constructor. On `start()` it:
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
- creates an `HttpServer` bound to that port,
- installs an `Executor` backed by a fixed thread pool of 10 threads named via
[`BaseThreadFactory`](threading.md),
- registers the handler contexts (below), and
- starts the server.
On `stop()` the server and its executor are shut down gracefully.
## Handler model
`AbstractHandler` (implements `HttpHandler`) is the base for all handlers and provides helpers such as
`sendJsonResponse(...)` and `sendStringResponse(...)`. Concrete handlers extend it and are mounted at
a path prefix.
| Handler | Base path | Endpoints |
|---|---|---|
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown; `/actions/settings/current/{guildId}` — current guild settings; `/actions/settings/reload/{guildId}` — invalidate & reload the settings cache. |
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings
endpoints).
## HTTP client — `WebModule`
`WebModule` wraps an `OkHttpClient` for the bot's own outbound HTTP calls (e.g. checking for updates
via GitLab). It keeps a small `LinkedHashMap`-based response cache limited to 50 entries.
## Notes
- There is no authentication layer documented on these endpoints; the API is expected to be bound to a
trusted/internal network (e.g. within the Docker network). `/actions/shutdown` is destructive, so the
port should not be exposed publicly.
-65
View File
@@ -1,65 +0,0 @@
server:
port: 2333
address: 0.0.0.0
lavalink:
plugins:
# Replacement for the in-process dev.lavalink.youtube:v2 source manager.
# Custom SABR-enabled fork (ashton045/youtube-source, feat/sabr-support) built by JitPack.
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:33d50abd5f"
repository: "https://jitpack.io"
snapshot: false
server:
password: "${SERVER_PASSWORD:youshallnotpass}"
sources:
# Handled by the youtube-plugin below, must stay disabled here.
youtube: false
soundcloud: true
bandcamp: false
twitch: false
vimeo: false
nico: false
http: true
local: false
filters:
volume: true
bufferDurationMs: 400
frameBufferDurationMs: 5000
opusEncodingQuality: 10
resamplingQuality: LOW
trackStuckThresholdMs: 10000
useSeekGhosting: true
youtubePlaylistLoadLimit: 6
playerUpdateInterval: 5
youtubeSearchEnabled: true
soundcloudSearchEnabled: true
plugins:
youtube:
enabled: true
allowSearch: true
allowDirectVideoIds: true
allowDirectPlaylistIds: true
clients:
- TV
- MWEB
- WEB
- MUSIC
- WEBEMBEDDED
- ANDROID_VR
- TVHTML5_SIMPLY
oauth:
enabled: true
refreshToken: "${NETNEUTRALITY_OAUTH_TOKEN}"
remoteCipher:
url: "${NETNEUTRALITY_CIPHER_URL:http://ejs-api:8001}"
password: "${NETNEUTRALITY_CIPHER_PASSWORD:}"
remotePot:
url: "${NETNEUTRALITY_POT_URL:http://webpo:8080}"
logging:
level:
root: INFO
lavalink: DEBUG
# Set to INFO to see the OAuth flow instructions (device code / verification URL) in the terminal.
dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO
+33 -17
View File
@@ -12,7 +12,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<main.class>me.bobert.netneutrality.App</main.class> <main.class>me.bobert.netneutrality.NetNeutrality</main.class>
<java.version>25</java.version> <java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>25</maven.compiler.source> <maven.compiler.source>25</maven.compiler.source>
@@ -146,11 +146,35 @@
<artifactId>JDA</artifactId> <artifactId>JDA</artifactId>
<version>6.4.1</version> <version>6.4.1</version>
</dependency> </dependency>
<!-- Lavalink v4 client; audio decoding happens on the remote node. -->
<dependency> <dependency>
<groupId>dev.arbjerg</groupId> <groupId>dev.arbjerg</groupId>
<artifactId>lavalink-client</artifactId> <artifactId>lavaplayer</artifactId>
<version>3.4.0</version> <version>2.2.6</version>
</dependency>
<dependency>
<groupId>dev.lavalink.youtube</groupId>
<artifactId>v2</artifactId>
<version>1.18.1</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-api</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-linux-x86-64</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-win-x86-64</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-darwin</artifactId>
<version>0.1.8</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -159,12 +183,6 @@
<version>5.3.2</version> <version>5.3.2</version>
</dependency> </dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>6.7.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.hibernate.orm</groupId> <groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId> <artifactId>hibernate-core</artifactId>
@@ -201,13 +219,6 @@
<groupId>org.gitlab4j</groupId> <groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId> <artifactId>gitlab4j-api</artifactId>
<version>6.2.0</version> <version>6.2.0</version>
<exclusions>
<!-- Clashes with the Servlet 5 API used by Javalin's Jetty 11 in the shaded jar. -->
<exclusion>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
@@ -215,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>
@@ -1,229 +0,0 @@
package me.bobert.netneutrality;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.bootstrap.BotState;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.db.SessionManager;
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
import me.bobert.netneutrality.db.service.GuildServiceProviderImpl;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.hooks.EventsHandler;
import me.bobert.netneutrality.http.server.WebApi;
import me.bobert.netneutrality.music.MusicServiceImpl;
import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
import me.bobert.netneutrality.db.JpaUtils;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
public class App {
private static final Logger logger = LogManager.getLogger(App.class);
private static App instance;
private final Settings settings;
private JDA jda;
private MusicService music;
private final ApplicationContext context = new ApplicationContext();
static void main() {
Settings settings = new Settings();
String version = settings.getMavenVersion();
WebModule.setDefaultVersion(version);
logger.info("Starting App v{}", version);
String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = settings.get("NETNEUTRALITY_SENTRY_DSN");
if (sentryDsn != null) {
logger.info("Found Sentry DSN in config, setting up the SDK..");
Sentry.init(options -> {
options.setDsn(sentryDsn);
options.setEnvironment(env);
options.setTracesSampleRate(1.0);
options.setDebug(false);
options.setRelease(version);
});
}
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG);
}
instance = new App(settings);
}
public App(Settings settings) {
this.settings = settings;
startup();
}
private void startup() {
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new RuntimeException("No bot token provided, exiting.");
}
buildContext();
try {
context.start();
} catch (Exception e) {
LogHandler.log(e);
System.exit(1);
}
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
}
/**
* Registers domain services and remaining startup/shutdown adapters with the
* {@link ApplicationContext}. Ascending {@link Lifecycle#priority()} drives startup order; the
* context stops components in the exact reverse order.
*/
private void buildContext() {
// Config first so SettingsProvider is resolvable for constructor injection.
context.register((SettingsProvider) settings);
// Allow ApplicationContext itself to be injected into components that need it.
context.register(context);
// Persistence: build the SessionFactory + run migrations; released last on shutdown.
// SessionManager binds itself into JpaUtils for repository access.
context.register(new SessionManager(settings));
// Executors: no explicit start today; shut down after music players are disconnected.
ExecutorProvider executors = context.register(new ThreadManager());
ChannelMessagePurger.bind(executors);
// Scheduled tasks (priority 55): discovers and schedules all ScheduledTask implementations
// on start; cancelled early on shutdown, before executors.
context.register(new ScheduledTaskManager(executors, context));
// Command registry (priority 30).
CommandRegistry commands = context.register(new CommandManager(context));
// Task registry (priority 30).
TaskManager tasks = context.register(new TaskManagerImpl(context));
// Per-guild service cache (not a Lifecycle component).
GuildServiceProvider guildServices = context.register(new GuildServiceProviderImpl());
LogHandler.bind(guildServices);
JpaUtils.bindGuildServiceLookup(guildServices::get);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown.
context.register(lifecycle(60, this::initJDA, this::disconnect));
// Register slash commands, attach the event handler and set the presence once JDA is ready.
context.register(lifecycle(65, () -> {
commands.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
jda.getPresence().setActivity(Activity.watching("you"));
}, () -> { }));
// Web API (priority 70) — Lifecycle with graceful stop.
context.register(new WebApi(settings, guildServices, this::getJDA, this::shutdown));
}
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
return new Lifecycle() {
@Override
public void start() throws Exception {
start.run();
}
@Override
public void stop() {
stop.run();
}
@Override
public int priority() {
return priority;
}
};
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
public static App getInstance() {
return instance;
}
public void initJDA() {
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager sessions = context.get(SessionManager.class);
if (sessions != null) {
sessions.stop();
}
throw new RuntimeException("No bot token provided, exiting.");
}
try {
jda = JDABuilder
.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
// DAVE encryption is handled by the node.
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
LogHandler.log(e);
System.exit(1);
}
}
public void shutdown() {
logger.info("[Net Neutrality] Shutting down...");
context.stop();
logger.info("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -0,0 +1,138 @@
package me.bobert.netneutrality;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import io.sentry.Sentry;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.core.db.DatabaseMigration;
import me.bobert.netneutrality.core.db.SessionManager;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.thread.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
import me.bobert.netneutrality.hooks.EventsHandler;
import me.bobert.netneutrality.http.server.WebApi;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.Settings;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
public class NetNeutrality {
private static final Logger logger = LogManager.getLogger(NetNeutrality.class);
public static final String VERSION = Settings.getVersion();
private static NetNeutrality instance;
private static JDA jda;
static void main() {
System.out.println("Starting NetNeutrality v" + VERSION);
String env = Settings.getInstance().get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = Settings.getInstance().get("NETNEUTRALITY_SENTRY_DSN");
if (sentryDsn != null) {
logger.info("Found Sentry DSN in config, setting up the SDK..");
Sentry.init(options -> {
options.setDsn(sentryDsn);
options.setEnvironment(env);
options.setTracesSampleRate(1.0);
options.setDebug(false);
options.setRelease(Settings.getVersion());
});
}
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
}
instance = new NetNeutrality();
}
public NetNeutrality() {
startup();
}
private void startup() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new RuntimeException("No bot token provided, exiting.");
}
SessionManager.getSessionFactory();
DatabaseMigration.migrate();
CommandManager.init();
MusicManager.initPlayerManager();
TaskManager.getInstance().init();
initJDA();
CommandManager.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler());
new WebApi(Integer.parseInt(Settings.getInstance().get("NETNEUTRALITY_API_PORT", "9000")));
jda.getPresence().setActivity(Activity.watching("you"));
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
}
public static NetNeutrality getInstance() {
return instance;
}
public void initJDA() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager.shutdown();
throw new RuntimeException("No bot token provided, exiting.");
}
try {
jda = JDABuilder
.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
.setAudioModuleConfig(
new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
LogHandler.log(e);
System.exit(1);
}
}
public void shutdown() {
System.out.println("[Net Neutrality] Shutting down...");
ScheduledTaskManager.cancelAll();
MusicManager.disconnectPlayers();
ThreadManager.shutdownExecutor();
disconnect();
SessionManager.shutdown();
System.out.println("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -1,99 +0,0 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* A small hand-rolled application container that owns wired instances and drives ordered
* startup/shutdown.
*
* <p>Components implementing {@link Lifecycle} are started in ascending {@link Lifecycle#priority()}
* order and stopped in the exact reverse order, giving symmetric, deterministic startup and
* shutdown without any DI framework.
*/
public final class ApplicationContext {
private static final Logger logger = LogManager.getLogger(ApplicationContext.class);
private final List<Lifecycle> components = new ArrayList<>();
private final Map<Class<?>, Object> instances = new HashMap<>();
/**
* Registers a component with the context. If the component implements {@link Lifecycle} it will
* participate in ordered start/stop. The instance is also indexed by its concrete type and can
* be resolved later via {@link #get(Class)}.
*
* @param component the component to register
* @param <T> the component type
* @return the same component, for fluent wiring
*/
public <T> T register(T component) {
if (component instanceof Lifecycle lifecycle) {
components.add(lifecycle);
}
instances.put(component.getClass(), component);
return component;
}
/**
* Resolves a previously registered instance assignable to the given type.
*
* @param type the requested type
* @param <T> the requested type
* @return the matching instance, or {@code null} if none was registered
*/
public <T> T get(Class<T> type) {
Object direct = instances.get(type);
if (direct != null) {
return type.cast(direct);
}
for (Object instance : instances.values()) {
if (type.isInstance(instance)) {
return type.cast(instance);
}
}
return null;
}
/**
* Starts all registered {@link Lifecycle} components in ascending priority order.
*
* @throws Exception if any component fails to start
*/
public void start() throws Exception {
components.sort(Comparator.comparingInt(Lifecycle::priority));
for (Lifecycle component : components) {
logger.debug("Starting component {} (priority {})", component.name(), component.priority());
component.start();
}
}
/**
* Stops all registered {@link Lifecycle} components in the reverse of their start order. Failures
* are logged and do not prevent the remaining components from being stopped.
*/
public void stop() {
List<Lifecycle> reversed = new ArrayList<>(components);
reversed.sort(Comparator.comparingInt(Lifecycle::priority));
Collections.reverse(reversed);
for (Lifecycle component : reversed) {
try {
logger.debug("Stopping component {} (priority {})", component.name(), component.priority());
component.stop();
} catch (Exception e) {
logger.error("Error while stopping component {}", component.name(), e);
}
}
}
}
@@ -1,56 +0,0 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Contract for a component whose lifecycle is owned and driven by the {@link ApplicationContext}.
*
* <p>Components are {@linkplain #start() started} in ascending {@link #priority()} order and
* {@linkplain #stop() stopped} in the exact reverse order, giving symmetric, deterministic startup
* and shutdown without any DI framework. A lower {@code priority()} means the component is started
* earlier and, consequently, stopped later.
*/
public interface Lifecycle {
/**
* Brings the component up. Called exactly once, during {@link ApplicationContext#start()}.
*
* @throws Exception if the component cannot be started; startup is aborted
*/
void start() throws Exception;
/**
* Tears the component down, releasing whatever {@link #start()} acquired. Called at most once,
* during {@link ApplicationContext#stop()}. Implementations should be null-safe / idempotent so
* they can cope with a component that never fully started.
*
* <p>The default is a no-op for components that hold no resources.
*
* @throws Exception if teardown fails; the context logs it and continues stopping the rest
*/
default void stop() throws Exception {
}
/**
* Relative ordering weight. Lower values start earlier and stop later; equal values keep their
* registration order. Defaults to {@code 0}.
*
* @return the priority of this component
*/
default int priority() {
return 0;
}
/**
* Human-readable name used in lifecycle diagnostics/logging. Defaults to the simple class name.
*
* @return a short label identifying this component
*/
default String name() {
return getClass().getSimpleName();
}
}
@@ -1,129 +0,0 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.reflections.Reflections;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
public class ReflectionHelper {
private static final Logger logger = LogManager.getLogger(ReflectionHelper.class);
private static final BiConsumer<Class<?>, Exception> DEFAULT_FAILURE = (aClass, e) -> logger.error("Failed to load class: {}", aClass.getName(), e);
public static <E> void loadClassesFromPackage(String packageName, Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage(packageName, clazz, null, onInstanciated, onFailed);
}
/**
* Discovers concrete subtypes of {@code clazz} under {@code packageName} and instantiates each
* one. When a non-null {@link ApplicationContext} is provided, prefers the public constructor
* with the most parameters whose every parameter type is resolvable via
* {@link ApplicationContext#get(Class)}; otherwise (or when no such constructor exists) falls
* back to the no-arg constructor.
*
* @param packageName root package to scan
* @param clazz base type of classes to load
* @param context optional DI context used for constructor injection; {@code null} keeps
* legacy no-arg construction only
* @param onInstanciated callback invoked with each successfully created instance
* @param onFailed callback invoked when a class cannot be instantiated
* @param <E> the base type
*/
public static <E> void loadClassesFromPackage(String packageName, Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
Set<Class<? extends E>> classes = new Reflections(packageName).getSubTypesOf(clazz);
for (Class<? extends E> s : classes) {
try {
if (Modifier.isAbstract(s.getModifiers())) {
continue;
}
E instance = instantiate(s, context);
onInstanciated.accept(instance);
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException
| InvocationTargetException | NoSuchMethodException | SecurityException e) {
onFailed.accept(s, e);
}
}
}
/**
* Instantiates {@code type} using context-based constructor injection when possible.
*
* <p>Selection algorithm: among public constructors, pick the one with the most parameters for
* which every parameter type resolves to a non-null value via {@code context.get(paramType)}.
* If no such constructor exists (or {@code context} is null), fall back to the public no-arg
* constructor.
*/
@SuppressWarnings("unchecked")
public static <E> E instantiate(Class<? extends E> type, ApplicationContext context)
throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
if (context != null) {
Constructor<?> best = null;
Object[] bestArgs = null;
for (Constructor<?> ctor : type.getConstructors()) {
Class<?>[] paramTypes = ctor.getParameterTypes();
if (paramTypes.length == 0) {
continue;
}
Object[] args = new Object[paramTypes.length];
boolean resolvable = true;
for (int i = 0; i < paramTypes.length; i++) {
Object dependency = context.get(paramTypes[i]);
if (dependency == null) {
resolvable = false;
break;
}
args[i] = dependency;
}
if (resolvable && (best == null || paramTypes.length > best.getParameterCount())) {
best = ctor;
bestArgs = args;
}
}
if (best != null) {
return (E) best.newInstance(bestArgs);
}
}
return type.getConstructor().newInstance();
}
public static <E> void loadCommands(Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed);
}
public static <E> void loadCommands(Class<E> clazz, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadCommands(Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadTasks(Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed);
}
public static <E> void loadTasks(Class<E> clazz, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadTasks(Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE);
}
}
@@ -12,23 +12,14 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.entities.OBlacklist; import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.service.BlacklistService; import me.bobert.netneutrality.db.service.BlacklistService;
import me.bobert.netneutrality.db.service.GuildService;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import java.util.List; import java.util.List;
import java.util.Objects;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
public class BlacklistCommand extends AdminCommand { public class BlacklistCommand extends AdminCommand {
private final GuildServiceProvider guildServices;
public BlacklistCommand(GuildServiceProvider guildServices) {
Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null");
this.guildServices = guildServices;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -64,7 +55,7 @@ public class BlacklistCommand extends AdminCommand {
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand(); String sub = context.getSubcommand();
String word = context.getOption("word", "", CommandOption::getAsString); String word = context.getOption("word", "", CommandOption::getAsString);
BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService(); BlacklistService service = GuildService.get(context.getGuild()).getBlacklistService();
switch (sub) { switch (sub) {
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> { case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
@@ -1,78 +0,0 @@
package me.bobert.netneutrality.command.admin;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class PurgeCommand extends AbstractCommand {
@Override
public boolean needAdmin() {
return true;
}
@Override
public boolean isEnabled() {
return false;
}
@Override
public boolean isGuildOnly() {
return true;
}
@Override
public String getCommandName() {
return "purge";
}
@Override
public String getDescription() {
return "Delete messages from the channel you are currently on";
}
@Override
public void registerCommandOptions(CommandOptionsBuilder builder) {
builder.addOptions(
new OptionData(OptionType.INTEGER, "to-purge", "Number of messages to delete (max 2500)", true)
.setRequiredRange(1, 2500));
}
@Override
public CommandCategory getCategory() {
return CommandCategory.ADMINISTRATION;
}
@Override
public CommandExecutionResult execute(CommandContext context) {
int toDelete = context.getOption("to-purge", 0, CommandOption::getAsInt);
context.ephemeral();
if (toDelete > 0) {
GuildMessageChannel messageChannel = (GuildMessageChannel) context.getChannel();
ChannelMessagePurger channelMessagePurger = ChannelMessagePurger.getPurger(messageChannel);
boolean lock = channelMessagePurger.canLockBeAcquired();
channelMessagePurger.purgeMessages(context, toDelete);
context.reply("Processing purge command.. This can take some time.");
if (!lock) {
context.reply("Another purge is currently in progress on this channel, please wait before purging again");
}
}
return CommandExecutionResult.none();
}
}
@@ -10,7 +10,8 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AdminCommand; import me.bobert.netneutrality.core.command.impl.AdminCommand;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -22,18 +23,9 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import me.bobert.netneutrality.core.tasks.TaskManager;
public class ReactionCommand extends AdminCommand { public class ReactionCommand extends AdminCommand {
private final TaskManager taskManager;
public ReactionCommand(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -101,11 +93,11 @@ public class ReactionCommand extends AdminCommand {
private CommandExecutionResult executeEmoji(CommandContext context, String guildId, String messageId, String sub) { private CommandExecutionResult executeEmoji(CommandContext context, String guildId, String messageId, String sub) {
String id = String.format("%s:%s", guildId, messageId); String id = String.format("%s:%s", guildId, messageId);
EmojiReactionRoleTask task = (EmojiReactionRoleTask) taskManager.getTask(id); EmojiReactionRoleTask task = (EmojiReactionRoleTask) TaskManager.getInstance().getTask(id);
switch (sub) { switch (sub) {
case "list" -> { case "list" -> {
List<EmojiReactionRoleTask> tasks = taskManager.findTasksByPartialId(EmojiReactionRoleTask.class, guildId); List<EmojiReactionRoleTask> tasks = TaskManager.getInstance().findTasksByPartialId(EmojiReactionRoleTask.class, guildId);
if (tasks.isEmpty()) { if (tasks.isEmpty()) {
return CommandExecutionResult.info("No tasks found for this guild"); return CommandExecutionResult.info("No tasks found for this guild");
} }
@@ -8,17 +8,16 @@ package me.bobert.netneutrality.command.basic;
import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandContainer; import me.bobert.netneutrality.core.command.CommandContainer;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AbstractCommand; import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.trait.WithButton; import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.help.BotInfo; import me.bobert.netneutrality.tasks.help.BotInfo;
import me.bobert.netneutrality.tasks.help.CommandHelp; import me.bobert.netneutrality.tasks.help.CommandHelp;
import me.bobert.netneutrality.tasks.help.ReportBug; import me.bobert.netneutrality.tasks.help.ReportBug;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -27,18 +26,6 @@ import java.util.List;
public class HelpCommand extends AbstractCommand { public class HelpCommand extends AbstractCommand {
private final CommandRegistry commands;
private final TaskManager taskManager;
public HelpCommand() {
this(null, null);
}
public HelpCommand(CommandRegistry commands, TaskManager taskManager) {
this.commands = commands;
this.taskManager = taskManager;
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return false; return false;
@@ -78,15 +65,15 @@ public class HelpCommand extends AbstractCommand {
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String command = context.getOption("command", null, CommandOption::getAsString); String command = context.getOption("command", null, CommandOption::getAsString);
if (command == null) { if (command == null) {
Button commandsBtn = createTaskButton(CommandHelp.class); Button commands = Task.createButton(CommandHelp.class);
Button info = createTaskButton(BotInfo.class); Button info = Task.createButton(BotInfo.class);
Button reportbug = createTaskButton(ReportBug.class); Button reportbug = Task.createButton(ReportBug.class);
ActionRow row = ActionRow.of(commandsBtn, info, reportbug); ActionRow row = ActionRow.of(commands, info, reportbug);
context.replyEphemeral("Thanks for using the Net Neutrality Bot! What do you need help with?", List.of(row)); context.replyEphemeral("Thanks for using the Net Neutrality Bot! What do you need help with?", List.of(row));
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
CommandContainer container = commands.getCommandContainer(command); CommandContainer container = CommandManager.getCommandContainer(command);
if (container.getCommand() == null) { if (container.getCommand() == null) {
return CommandExecutionResult.warn("Command does not exist"); return CommandExecutionResult.warn("Command does not exist");
} }
@@ -94,12 +81,4 @@ public class HelpCommand extends AbstractCommand {
context.replyEphemeral(MessageFunction.buildCommandHelpMessage(container)); context.replyEphemeral(MessageFunction.buildCommandHelpMessage(container));
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
private Button createTaskButton(Class<? extends WithButton> clazz) {
WithButton withButton = taskManager.findButtonTaskByClass(clazz);
if (withButton == null) {
throw new IllegalArgumentException("Task " + clazz.getName() + " was not registered by the task manager.");
}
return withButton.createButton();
}
} }
@@ -12,6 +12,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicManager;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
@@ -21,24 +22,9 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import java.util.Map; import java.util.Map;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
public class ConfigureCommand extends AdminCommand { public class ConfigureCommand extends AdminCommand {
private final GuildServiceProvider guildServices;
private final MusicService musicService;
public ConfigureCommand() {
this(null, null);
}
public ConfigureCommand(GuildServiceProvider guildServices, MusicService musicService) {
this.guildServices = guildServices;
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -77,7 +63,7 @@ public class ConfigureCommand extends AdminCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand(); String sub = context.getSubcommand();
GuildService guildService = guildServices.get(context.getGuild().getId()); GuildService guildService = GuildService.get(context.getGuild().getId());
context.ephemeral(); context.ephemeral();
if ("music-role".equals(sub)) { if ("music-role".equals(sub)) {
@@ -119,7 +105,7 @@ public class ConfigureCommand extends AdminCommand {
if ("music-volume".equals(sub)) { if ("music-volume".equals(sub)) {
int volume = context.getOption("volume", 30, CommandOption::getAsInt); int volume = context.getOption("volume", 30, CommandOption::getAsInt);
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume)); guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume));
musicService.get(context.getGuild()).setVolume(volume); MusicManager.get(context.getGuild()).setVolume(volume);
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume)); return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
} }
@@ -1 +1 @@
**/*.java *.java
@@ -12,24 +12,11 @@ import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.http.WebModule;
import me.bobert.netneutrality.tasks.git.ReportIssueTask; import me.bobert.netneutrality.tasks.git.ReportIssueTask;
import me.bobert.netneutrality.utils.Settings;
import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.SettingsProvider;
public class ReportIssueCommand extends AbstractCommand { public class ReportIssueCommand extends AbstractCommand {
private final SettingsProvider settings;
private final TaskManager taskManager;
public ReportIssueCommand(SettingsProvider settings, TaskManager taskManager) {
Objects.requireNonNull(settings, "SettingsProvider must not be null");
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.settings = settings;
this.taskManager = taskManager;
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return false; return false;
@@ -72,16 +59,16 @@ public class ReportIssueCommand extends AbstractCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String gitlabHost = settings.get("GITLAB_HOST"); String gitlabHost = Settings.getInstance().get("GITLAB_HOST");
String gitlabToken = settings.get("GITLAB_TOKEN"); String gitlabToken = Settings.getInstance().get("GITLAB_TOKEN");
String projectId = settings.get("GITLAB_PROJECT_ID"); String projectId = Settings.getInstance().get("GITLAB_PROJECT_ID");
if (gitlabHost == null || gitlabToken == null || projectId == null) { if (gitlabHost == null || gitlabToken == null || projectId == null) {
return CommandExecutionResult.error("Command is not configured, failed to create issue."); return CommandExecutionResult.error("Command is not configured, failed to create issue.");
} }
if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) { if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) {
return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings)); return CommandExecutionResult.task(new ReportIssueTask(context));
} }
return CommandExecutionResult.error("Unable to create bug reports at this time, please try again later."); return CommandExecutionResult.error("Unable to create bug reports at this time, please try again later.");
@@ -10,28 +10,11 @@ import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
public class BitconnectCommand extends MusicCommand { public class BitconnectCommand extends MusicCommand {
private final MusicService musicService;
public BitconnectCommand() {
this(null, null);
}
public BitconnectCommand(MusicService musicService) {
this(musicService, null);
}
public BitconnectCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -59,7 +42,7 @@ public class BitconnectCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
context.ephemeral(); context.ephemeral();
context.deleteMessage(); context.deleteMessage();
@@ -9,29 +9,12 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ClearQueueCommand extends MusicCommand { public class ClearQueueCommand extends MusicCommand {
private final MusicService musicService;
public ClearQueueCommand() {
this(null, null);
}
public ClearQueueCommand(MusicService musicService) {
this(musicService, null);
}
public ClearQueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return true; return true;
@@ -59,10 +42,10 @@ public class ClearQueueCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
return manager.getQueueManager().clearQueue(); return manager.getQueueManager().clearQueue();
} }
@@ -9,30 +9,13 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class CurrentlyPlayingCommand extends MusicCommand { public class CurrentlyPlayingCommand extends MusicCommand {
private final MusicService musicService;
public CurrentlyPlayingCommand() {
this(null, null);
}
public CurrentlyPlayingCommand(MusicService musicService) {
this(musicService, null);
}
public CurrentlyPlayingCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -55,10 +38,10 @@ public class CurrentlyPlayingCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
if (!manager.isCurrentlyPlaying()) { if (!manager.isCurrentlyPlaying()) {
return CommandExecutionResult.info("Bot isn't playing any tracks right now"); return CommandExecutionResult.info("Bot isn't playing any tracks right now");
} }
@@ -9,29 +9,11 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class JoinCommand extends MusicCommand { public class JoinCommand extends MusicCommand {
private final MusicService musicService;
public JoinCommand() {
this(null, null);
}
public JoinCommand(MusicService musicService) {
this(musicService, null);
}
public JoinCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -54,9 +36,7 @@ public class JoinCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); if (MusicUtils.connect(context)) {
if (MusicUtils.connect(context, manager)) {
context.reply("Joining current voice channel"); context.reply("Joining current voice channel");
} }
@@ -10,35 +10,11 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
/**
* Leaves the voice channel for the current guild.
*
* <p>Representative command migrated to constructor injection of {@link MusicService} (and optional
* {@link GuildServiceProvider} for the {@link MusicCommand} base). The no-arg constructor remains as
* a fallback for non-DI loaders.
*/
public class LeaveCommand extends MusicCommand { public class LeaveCommand extends MusicCommand {
private final MusicService musicService;
public LeaveCommand() {
this(null, null);
}
public LeaveCommand(MusicService musicService) {
this(musicService, null);
}
public LeaveCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -61,9 +37,9 @@ public class LeaveCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
context.reply("Leaving voice channel and clearing queue"); context.reply("Leaving voice channel and clearing queue");
manager.disconnect(); manager.disconnect();
} }
@@ -9,8 +9,6 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
@@ -18,21 +16,6 @@ import net.dv8tion.jda.api.interactions.commands.OptionType;
public class PlayCommand extends MusicCommand { public class PlayCommand extends MusicCommand {
private final MusicService musicService;
public PlayCommand() {
this(null, null);
}
public PlayCommand(MusicService musicService) {
this(musicService, null);
}
public PlayCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -55,7 +38,7 @@ public class PlayCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
String query = context.getOption("query", CommandOption::getAsString); String query = context.getOption("query", CommandOption::getAsString);
context.ack(); context.ack();
@@ -8,12 +8,10 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -22,21 +20,6 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
public class QueueCommand extends MusicCommand { public class QueueCommand extends MusicCommand {
private final MusicService musicService;
public QueueCommand() {
this(null, null);
}
public QueueCommand(MusicService musicService) {
this(musicService, null);
}
public QueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -75,11 +58,11 @@ public class QueueCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
String sub = context.getSubcommand(); String sub = context.getSubcommand();
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
switch (sub) { switch (sub) {
case "clear-all" -> { case "clear-all" -> {
@@ -4,37 +4,14 @@ package me.bobert.netneutrality.command.music;/*
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ResumeCommand extends MusicCommand { public class ResumeCommand extends MusicCommand {
private final MusicService musicService;
public ResumeCommand() {
this(null, null);
}
public ResumeCommand(MusicService musicService) {
this(musicService, null);
}
public ResumeCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -57,9 +34,9 @@ public class ResumeCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
return manager.resume(); return manager.resume();
} }
@@ -9,28 +9,11 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ShuffleCommand extends MusicCommand { public class ShuffleCommand extends MusicCommand {
private final MusicService musicService;
public ShuffleCommand() {
this(null, null);
}
public ShuffleCommand(MusicService musicService) {
this(musicService, null);
}
public ShuffleCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -53,10 +36,10 @@ public class ShuffleCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
return manager.getQueueManager().shuffle(); return manager.getQueueManager().shuffle();
} }
@@ -9,33 +9,16 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class SkipCommand extends MusicCommand { public class SkipCommand extends MusicCommand {
private final MusicService musicService;
public SkipCommand() {
this(null, null);
}
public SkipCommand(MusicService musicService) {
this(musicService, null);
}
public SkipCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -59,12 +42,12 @@ public class SkipCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
int skips = context.getOption("skip", 1, CommandOption::getAsInt); int skips = context.getOption("skip", 1, CommandOption::getAsInt);
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) { if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) {
return manager.getQueueManager().skip(skips); return manager.getQueueManager().skip(skips);
} else { } else {
return manager.voteSkip(context); return manager.voteSkip(context);
@@ -9,30 +9,13 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
public class StopCommand extends MusicCommand { public class StopCommand extends MusicCommand {
private final MusicService musicService;
public StopCommand() {
this(null, null);
}
public StopCommand(MusicService musicService) {
this(musicService, null);
}
public StopCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -55,10 +38,10 @@ public class StopCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
if (UserFunction.isAdmin(context)) { if (UserFunction.isAdmin(context)) {
manager.stopTrack(); manager.stopTrack();
context.reply("Stopping current track"); context.reply("Stopping current track");
@@ -8,30 +8,13 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ViewQueueCommand extends MusicCommand { public class ViewQueueCommand extends MusicCommand {
private final MusicService musicService;
public ViewQueueCommand() {
this(null, null);
}
public ViewQueueCommand(MusicService musicService) {
this(musicService, null);
}
public ViewQueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -54,10 +37,10 @@ public class ViewQueueCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
int queueSize = manager.getQueueSize(); int queueSize = manager.getQueueSize();
if (queueSize == 0) { if (queueSize == 0) {
return CommandExecutionResult.warn("Queue is empty!"); return CommandExecutionResult.warn("Queue is empty!");
@@ -9,33 +9,16 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class VolumeCommand extends MusicCommand { public class VolumeCommand extends MusicCommand {
private final MusicService musicService;
public VolumeCommand() {
this(null, null);
}
public VolumeCommand(MusicService musicService) {
this(musicService, null);
}
public VolumeCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -59,11 +42,11 @@ public class VolumeCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
int volume = context.getOption("volume", 30, CommandOption::getAsInt); int volume = context.getOption("volume", 30, CommandOption::getAsInt);
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) { if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) {
manager.setVolume(volume); manager.setVolume(volume);
return CommandExecutionResult.success(String.format("Volume set to %s", volume)); return CommandExecutionResult.success(String.format("Volume set to %s", volume));
} }
@@ -8,29 +8,12 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class VoteSkipCommand extends MusicCommand { public class VoteSkipCommand extends MusicCommand {
private final MusicService musicService;
public VoteSkipCommand() {
this(null, null);
}
public VoteSkipCommand(MusicService musicService) {
this(musicService, null);
}
public VoteSkipCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -53,9 +36,9 @@ public class VoteSkipCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = MusicManager.get(context.getGuild());
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context)) {
return manager.voteSkip(context); return manager.voteSkip(context);
} }
@@ -9,32 +9,22 @@ package me.bobert.netneutrality.command.react;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand; import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiRoleFirstStep; import me.bobert.netneutrality.tasks.reaction.EmojiRoleFirstStep;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.Objects;
public class CreateEmojiReaction extends MessageMenuCommand { public class CreateEmojiReaction extends MessageMenuCommand {
private final TaskManager taskManager;
public CreateEmojiReaction(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
@Override @Override
public CommandExecutionResult execute(Message target, CommandContext context) { public CommandExecutionResult execute(Message target, CommandContext context) {
EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
if (task != null) { if (task != null) {
context.replyEphemeral("This message is already registered with the bot, please use the modify command instead"); context.replyEphemeral("This message is already registered with the bot, please use the modify command instead");
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
return CommandExecutionResult.task(new EmojiRoleFirstStep(taskManager, target)); return CommandExecutionResult.task(new EmojiRoleFirstStep(target));
} }
@Override @Override
@@ -9,32 +9,22 @@ package me.bobert.netneutrality.command.react;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand; import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiRoleModify; import me.bobert.netneutrality.tasks.reaction.EmojiRoleModify;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.Objects;
public class ModifyEmojiReaction extends MessageMenuCommand { public class ModifyEmojiReaction extends MessageMenuCommand {
private final TaskManager taskManager;
public ModifyEmojiReaction(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
@Override @Override
public CommandExecutionResult execute(Message target, CommandContext context) { public CommandExecutionResult execute(Message target, CommandContext context) {
EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
if (task == null) { if (task == null) {
context.replyEphemeral("This message is not registered with the bot, please use the create command instead"); context.replyEphemeral("This message is not registered with the bot, please use the create command instead");
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
return CommandExecutionResult.task(new EmojiRoleModify(taskManager, target, task)); return CommandExecutionResult.task(new EmojiRoleModify(target, task));
} }
@Override @Override
@@ -1,10 +1,11 @@
package me.bobert.netneutrality.core.context; package me.bobert.netneutrality.context;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.context.AbstractContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.context;
*/ */
import me.bobert.netneutrality.core.context.AbstractContext; import me.bobert.netneutrality.core.context.AbstractContext;
import me.bobert.netneutrality.core.context.AsyncContext;
import me.bobert.netneutrality.core.context.NoMessageContext; import me.bobert.netneutrality.core.context.NoMessageContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
@@ -38,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() {
@@ -9,7 +9,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.MessageCommandOption; import me.bobert.netneutrality.core.command.options.MessageCommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.MessageContext; import me.bobert.netneutrality.core.context.MessageContext;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.Settings;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Mentions; import net.dv8tion.jda.api.entities.Mentions;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
@@ -30,11 +30,8 @@ public class MessageCommandContext extends CommandContext implements MessageCont
private final MessageReceivedEvent event; private final MessageReceivedEvent event;
private final Message message; private final Message message;
private final SettingsProvider settings; public MessageCommandContext(MessageReceivedEvent event) {
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor());
public MessageCommandContext(MessageReceivedEvent event, SettingsProvider settings) {
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor(), settings);
this.settings = settings;
this.event = event; this.event = event;
this.message = event.getMessage(); this.message = event.getMessage();
} }
@@ -323,7 +320,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override @Override
public String parseCommandName() { public String parseCommandName() {
String rawMessage = message.getContentRaw().trim(); String rawMessage = message.getContentRaw().trim();
String prefix = settings.getPrefix(); String prefix = Settings.getInstance().getPrefix();
if (rawMessage.startsWith(prefix)) { if (rawMessage.startsWith(prefix)) {
return getFullCommand().split(" ", 2)[0]; return getFullCommand().split(" ", 2)[0];
@@ -335,7 +332,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override @Override
public String getFullCommand() { public String getFullCommand() {
String rawMessage = message.getContentRaw().trim(); String rawMessage = message.getContentRaw().trim();
String prefix = settings.getPrefix(); String prefix = Settings.getInstance().getPrefix();
return rawMessage.replaceFirst("^[" + prefix + "\\s]+", "").trim(); return rawMessage.replaceFirst("^[" + prefix + "\\s]+", "").trim();
} }
@@ -9,7 +9,6 @@ package me.bobert.netneutrality.context.command;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext; import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.Event; import net.dv8tion.jda.api.events.Event;
@@ -21,13 +20,8 @@ import java.util.List;
public class MessageMenuContext extends CommandContext implements InteractionContext { public class MessageMenuContext extends CommandContext implements InteractionContext {
private final MessageContextInteractionEvent event; private final MessageContextInteractionEvent event;
@Override public MessageMenuContext(MessageContextInteractionEvent event) {
public boolean isInteraction() { super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
return true;
}
public MessageMenuContext(MessageContextInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event; this.event = event;
} }
@@ -9,7 +9,6 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.SlashCommandOption; import me.bobert.netneutrality.core.command.options.SlashCommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext; import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
@@ -20,21 +19,11 @@ import java.util.stream.Collectors;
public class SlashCommandContext extends CommandContext implements InteractionContext { public class SlashCommandContext extends CommandContext implements InteractionContext {
private final SlashCommandInteractionEvent event; private final SlashCommandInteractionEvent event;
public SlashCommandContext(SlashCommandInteractionEvent event, SettingsProvider settings) { public SlashCommandContext(SlashCommandInteractionEvent event) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings); super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
this.event = event; this.event = event;
} }
@Override
public boolean isInteraction() {
return true;
}
@Override
public boolean canSkipPermissionCheck() {
return true;
}
@Override @Override
public SlashCommandInteractionEvent getEvent() { public SlashCommandInteractionEvent getEvent() {
return event; return event;
@@ -9,7 +9,6 @@ package me.bobert.netneutrality.context.command;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext; import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -22,13 +21,8 @@ import java.util.List;
public class UserMenuContext extends CommandContext implements InteractionContext { public class UserMenuContext extends CommandContext implements InteractionContext {
private final UserContextInteractionEvent event; private final UserContextInteractionEvent event;
@Override public UserMenuContext(UserContextInteractionEvent event) {
public boolean isInteraction() { super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser());
return true;
}
public UserMenuContext(UserContextInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event; this.event = event;
} }
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.context.tasks;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.context.AsyncContext; import me.bobert.netneutrality.context.AsyncContext;
import me.bobert.netneutrality.core.context.NoMessageContext; import me.bobert.netneutrality.core.context.NoMessageContext;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
@@ -1,13 +1,11 @@
package me.bobert.netneutrality.bootstrap; package me.bobert.netneutrality.core;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.TrackScheduler; import me.bobert.netneutrality.music.TrackScheduler;
import org.json.JSONArray; import org.json.JSONArray;
@@ -21,32 +19,14 @@ import java.util.Map;
public class BotState { public class BotState {
public static boolean SAFE = false; public static boolean SAFE = false;
private static volatile GuildServiceProvider guildServices;
private static volatile MusicService musicService;
/**
* Binds providers used by the optional state dump. Called once during bootstrap.
*
* @param guildServices the guild service provider
* @param musicService the music service
*/
public static void bind(GuildServiceProvider guildServices, MusicService musicService) {
BotState.guildServices = guildServices;
BotState.musicService = musicService;
}
//TODO: is it needed? //TODO: is it needed?
private static void saveGuildsState() { private static void saveGuildsState() {
if (guildServices == null || musicService == null) { Map<String, GuildService> services = GuildService.getServices();
return;
}
Map<String, GuildService> services = guildServices.getServices();
JSONObject main = new JSONObject(); JSONObject main = new JSONObject();
JSONArray guildsArray = new JSONArray(); JSONArray guildsArray = new JSONArray();
for (MusicManager manager : musicService.getManagers()) { for (MusicManager manager : MusicManager.getManagers()) {
TrackScheduler trackScheduler = manager.getScheduler(); TrackScheduler trackScheduler = manager.getScheduler();
} }
@@ -9,9 +9,9 @@ import io.sentry.Sentry;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -20,17 +20,6 @@ import org.apache.logging.log4j.Logger;
public class LogHandler { public class LogHandler {
private static final Logger logger = LogManager.getLogger(LogHandler.class); private static final Logger logger = LogManager.getLogger(LogHandler.class);
private static volatile GuildServiceProvider guildServices;
/**
* Binds the guild-service provider used for debug-channel logging. Called once during bootstrap.
*
* @param provider the live guild service provider
*/
public static void bind(GuildServiceProvider provider) {
guildServices = provider;
}
/** /**
* Log an exception accordingly * Log an exception accordingly
@@ -91,8 +80,7 @@ public class LogHandler {
Context context = exception.getContext(); Context context = exception.getContext();
if (context.getGuild() != null) { if (context.getGuild() != null) {
GuildServiceProvider provider = guildServices; if (GuildService.get(context.getGuild()).isDebugEnabled()) {
if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) {
context.getGuild() context.getGuild()
.getTextChannels() .getTextChannels()
.stream() .stream()
@@ -1,20 +0,0 @@
package me.bobert.netneutrality.core;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.Lifecycle;
public class NetNeutralityBot implements Lifecycle {
@Override
public void start() throws Exception {
}
@Override
public int priority() {
return 0;
}
}
@@ -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);
}
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.bootstrap.ApplicationContext; import me.bobert.netneutrality.utils.ReflectionHelper;
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.InteractionContextType;
import net.dv8tion.jda.api.interactions.commands.build.CommandData; import net.dv8tion.jda.api.interactions.commands.build.CommandData;
@@ -18,43 +17,15 @@ import org.apache.logging.log4j.Logger;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/** public class CommandManager {
* Instance-based {@link CommandRegistry} owning the discovered command map.
*/
public class CommandManager implements CommandRegistry {
private static final Logger logger = LogManager.getLogger(CommandManager.class); private static final Logger logger = LogManager.getLogger(CommandManager.class);
public static final Map<String, CommandContainer> commands = new ConcurrentHashMap<>();
private final Map<String, CommandContainer> commands = new ConcurrentHashMap<>(); public static void init() {
private final ApplicationContext context;
/**
* Creates a command manager that can inject context-registered dependencies into reflectively
* loaded commands.
*
* @param context the application context; must not be {@code null}
*/
public CommandManager(ApplicationContext context) {
Objects.requireNonNull(context, "ApplicationContext must not be null");
this.context = context;
}
@Override
public int priority() {
return 30;
}
@Override
public void start() {
init();
}
public void init() {
logger.info("Loading commands.."); logger.info("Loading commands..");
initCommands(); initCommands();
@@ -62,30 +33,12 @@ public class CommandManager implements CommandRegistry {
logger.info("Loaded {} commands!", commands.size()); logger.info("Loaded {} commands!", commands.size());
} }
@Override public static CommandContainer getCommandContainer(String commandString) {
public CommandContainer getCommandContainer(String name) { return commands.getOrDefault(commandString, CommandContainer.NULL_COMMAND);
return commands.getOrDefault(name, CommandContainer.NULL_COMMAND);
} }
@Override public static void initCommands() {
public boolean hasCommand(String name) { ReflectionHelper.loadCommands(Command.class, command -> {
return commands.containsKey(name);
}
/**
* Exposes the live command map for call sites (and tests) that still iterate or mutate it
* directly. Prefer {@link #hasCommand(String)} / {@link #getCommandContainer(String)} where
* possible.
*
* @return the registered commands keyed by name
*/
@Override
public Map<String, CommandContainer> getCommands() {
return commands;
}
public void initCommands() {
Consumer<Command> onLoaded = command -> {
if (!command.isEnabled()) { if (!command.isEnabled()) {
logger.info("Skipping command {} as it is disabled", command.getClass().getName()); logger.info("Skipping command {} as it is disabled", command.getClass().getName());
return; return;
@@ -105,13 +58,10 @@ public class CommandManager implements CommandRegistry {
if (!commands.containsKey(command.getCommandName())) { if (!commands.containsKey(command.getCommandName())) {
commands.put(command.getCommandName(), container); commands.put(command.getCommandName(), container);
} }
}; });
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
} }
@Override public static void registerSlashCommands(JDA jda) {
public void registerSlashCommands(JDA jda) {
CommandListUpdateAction commandListUpdateAction = jda.updateCommands(); CommandListUpdateAction commandListUpdateAction = jda.updateCommands();
List<CommandData> slashCommands = new ArrayList<>(); List<CommandData> slashCommands = new ArrayList<>();
@@ -6,74 +6,61 @@ package me.bobert.netneutrality.core.command;
*/ */
import io.sentry.Sentry; import io.sentry.Sentry;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.precondition.CommandPrecondition;
import me.bobert.netneutrality.core.command.precondition.GuildOnlyPrecondition;
import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition;
import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.exceptions.CommandExecutionException; import me.bobert.netneutrality.exceptions.CommandExecutionException;
import me.bobert.netneutrality.exceptions.GracefulContextError; import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.utils.UserFunction;
import me.bobert.netneutrality.shared.util.UserFunction;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import static java.util.function.Predicate.not;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.ERROR;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.NOACTION;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.TASK;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.WARN;
public class CommandProcessor { public class CommandProcessor {
private static final Logger logger = LogManager.getLogger(CommandProcessor.class); private static final Logger logger = LogManager.getLogger(CommandProcessor.class);
private final CommandContext context; private final CommandContext context;
private final CommandRegistry commands;
private final TaskManager taskManager;
private final List<CommandPrecondition> preconditions;
private final UsageValidator usageValidator;
private final CommandResultHandler resultHandler;
public CommandProcessor(CommandContext context, CommandRegistry commands, TaskManager taskManager) { public CommandProcessor(CommandContext context) {
this(context, commands, taskManager, defaultPreconditions(), new UsageValidator(), new CommandResultHandler());
}
public CommandProcessor(CommandContext context, CommandRegistry commands, TaskManager taskManager,
List<CommandPrecondition> preconditions, UsageValidator usageValidator,
CommandResultHandler resultHandler) {
this.context = context; this.context = context;
this.commands = Objects.requireNonNull(commands, "commands");
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
this.preconditions = Objects.requireNonNull(preconditions, "preconditions");
this.usageValidator = Objects.requireNonNull(usageValidator, "usageValidator");
this.resultHandler = Objects.requireNonNull(resultHandler, "resultHandler");
}
private static List<CommandPrecondition> defaultPreconditions() {
return List.of(
new GuildOnlyPrecondition(),
new SlashCommandOnlyPrecondition(),
new PermissionPrecondition()
);
} }
public void dispatch() { public void dispatch() {
if (!commands.hasCommand(context.parseCommandName())) { if (!CommandManager.commands.containsKey(context.parseCommandName())) {
return; return;
} }
CommandContainer container = commands.getCommandContainer(context.parseCommandName()); CommandContainer container = CommandManager.getCommandContainer(context.parseCommandName());
Command command = container.getCommand(); Command command = container.getCommand();
if (command == null) { if (command == null) {
throw new IllegalArgumentException("Context has no command"); throw new IllegalArgumentException("Context has no command");
} }
context.setCommand(command); context.setCommand(command);
if (command.isGuildOnly() && !context.isFromGuild()) {
context.replyEphemeral("This command is only available in a guild");
context.deleteMessage();
return;
}
for (CommandPrecondition precondition : preconditions) { if (command.isSlashCommandOnly() && !(context instanceof InteractionContext)) {
if (!precondition.check(context, command)) { context.replyEphemeral("This command is only available as a slash command");
return; return;
}
} }
try { try {
@@ -81,7 +68,12 @@ public class CommandProcessor {
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId()); logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
configureSentry(command); configureSentry(command);
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) { if (command.getType() == Command.Type.SLASH && !canExecute(command)) {
context.reply("You are not powerful enough, scrub");
return;
}
if (command.getType() == Command.Type.SLASH && !checkUsage(container)) {
return; return;
} }
@@ -96,7 +88,31 @@ public class CommandProcessor {
} }
CommandExecutionResult commandExecutionResult = command.execute(context); CommandExecutionResult commandExecutionResult = command.execute(context);
resultHandler.handle(context, commandExecutionResult, taskManager); if (commandExecutionResult == null) {
throw new RuntimeException("CommandExecutionResult is null");
}
CommandExecutionResult.Result result = commandExecutionResult.getResult();
if (result == NOACTION) {
return;
}
if (result == TASK) {
TaskManager.getInstance().insert(context, commandExecutionResult.getTask());
}
String message = commandExecutionResult.getMessage();
if (result == ERROR) {
logger.error("Context[{}]: Error from command: {}", context.getId(), message, commandExecutionResult.getException());
}
if (result == WARN) {
logger.warn("Context[{}]: Warning from command: {}", context.getId(), message);
}
if (message != null) {
context.reply(message);
}
} catch (GracefulContextError exception) { } catch (GracefulContextError exception) {
logger.error(exception.getMessage()); logger.error(exception.getMessage());
context.replyEphemeral(exception.getMessage()); context.replyEphemeral(exception.getMessage());
@@ -107,6 +123,111 @@ public class CommandProcessor {
} }
} }
private boolean checkUsage(CommandContainer container) {
List<SubcommandGroupData> groups = container.getSubcommandGroups();
List<SubcommandData> subcommands = container.getSubcommands();
List<OptionData> options = container.getOptions();
String[] possibleCommands = Arrays.stream(context.getFullCommand().split(" ")).skip(1).toArray(String[]::new);
if (possibleCommands.length == 0) {
if (!groups.isEmpty()) {
// missing subcommand group
context.reply(invalidUsage("missing subcommand group"));
return false;
}
if (!subcommands.isEmpty()) {
// missing subcommand
context.reply(invalidUsage("missing subcommand"));
return false;
}
return checkOptions(possibleCommands, 0, options);
}
SubcommandGroupData group = groups.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (group == null) {
SubcommandData subcommand = subcommands.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (subcommand == null) {
if (!groups.isEmpty()) {
// no group found but one is expected
context.reply(invalidUsage("unknown subcommand group"));
return false;
}
if (!subcommands.isEmpty()) {
// no subcommand found but one is expected
context.reply(invalidUsage("unknown subcommand"));
return false;
}
// no group found, no subcommand found, command with options only
return checkOptions(possibleCommands, 0, options);
}
// no group found, subcommand found
context.setSubcommand(subcommand);
return checkOptions(possibleCommands, 1, subcommand.getOptions());
}
if (possibleCommands.length < 2) {
// missing subcommand for group
context.reply(invalidUsage("missing subcommand for command group"));
return false;
}
SubcommandData subcommand = group.getSubcommands().stream().filter(data -> data.getName().equals(possibleCommands[1])).findFirst().orElse(null);
if (subcommand == null) {
// group found, no subcommand found
context.reply(invalidUsage("unknown subcommand in command group"));
return false;
}
// group found, subcommand found
context.setSubcommandGroup(group);
context.setSubcommand(subcommand);
return checkOptions(possibleCommands, 2, subcommand.getOptions());
}
private boolean checkOptions(String[] args, int skip, List<OptionData> options) {
if (context instanceof InteractionContext) {
context.setCurrentCommandOptions(args, options);
return true; // slash commands are validated by discord
}
List<String> arguments = new ArrayList<>(Arrays.stream(args).skip(skip).toList());
int required = options.stream().filter(OptionData::isRequired).toList().size();
int optional = options.stream().filter(not(OptionData::isRequired)).toList().size();
if (arguments.size() < required) {
context.reply(invalidUsage("missing one or more required arguments"));
return false;
}
context.setCurrentCommandOptions(arguments.toArray(String[]::new), options);
return true;
}
private boolean canExecute(Command command) {
if (command.canBeExecutedFrom(context.getChannel())) {
return true;
}
if (context instanceof SlashCommandContext) {
return true; // no further checks since slash perms are handled by guild settings on discord
}
if (command.canBeExecutedBy(context.getGuildUser())) {
return true;
}
return command.needAdmin() && UserFunction.isAdmin(context.getGuildUser());
}
private String invalidUsage(String message) {
return String.format("Invalid usage for command: %s\n" +
"Use **/help %s** for information on how to use this command", message, context.getCommand().getCommandName());
}
private void configureSentry(Command command) { private void configureSentry(Command command) {
Sentry.configureScope(scope -> { Sentry.configureScope(scope -> {
scope.setTag("command", command.getCommandName()); scope.setTag("command", command.getCommandName());
@@ -1,51 +0,0 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.Lifecycle;
import net.dv8tion.jda.api.JDA;
import java.util.Map;
/**
* Owns the bot command registry and slash-command registration against Discord.
*
* <p>As a {@link Lifecycle} component it discovers and loads commands on {@link #start()}.
*/
public interface CommandRegistry extends Lifecycle {
/**
* Returns the container for the named command, or a null-object container if unknown.
*
* @param name the command name
* @return the command container
*/
CommandContainer getCommandContainer(String name);
/**
* Whether a command with the given name is registered.
*
* @param name the command name
* @return {@code true} if registered
*/
boolean hasCommand(String name);
/**
* Pushes every enabled slash command up to Discord via the given JDA instance.
*
* @param jda the connected JDA client
*/
void registerSlashCommands(JDA jda);
/**
* Returns the live command map keyed by command name.
*
* @return the registered commands
*/
Map<String, CommandContainer> getCommands();
}
@@ -1,49 +0,0 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.ERROR;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.NOACTION;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.TASK;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.WARN;
public class CommandResultHandler {
private static final Logger logger = LogManager.getLogger(CommandResultHandler.class);
public void handle(CommandContext context, CommandExecutionResult commandExecutionResult, TaskManager taskManager) {
if (commandExecutionResult == null) {
throw new RuntimeException("CommandExecutionResult is null");
}
CommandExecutionResult.Result result = commandExecutionResult.getResult();
if (result == NOACTION) {
return;
}
if (result == TASK) {
taskManager.insert(context, commandExecutionResult.getTask());
}
String message = commandExecutionResult.getMessage();
if (result == ERROR) {
logger.error("Context[{}]: Error from command: {}", context.getId(), message, commandExecutionResult.getException());
}
if (result == WARN) {
logger.warn("Context[{}]: Warning from command: {}", context.getId(), message);
}
if (message != null) {
context.reply(message);
}
}
}
@@ -1,97 +0,0 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class UsageValidator {
public boolean validate(CommandContext context, CommandContainer container) {
List<SubcommandGroupData> groups = container.getSubcommandGroups();
List<SubcommandData> subcommands = container.getSubcommands();
List<OptionData> options = container.getOptions();
String[] possibleCommands = Arrays.stream(context.getFullCommand().split(" ")).skip(1).toArray(String[]::new);
if (possibleCommands.length == 0) {
if (!groups.isEmpty()) {
context.reply(invalidUsage("missing subcommand group", context));
return false;
}
if (!subcommands.isEmpty()) {
context.reply(invalidUsage("missing subcommand", context));
return false;
}
return checkOptions(context, possibleCommands, 0, options);
}
SubcommandGroupData group = groups.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (group == null) {
SubcommandData subcommand = subcommands.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (subcommand == null) {
if (!groups.isEmpty()) {
context.reply(invalidUsage("unknown subcommand group", context));
return false;
}
if (!subcommands.isEmpty()) {
context.reply(invalidUsage("unknown subcommand", context));
return false;
}
return checkOptions(context, possibleCommands, 0, options);
}
context.setSubcommand(subcommand);
return checkOptions(context, possibleCommands, 1, subcommand.getOptions());
}
if (possibleCommands.length < 2) {
context.reply(invalidUsage("missing subcommand for command group", context));
return false;
}
SubcommandData subcommand = group.getSubcommands().stream().filter(data -> data.getName().equals(possibleCommands[1])).findFirst().orElse(null);
if (subcommand == null) {
context.reply(invalidUsage("unknown subcommand in command group", context));
return false;
}
context.setSubcommandGroup(group);
context.setSubcommand(subcommand);
return checkOptions(context, possibleCommands, 2, subcommand.getOptions());
}
private boolean checkOptions(CommandContext context, String[] args, int skip, List<OptionData> options) {
if (context.isInteraction()) {
context.setCurrentCommandOptions(args, options);
return true;
}
List<String> arguments = new ArrayList<>(Arrays.stream(args).skip(skip).toList());
int required = options.stream().filter(OptionData::isRequired).toList().size();
if (arguments.size() < required) {
context.reply(invalidUsage("missing one or more required arguments", context));
return false;
}
context.setCurrentCommandOptions(arguments.toArray(String[]::new), options);
return true;
}
private String invalidUsage(String message, CommandContext context) {
return String.format("Invalid usage for command: %s\n" +
"Use **/help %s** for information on how to use this command", message, context.getCommand().getCommandName());
}
}
@@ -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
* *
@@ -6,10 +10,9 @@ package me.bobert.netneutrality.core.command.impl;
*/ */
import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
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;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
@@ -17,45 +20,8 @@ import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import java.util.Objects;
public abstract class MusicCommand extends AbstractCommand { public abstract class MusicCommand extends AbstractCommand {
/**
* Injected guild-service provider when constructed via DI.
*/
private final GuildServiceProvider guildServices;
/**
* Preferred constructor for DI.
*
* @param guildServices the shared guild service provider, must not be {@code null}
*/
public MusicCommand(GuildServiceProvider guildServices) {
Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null");
this.guildServices = guildServices;
}
/**
* Resolves the {@link GuildService} for a guild via the injected provider.
*
* @param guild the guild
* @return the guild service
*/
protected final GuildService guildService(Guild guild) {
return guildServices.get(guild);
}
/**
* Resolves the {@link GuildService} for a guild id via the injected provider.
*
* @param guildId the guild id
* @return the guild service
*/
protected final GuildService guildService(String guildId) {
return guildServices.get(guildId);
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return false; return false;
@@ -79,7 +45,7 @@ public abstract class MusicCommand extends AbstractCommand {
Guild guild = member.getGuild(); Guild guild = member.getGuild();
String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE); String roleDb = GuildService.get(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
if (roleDb != null && !roleDb.isEmpty()) { if (roleDb != null && !roleDb.isEmpty()) {
if (UserFunction.isAdmin(member)) { if (UserFunction.isAdmin(member)) {
@@ -89,7 +55,7 @@ public abstract class MusicCommand extends AbstractCommand {
Role neededRole = guild.getRoleById(roleDb); Role neededRole = guild.getRoleById(roleDb);
if (neededRole == null) { if (neededRole == null) {
// Role doesn't exist on guild anymore, remove from db // Role doesn't exist on guild anymore, remove from db
guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null); GuildService.get(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
return true; return true;
} }
@@ -106,7 +72,7 @@ public abstract class MusicCommand extends AbstractCommand {
return false; return false;
} }
String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL); String channelDb = GuildService.get(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL);
if (channelDb != null && !channelDb.isEmpty()) { if (channelDb != null && !channelDb.isEmpty()) {
return channelDb.equals(channel.getId()); return channelDb.equals(channel.getId());
} }
@@ -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();
}
}
@@ -1,17 +0,0 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
public interface CommandPrecondition {
/**
* Returns true if the precondition passes and execution should continue,
* or false if it failed (the implementation is responsible for sending any reply).
*/
boolean check(CommandContext context, Command command);
}
@@ -1,23 +0,0 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
public class GuildOnlyPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.isGuildOnly() && !context.isFromGuild()) {
context.replyEphemeral("This command is only available in a guild");
context.deleteMessage();
return false;
}
return true;
}
}
@@ -1,39 +0,0 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.shared.util.UserFunction;
public class PermissionPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.getType() != Command.Type.SLASH) {
return true;
}
if (context.canSkipPermissionCheck()) {
return true;
}
if (command.canBeExecutedFrom(context.getChannel())) {
return true;
}
if (command.canBeExecutedBy(context.getGuildUser())) {
return true;
}
if (command.needAdmin() && UserFunction.isAdmin(context.getGuildUser())) {
return true;
}
context.reply("You are not powerful enough, scrub");
return false;
}
}
@@ -1,22 +0,0 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
public class SlashCommandOnlyPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.isSlashCommandOnly() && !context.isInteraction()) {
context.replyEphemeral("This command is only available as a slash command");
return false;
}
return true;
}
}
@@ -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;
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.core.context;
import me.bobert.netneutrality.core.command.Command; import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.Settings;
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;
@@ -33,12 +33,10 @@ public abstract class CommandContext extends AbstractContext {
private SubcommandData subcommand; private SubcommandData subcommand;
private final Map<OptionData, String> invalidOptions = new HashMap<>(); private final Map<OptionData, String> invalidOptions = new HashMap<>();
private final List<CommandOption> options; private final List<CommandOption> options;
private final SettingsProvider settings;
public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user, SettingsProvider settings) { public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user) {
super(jda, guild, channel, member, user); super(jda, guild, channel, member, user);
this.options = new ArrayList<>(); this.options = new ArrayList<>();
this.settings = settings;
} }
public Command getCommand() { public Command getCommand() {
@@ -100,7 +98,7 @@ public abstract class CommandContext extends AbstractContext {
public CommandOption getOption(String name) { public CommandOption getOption(String name) {
List<CommandOption> options = getOptionsByName(name); List<CommandOption> options = getOptionsByName(name);
return options.isEmpty() ? null : options.getFirst(); return options.isEmpty() ? null : options.get(0);
} }
public List<CommandOption> getOptionsByName(String name) { public List<CommandOption> getOptionsByName(String name) {
@@ -133,7 +131,7 @@ public abstract class CommandContext extends AbstractContext {
} }
public boolean isMessageFromCreator() { public boolean isMessageFromCreator() {
String creatorId = settings.get("NETNEUTRALITY_BOT_OWNER"); String creatorId = Settings.getInstance().get("NETNEUTRALITY_BOT_OWNER");
return creatorId.equals(getDiscordUser().getId()); return creatorId.equals(getDiscordUser().getId());
} }
@@ -143,12 +141,4 @@ public abstract class CommandContext extends AbstractContext {
public abstract String parseCommandName(); public abstract String parseCommandName();
public abstract String getFullCommand(); public abstract String getFullCommand();
public boolean isInteraction() {
return false;
}
public boolean canSkipPermissionCheck() {
return false;
}
} }
@@ -5,7 +5,8 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.shared.util.ContextUtils; import me.bobert.netneutrality.context.AsyncContext;
import me.bobert.netneutrality.utils.ContextUtils;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
@@ -5,6 +5,7 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.interactions.Interaction; import net.dv8tion.jda.api.interactions.Interaction;
import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.InteractionHook;
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.modals.Modal; import net.dv8tion.jda.api.modals.Modal;
@@ -1,11 +1,11 @@
package me.bobert.netneutrality.db; package me.bobert.netneutrality.core.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.Settings;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
@@ -14,12 +14,12 @@ import org.flywaydb.core.api.output.MigrateResult;
public class DatabaseMigration { public class DatabaseMigration {
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class); private final static Logger logger = LogManager.getLogger(DatabaseMigration.class);
public static void migrate(SettingsProvider settings) { public static void migrate() {
String dbName = settings.get("MYSQL_DATABASE", "netneutrality"); String dbName = Settings.getInstance().get("MYSQL_DATABASE", "netneutrality");
String dbUser = settings.get("MYSQL_USER", "root"); String dbUser = Settings.getInstance().get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", ""); String dbPass = Settings.getInstance().get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost"); String dbHost = Settings.getInstance().get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306"); String dbPort = Settings.getInstance().get("MYSQL_PORT", "3306");
String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName); String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName);
try { try {
@@ -0,0 +1,69 @@
package me.bobert.netneutrality.core.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.sentry.Sentry;
import me.bobert.netneutrality.utils.Settings;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.Environment;
import java.util.HashMap;
import java.util.Map;
public class SessionManager {
private static final Logger logger = LogManager.getLogger(SessionManager.class);
private static StandardServiceRegistry registry;
private static SessionFactory sessionFactory;
public static SessionFactory getSessionFactory() {
if (sessionFactory == null) {
try {
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure();
Map<String, Object> settings = new HashMap<>();
String dbName = Settings.getInstance().get("MYSQL_DATABASE", "netneutrality");
String dbUser = Settings.getInstance().get("MYSQL_USER", "root");
String dbPass = Settings.getInstance().get("MYSQL_PASSWORD", "");
String dbHost = Settings.getInstance().get("MYSQL_HOST", "localhost");
String dbPort = Settings.getInstance().get("MYSQL_PORT", "3306");
logger.info("Connecting to database '{}' at {}:{} with user '{}'", dbName, dbHost, dbPort, dbUser);
settings.put(Environment.JAKARTA_JDBC_URL, String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
settings.put(Environment.JAKARTA_JDBC_USER, dbUser);
settings.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
registryBuilder.applySettings(settings);
registry = registryBuilder.build();
MetadataSources sources = new MetadataSources(registry);
Metadata metadata = sources.getMetadataBuilder().build();
sessionFactory = metadata.getSessionFactoryBuilder().build();
} catch (Exception e) {
String env = Settings.getInstance().get("NETNEUTRALITY_BOT_ENV", "dev");
if (!"dev".equals(env)) {
Sentry.captureException(e);
}
shutdown();
logger.error("Error connecting to database. Shutting down..");
System.exit(1);
}
}
return sessionFactory;
}
public static void shutdown() {
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -1,112 +0,0 @@
package me.bobert.netneutrality.core.scheduled;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.thread.ExecutorProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.function.Consumer;
/**
* Instance-based {@link Lifecycle} component that owns scheduled fixed-delay tasks.
*
* <p>On {@link #start()} it discovers all concrete {@link ScheduledTask} implementations via
* reflection and schedules them automatically.
*/
public class ScheduledTaskManager implements Lifecycle {
private static final Logger logger = LogManager.getLogger(ScheduledTaskManager.class);
private final Map<String, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
private final ScheduledExecutorService executorService;
private final ApplicationContext context;
public ScheduledTaskManager(ExecutorProvider executors, ApplicationContext context) {
this.executorService = executors.getScheduledExecutor();
this.context = context;
}
@Override
public int priority() {
// High priority number => stopped early on shutdown, before executors (priority 20).
return 55;
}
@Override
public void start() {
loadScheduledTasks();
}
@Override
public void stop() {
cancelAll();
}
public void schedule(ScheduledTask task) {
String taskId = task.getId();
if (scheduledTasks.containsKey(taskId)) {
logger.warn("Task {} is already scheduled.", taskId);
return;
}
ScheduledTask.TimeFrame timeFrame = task.getTimeFrame();
Runnable runnable = () -> {
logger.info("Executing scheduled task: {}", taskId);
try {
task.run();
} catch (Exception e) {
logger.error("An exception was thrown while executing scheduled task {}", taskId);
LogHandler.log(e);
}
};
ScheduledFuture<?> future = executorService.scheduleWithFixedDelay(
runnable,
1,
timeFrame.getInterval(),
timeFrame.getUnit()
);
scheduledTasks.put(taskId, future);
logger.info("Successfully scheduled task: {} with interval {} {}",
taskId, timeFrame.getInterval(), timeFrame.getUnit());
}
public void cancelAll() {
logger.info("Cancelling all {} scheduled tasks", scheduledTasks.size());
scheduledTasks.forEach((_, future) -> future.cancel(false));
scheduledTasks.clear();
}
private void loadScheduledTasks() {
logger.info("Loading scheduled tasks...");
Consumer<ScheduledTask> onLoaded = scheduledTask -> {
String taskId = scheduledTask.getId();
if (taskId == null || taskId.trim().isEmpty()) {
logger.error("Cannot load scheduled task {}: task ID is null or empty", scheduledTask.getClass().getName());
return;
}
schedule(scheduledTask);
};
ReflectionHelper.loadTasks(ScheduledTask.class, context, onLoaded);
}
}
@@ -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);
}
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.core.scheduled; package me.bobert.netneutrality.core.tasks;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
@@ -79,4 +79,14 @@ public interface Task {
Lifecycle execute(TaskContext context); Lifecycle execute(TaskContext context);
void insert(CommandContext context); void insert(CommandContext context);
static Button createButton(Class<? extends WithButton> clazz) {
WithButton withButton = TaskManager.getInstance().findButtonTaskByClass(clazz);
if (withButton == null) {
throw new IllegalArgumentException("Task " + clazz.getName() + " was not registered by the task manager.");
}
return withButton.createButton();
}
} }
@@ -1,120 +1,321 @@
package me.bobert.netneutrality.core.tasks; package me.bobert.netneutrality.core.tasks;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.trait.Deletable;
import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.core.thread.ScheduledTaskManager;
import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException;
import me.bobert.netneutrality.utils.ReflectionHelper;
import net.dv8tion.jda.api.interactions.InteractionHook;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import me.bobert.netneutrality.bootstrap.Lifecycle; import java.util.concurrent.ConcurrentHashMap;
import me.bobert.netneutrality.core.context.CommandContext; import java.util.concurrent.CopyOnWriteArrayList;
import me.bobert.netneutrality.core.context.TaskContext; import java.util.concurrent.locks.ReentrantLock;
import me.bobert.netneutrality.core.tasks.trait.WithButton; import java.util.stream.Collectors;
import me.bobert.netneutrality.db.service.TaskService;
/** public class TaskManager {
* Owns the in-memory task registry, button/interaction dispatch, and persistable-task loading. private static final Logger logger = LogManager.getLogger(TaskManager.class);
* private static TaskManager instance;
* <p>As a {@link Lifecycle} component it loads built-in and persisted tasks on private final TaskService taskService;
* {@link #start()}. private final Map<String, Task> tasks;
*/ private final List<Task> pendingTasks;
public interface TaskManager extends Lifecycle { private final Map<String, CommandContext> deletableTasksContext;
private final Map<String, ReentrantLock> tasksLock;
/** private TaskManager() {
* Inserts a newly created task into the registry (and persists it when applicable). this.tasks = new ConcurrentHashMap<>();
* this.pendingTasks = new CopyOnWriteArrayList<>();
* @param ctx the command context that produced the task this.deletableTasksContext = new ConcurrentHashMap<>();
* @param task the task to insert this.tasksLock = new ConcurrentHashMap<>();
*/ this.taskService = new TaskService();
void insert(CommandContext ctx, Task task); }
/** public static TaskManager getInstance() {
* Looks up a task by its full id. if (instance == null) {
* instance = new TaskManager();
* @param id the task id }
* @return the task, or {@code null} if unknown
*/
Task getTask(String id);
/** return instance;
* Whether a task with the given id is registered or pending activation. }
*
* @param id the task id
* @return {@code true} if present
*/
boolean hasTask(String id);
/** public void init() {
* Dispatches an interaction/context event to the matching task. logger.info("Initializing task manager...");
*
* @param ctx the task context
*/
void handle(TaskContext ctx);
/** loadBuiltInTasks();
* Promotes a pending step-task into the active registry under its (possibly new) id. loadScheduledTasks();
* taskService.loadTasks();
* @param task the task to activate }
* @param oldId the previous id used while the task was pending
*/
void activateTask(Task task, String oldId);
/** public TaskService getTaskService() {
* Removes a task from the active registry. return taskService;
* }
* @param task the task to remove
*/
void removeTask(Task task);
/** public Task getTask(String id) {
* Inserts a previously persisted task back into the active registry. return tasks.get(id);
* }
* @param task the persistable task
*/
void insertPersistableTask(PersistableTask task);
/** public boolean hasTask(String id) {
* Returns every currently active task. return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id));
* }
* @return the active tasks
*/
Collection<Task> getTasks();
/** public synchronized Collection<Task> getTasks() {
* Finds the first task of the given type whose id contains {@code partialId}. return tasks.values();
* }
* @param clazz the expected task type
* @param partialId a fragment of the task id
* @param <E> the task type
* @return the matching task, if any
*/
<E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId);
/** public void insert(CommandContext context, Task task) {
* Finds every task of the given type whose id contains {@code partialId}. String id = task.getId();
*
* @param clazz the expected task type
* @param partialId a fragment of the task id
* @param <E> the task type
* @return the matching tasks
*/
<E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId);
/** if (hasTask(id)) {
* Finds the first button-bearing task of the given class. context.reply("A task with the same ID already exists!");
* return;
* @param clazz the expected button-task type }
* @return the matching task, or {@code null}
*/
WithButton findButtonTaskByClass(Class<? extends WithButton> clazz);
/** task.replyToInteraction(context);
* Returns the persistence service used for loadable tasks.
* if (task.isInsertable()) {
* @return the task service logger.info("Inserting task: {}", task);
*/
TaskService getTaskService(); if (task instanceof PersistableTask persistableTask) {
taskService.persistTask(persistableTask);
}
if (task instanceof StepTask) {
pendingTasks.add(task);
} else {
tasks.put(id, task);
}
}
if (task instanceof Deletable) {
deletableTasksContext.put(task.getId(), context);
}
if (task instanceof WithChildren taskWithChildren) {
List<Task> childrens = taskWithChildren.getChildTasks();
if (childrens == null || childrens.isEmpty()) {
logger.warn("Parent task '{}' doesn't create any childs", task.getName());
return;
}
for (Task child : childrens) {
insert(context, child);
}
}
}
public void activateTask(Task task, String oldId) {
if (pendingTasks.remove(task)) {
tasks.put(task.getId(), task);
logger.info("Updating task {} with new id: {} (was: {})",
task.getName(), task.getId(), oldId);
}
}
public void removeTask(Task task) {
String id = task.getId();
if (tasks.remove(id) != null) {
logger.debug("Removing task: {}", id);
ReentrantLock lock = tasksLock.remove(task.getLockId());
if (lock != null) {
logger.debug("Cleaning up lock for removed task: {}", task.getId());
}
}
}
public void handle(TaskContext context) {
String taskId = context.getId();
Task task = getTask(taskId);
if (task == null && context instanceof InteractionContext) {
context.replyEphemeral("This task is no longer available, someone might have already used it or it might have expired");
}
if (task != null) {
String lockId = task.getLockId();
ReentrantLock lock = tasksLock.computeIfAbsent(lockId, k -> {
if (task instanceof PersistableTask || task instanceof BuiltInTask) {
return null;
}
return new ReentrantLock();
});
try {
if (lock != null) {
logger.debug("Aquiring lock for task: {}", taskId);
lock.lock();
if (!tasks.containsKey(taskId)) {
logger.debug("Task {} is no longer available after aquiring lock", taskId);
tasksLock.remove(lockId);
return;
}
}
logger.info("Executing task: {}", taskId);
Task.Lifecycle status = task.execute(context);
handleTaskLifecycle(task, status);
deleteInteraction(task);
// If interaction is still not acknowledged after execution, send a placeholder response.
if (!context.isAcknowledged() && !(task instanceof NotAcknowledgeable)) {
logger.warn("Interaction not acknowledged");
context.replyEphemeral("Task executed successfully but interaction wasn't acknowledged, report this bug by using the command /report-bug");
}
} catch (GracefulContextError exception) {
logger.error(exception.getMessage());
context.replyEphemeral(exception.getMessage());
} catch (TaskExecutionException exception) {
LogHandler.log(exception);
} catch (Exception exception) {
LogHandler.log(new TaskExecutionException("Error during task execution", context, exception));
} finally {
if (lock != null) {
logger.debug("Releasing lock for task: {}", taskId);
lock.unlock();
}
}
}
}
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
List<E> tasks = findTasksByPartialId(clazz, partialId);
return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst());
}
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
return tasks.values().stream()
.filter(clazz::isInstance)
.map(clazz::cast)
.filter(task -> task.getId().contains(partialId))
.collect(Collectors.toList());
}
public WithButton findButtonTaskByClass(Class<? extends WithButton> clazz) {
return tasks.values().stream()
.filter(clazz::isInstance)
.map(task -> (WithButton) task)
.findFirst()
.orElse(null);
}
public void insertPersistableTask(PersistableTask task) {
if (tasks.containsKey(task.getId())) {
logger.error("Trying to load persistable task but ID already exists: {}", task.getId());
return;
}
logger.info("Loaded persistable task: {}", task);
tasks.put(task.getId(), task);
}
private void loadBuiltInTasks() {
logger.info("Loading built-in tasks...");
ReflectionHelper.loadTasks(BuiltInTask.class, builtInTask -> {
if (builtInTask.isInsertable()) {
logger.info("Loaded task: {}", builtInTask.getClass().getName());
tasks.put(builtInTask.getId(), builtInTask);
}
});
}
private void loadScheduledTasks() {
logger.info("Loading scheduled tasks...");
ReflectionHelper.loadTasks(ScheduledTask.class, scheduledTask -> {
String taskId = scheduledTask.getId();
if (taskId == null || taskId.trim().isEmpty()) {
logger.error("Cannot load scheduled task {}: task ID is null or empty", scheduledTask.getClass().getName());
return;
}
ScheduledTaskManager.schedule(scheduledTask);
});
}
private void deleteInteraction(Task task) {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent instanceof Deletable deletable && task.isInsertable()) {
deleteInteraction(parent.getId(), deletable);
return;
}
}
if (task instanceof Deletable deletable && task.isInsertable()) {
deleteInteraction(task.getId(), deletable);
}
}
private void deleteInteraction(String taskId, Deletable deletable) {
logger.debug("Deleting interaction: {}", taskId);
CommandContext context = deletableTasksContext.get(taskId);
if (context != null) {
deletableTasksContext.remove(taskId);
InteractionHook hook = context.getInteractionHook();
if (hook != null) {
if (hook.isExpired()) {
context.replyEphemeral("This interaction has expired, can't remove it");
}
if (!hook.isExpired()) {
hook.deleteOriginal().queue();
}
}
}
deletable.onInteractionDelete();
}
private void handleTaskLifecycle(Task task, Task.Lifecycle status) {
if (status == null) {
throw new IllegalArgumentException("Task lifecycle status cannot be null");
}
switch (status) {
case REMOVE_SELF:
removeTask(task);
break;
case REMOVE_SELF_AND_CHILDREN:
if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
removeTask(child);
}
}
removeTask(task);
break;
case KEEP:
default:
break;
}
}
} }
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more