Compare commits
10
Commits
main
..
4cf7c47645
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cf7c47645 | ||
|
|
01bfe22d22 | ||
|
|
5f6265db01 | ||
|
|
a507b471b4 | ||
|
|
868dbea8df | ||
|
|
dd1c4dd42f | ||
|
|
64f4890931 | ||
|
|
1d737a2a4f | ||
|
|
1b981abdf7 | ||
|
|
b8786c8dc9 |
No files matched your search
+7
-4
@@ -1,5 +1,4 @@
|
||||
NETNEUTRALITY_TAG=latest
|
||||
|
||||
NETNEUTRALITY_BOT_ENV=dev
|
||||
NETNEUTRALITY_BOT_TOKEN=
|
||||
NETNEUTRALITY_BOT_PREFIX=$
|
||||
@@ -12,13 +11,17 @@ MYSQL_PORT=3306
|
||||
MYSQL_USER=java
|
||||
MYSQL_PASSWORD=password
|
||||
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_VISITOR_DATA=
|
||||
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
||||
NETNEUTRALITY_OAUTH_TOKEN=
|
||||
NETNEUTRALITY_CIPHER_URL=http://ejs-api:8001
|
||||
NETNEUTRALITY_CIPHER_PASSWORD=
|
||||
NETNEUTRALITY_WEBPO_URL=http://webpo:8080
|
||||
NETNEUTRALITY_WEBPO_PASSWORD=
|
||||
|
||||
NETNEUTRALITY_SENTRY_DSN=
|
||||
GITLAB_HOST=
|
||||
|
||||
@@ -129,5 +129,5 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:buildcache,mode=max
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
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()`
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
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.
|
||||
@@ -20,6 +20,10 @@ NETNEUTRALITY_VISITOR_DATA=
|
||||
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
||||
NETNEUTRALITY_CIPHER_PASSWORD=
|
||||
|
||||
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
|
||||
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
|
||||
NETNEUTRALITY_LAVALINK_NAME=main
|
||||
|
||||
NETNEUTRALITY_SENTRY_DSN=
|
||||
GITLAB_HOST=
|
||||
GITLAB_TOKEN=
|
||||
|
||||
+47
-5
@@ -23,14 +23,39 @@ services:
|
||||
- netneutrality
|
||||
depends_on:
|
||||
- netneutrality-db
|
||||
- lavalink
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
MYSQL_HOST: netneutrality-db
|
||||
MYSQL_PORT: 3306
|
||||
MYSQL_USER: java
|
||||
MYSQL_PASSWORD: password
|
||||
MYSQL_DATABASE: netneutrality
|
||||
- MYSQL_HOST=${MYSQL_HOST:-netneutrality-db}
|
||||
- MYSQL_PORT=${MYSQL_PORT:-3306}
|
||||
- MYSQL_USER=${MYSQL_USER:-java}
|
||||
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-password}
|
||||
- MYSQL_DATABASE=${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:
|
||||
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449
|
||||
container_name: ejs_signature_api
|
||||
@@ -39,8 +64,25 @@ services:
|
||||
- netneutrality
|
||||
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
|
||||
# - 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:
|
||||
netneutrality-db: null
|
||||
netneutrality-lavalink-plugins: null
|
||||
|
||||
networks:
|
||||
netneutrality: null
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,162 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,169 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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
@@ -0,0 +1,102 @@
|
||||
# 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
@@ -0,0 +1,170 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
File renamed without changes.
+2
-1
@@ -10,7 +10,8 @@ import com.sedmelluq.discord.lavaplayer.track.AudioReference;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
public class UdioAudioSourceManagerTest {
|
||||
File renamed without changes.
+315
@@ -0,0 +1,315 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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)).
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
@@ -11,15 +11,8 @@
|
||||
<description>Discord bot</description>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<distributionManagement>
|
||||
<repository>
|
||||
<id>gitea</id>
|
||||
<url>https://gitea.bobert.me/api/packages/anthorob/maven</url>
|
||||
</repository>
|
||||
</distributionManagement>
|
||||
|
||||
<properties>
|
||||
<main.class>me.bobert.netneutrality.NetNeutrality</main.class>
|
||||
<main.class>me.bobert.netneutrality.App</main.class>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
@@ -143,7 +136,7 @@
|
||||
<repository>
|
||||
<id>arbjergDev-releases</id>
|
||||
<name>Lavalink Repository</name>
|
||||
<url>https://maven.lavalink.dev/snapshots</url>
|
||||
<url>https://maven.lavalink.dev/releases</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
@@ -153,35 +146,11 @@
|
||||
<artifactId>JDA</artifactId>
|
||||
<version>6.4.1</version>
|
||||
</dependency>
|
||||
<!-- Lavalink v4 client; audio decoding happens on the remote node. -->
|
||||
<dependency>
|
||||
<groupId>dev.arbjerg</groupId>
|
||||
<artifactId>lavaplayer</artifactId>
|
||||
<version>2.2.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.lavalink.youtube</groupId>
|
||||
<artifactId>v2</artifactId>
|
||||
<version>f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT</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>
|
||||
<artifactId>lavalink-client</artifactId>
|
||||
<version>3.4.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -190,6 +159,12 @@
|
||||
<version>5.3.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>6.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-core</artifactId>
|
||||
@@ -226,6 +201,13 @@
|
||||
<groupId>org.gitlab4j</groupId>
|
||||
<artifactId>gitlab4j-api</artifactId>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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(VERSION);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -1,11 +1,13 @@
|
||||
package me.bobert.netneutrality.core;
|
||||
package me.bobert.netneutrality.bootstrap;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
import me.bobert.netneutrality.music.TrackScheduler;
|
||||
import org.json.JSONArray;
|
||||
@@ -19,14 +21,32 @@ import java.util.Map;
|
||||
public class BotState {
|
||||
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?
|
||||
private static void saveGuildsState() {
|
||||
Map<String, GuildService> services = GuildService.getServices();
|
||||
if (guildServices == null || musicService == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, GuildService> services = guildServices.getServices();
|
||||
|
||||
JSONObject main = new JSONObject();
|
||||
JSONArray guildsArray = new JSONArray();
|
||||
|
||||
for (MusicManager manager : MusicManager.getManagers()) {
|
||||
for (MusicManager manager : musicService.getManagers()) {
|
||||
TrackScheduler trackScheduler = manager.getScheduler();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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,14 +12,23 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.db.entities.OBlacklist;
|
||||
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.build.SubcommandData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -55,7 +64,7 @@ public class BlacklistCommand extends AdminCommand {
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
String sub = context.getSubcommand();
|
||||
String word = context.getOption("word", "", CommandOption::getAsString);
|
||||
BlacklistService service = GuildService.get(context.getGuild()).getBlacklistService();
|
||||
BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService();
|
||||
|
||||
switch (sub) {
|
||||
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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,8 +10,7 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
import me.bobert.netneutrality.core.command.impl.AdminCommand;
|
||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
|
||||
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
@@ -23,9 +22,18 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -93,11 +101,11 @@ public class ReactionCommand extends AdminCommand {
|
||||
|
||||
private CommandExecutionResult executeEmoji(CommandContext context, String guildId, String messageId, String sub) {
|
||||
String id = String.format("%s:%s", guildId, messageId);
|
||||
EmojiReactionRoleTask task = (EmojiReactionRoleTask) TaskManager.getInstance().getTask(id);
|
||||
EmojiReactionRoleTask task = (EmojiReactionRoleTask) taskManager.getTask(id);
|
||||
|
||||
switch (sub) {
|
||||
case "list" -> {
|
||||
List<EmojiReactionRoleTask> tasks = TaskManager.getInstance().findTasksByPartialId(EmojiReactionRoleTask.class, guildId);
|
||||
List<EmojiReactionRoleTask> tasks = taskManager.findTasksByPartialId(EmojiReactionRoleTask.class, guildId);
|
||||
if (tasks.isEmpty()) {
|
||||
return CommandExecutionResult.info("No tasks found for this guild");
|
||||
}
|
||||
|
||||
@@ -8,16 +8,17 @@ package me.bobert.netneutrality.command.basic;
|
||||
import me.bobert.netneutrality.core.command.CommandCategory;
|
||||
import me.bobert.netneutrality.core.command.CommandContainer;
|
||||
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.impl.AbstractCommand;
|
||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.tasks.Task;
|
||||
import me.bobert.netneutrality.core.tasks.trait.WithButton;
|
||||
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.CommandHelp;
|
||||
import me.bobert.netneutrality.tasks.help.ReportBug;
|
||||
import me.bobert.netneutrality.utils.MessageFunction;
|
||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
||||
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
||||
import net.dv8tion.jda.api.components.buttons.Button;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
@@ -26,6 +27,18 @@ import java.util.List;
|
||||
|
||||
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
|
||||
public boolean needAdmin() {
|
||||
return false;
|
||||
@@ -65,15 +78,15 @@ public class HelpCommand extends AbstractCommand {
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
String command = context.getOption("command", null, CommandOption::getAsString);
|
||||
if (command == null) {
|
||||
Button commands = Task.createButton(CommandHelp.class);
|
||||
Button info = Task.createButton(BotInfo.class);
|
||||
Button reportbug = Task.createButton(ReportBug.class);
|
||||
ActionRow row = ActionRow.of(commands, info, reportbug);
|
||||
Button commandsBtn = createTaskButton(CommandHelp.class);
|
||||
Button info = createTaskButton(BotInfo.class);
|
||||
Button reportbug = createTaskButton(ReportBug.class);
|
||||
ActionRow row = ActionRow.of(commandsBtn, info, reportbug);
|
||||
context.replyEphemeral("Thanks for using the Net Neutrality Bot! What do you need help with?", List.of(row));
|
||||
return CommandExecutionResult.none();
|
||||
}
|
||||
|
||||
CommandContainer container = CommandManager.getCommandContainer(command);
|
||||
CommandContainer container = commands.getCommandContainer(command);
|
||||
if (container.getCommand() == null) {
|
||||
return CommandExecutionResult.warn("Command does not exist");
|
||||
}
|
||||
@@ -81,4 +94,12 @@ public class HelpCommand extends AbstractCommand {
|
||||
context.replyEphemeral(MessageFunction.buildCommandHelpMessage(container));
|
||||
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,7 +12,6 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
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.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
|
||||
@@ -22,9 +21,24 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
|
||||
|
||||
import java.util.Map;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -63,7 +77,7 @@ public class ConfigureCommand extends AdminCommand {
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
String sub = context.getSubcommand();
|
||||
GuildService guildService = GuildService.get(context.getGuild().getId());
|
||||
GuildService guildService = guildServices.get(context.getGuild().getId());
|
||||
context.ephemeral();
|
||||
|
||||
if ("music-role".equals(sub)) {
|
||||
@@ -105,7 +119,7 @@ public class ConfigureCommand extends AdminCommand {
|
||||
if ("music-volume".equals(sub)) {
|
||||
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
|
||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume));
|
||||
MusicManager.get(context.getGuild()).setVolume(volume);
|
||||
musicService.get(context.getGuild()).setVolume(volume);
|
||||
|
||||
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
*.java
|
||||
**/*.java
|
||||
@@ -12,11 +12,24 @@ import me.bobert.netneutrality.core.command.impl.AbstractCommand;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.http.WebModule;
|
||||
import me.bobert.netneutrality.tasks.git.ReportIssueTask;
|
||||
import me.bobert.netneutrality.utils.Settings;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
|
||||
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
|
||||
public boolean needAdmin() {
|
||||
return false;
|
||||
@@ -59,16 +72,16 @@ public class ReportIssueCommand extends AbstractCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
String gitlabHost = Settings.getInstance().get("GITLAB_HOST");
|
||||
String gitlabToken = Settings.getInstance().get("GITLAB_TOKEN");
|
||||
String projectId = Settings.getInstance().get("GITLAB_PROJECT_ID");
|
||||
String gitlabHost = settings.get("GITLAB_HOST");
|
||||
String gitlabToken = settings.get("GITLAB_TOKEN");
|
||||
String projectId = settings.get("GITLAB_PROJECT_ID");
|
||||
|
||||
if (gitlabHost == null || gitlabToken == null || projectId == null) {
|
||||
return CommandExecutionResult.error("Command is not configured, failed to create issue.");
|
||||
}
|
||||
|
||||
if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) {
|
||||
return CommandExecutionResult.task(new ReportIssueTask(context));
|
||||
return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings));
|
||||
}
|
||||
|
||||
return CommandExecutionResult.error("Unable to create bug reports at this time, please try again later.");
|
||||
|
||||
@@ -10,11 +10,28 @@ 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.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.music.MusicManager;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -42,7 +59,7 @@ public class BitconnectCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
context.ephemeral();
|
||||
context.deleteMessage();
|
||||
|
||||
|
||||
@@ -9,12 +9,29 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
|
||||
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
|
||||
public boolean needAdmin() {
|
||||
return true;
|
||||
@@ -42,10 +59,10 @@ public class ClearQueueCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
context.ack();
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
return manager.getQueueManager().clearQueue();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,30 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MessageFunction;
|
||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -38,10 +55,10 @@ public class CurrentlyPlayingCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
context.ack();
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
if (!manager.isCurrentlyPlaying()) {
|
||||
return CommandExecutionResult.info("Bot isn't playing any tracks right now");
|
||||
}
|
||||
|
||||
@@ -9,11 +9,29 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.utils.MusicUtils;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -36,7 +54,9 @@ public class JoinCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
if (MusicUtils.connect(context)) {
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
|
||||
if (MusicUtils.connect(context, manager)) {
|
||||
context.reply("Joining current voice channel");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,35 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
import me.bobert.netneutrality.core.command.impl.MusicCommand;
|
||||
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.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 {
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -37,9 +61,9 @@ public class LeaveCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
context.reply("Leaving voice channel and clearing queue");
|
||||
manager.disconnect();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.context.CommandContext;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
@@ -16,6 +18,21 @@ import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -38,7 +55,7 @@ public class PlayCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
String query = context.getOption("query", CommandOption::getAsString);
|
||||
context.ack();
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.context.CommandContext;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MessageFunction;
|
||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
@@ -20,6 +22,21 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -58,11 +75,11 @@ public class QueueCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
String sub = context.getSubcommand();
|
||||
context.ack();
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
|
||||
switch (sub) {
|
||||
case "clear-all" -> {
|
||||
|
||||
@@ -4,14 +4,37 @@ package me.bobert.netneutrality.command.music;/*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -34,9 +57,9 @@ public class ResumeCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
return manager.resume();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,28 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -36,10 +53,10 @@ public class ShuffleCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
context.ack();
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
return manager.getQueueManager().shuffle();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,16 +9,33 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.context.CommandContext;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -42,12 +59,12 @@ public class SkipCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
context.ack();
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
int skips = context.getOption("skip", 1, CommandOption::getAsInt);
|
||||
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) {
|
||||
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
|
||||
return manager.getQueueManager().skip(skips);
|
||||
} else {
|
||||
return manager.voteSkip(context);
|
||||
|
||||
@@ -9,13 +9,30 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -38,10 +55,10 @@ public class StopCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
context.ack();
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
if (UserFunction.isAdmin(context)) {
|
||||
manager.stopTrack();
|
||||
context.reply("Stopping current track");
|
||||
|
||||
@@ -8,13 +8,30 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MessageFunction;
|
||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -37,10 +54,10 @@ public class ViewQueueCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
context.ack();
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
int queueSize = manager.getQueueSize();
|
||||
if (queueSize == 0) {
|
||||
return CommandExecutionResult.warn("Queue is empty!");
|
||||
|
||||
@@ -9,16 +9,33 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.context.CommandContext;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -42,11 +59,11 @@ public class VolumeCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
|
||||
manager.setVolume(volume);
|
||||
return CommandExecutionResult.success(String.format("Volume set to %s", volume));
|
||||
}
|
||||
|
||||
@@ -8,12 +8,29 @@ package me.bobert.netneutrality.command.music;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
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.music.MusicManager;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
|
||||
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
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
@@ -36,9 +53,9 @@ public class VoteSkipCommand extends MusicCommand {
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = MusicManager.get(context.getGuild());
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
|
||||
if (MusicUtils.checkIfConnected(context)) {
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
return manager.voteSkip(context);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,22 +9,32 @@ package me.bobert.netneutrality.command.react;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
|
||||
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
|
||||
import me.bobert.netneutrality.tasks.reaction.EmojiRoleFirstStep;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
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
|
||||
public CommandExecutionResult execute(Message target, CommandContext context) {
|
||||
EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
|
||||
EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
|
||||
|
||||
if (task != null) {
|
||||
context.replyEphemeral("This message is already registered with the bot, please use the modify command instead");
|
||||
return CommandExecutionResult.none();
|
||||
}
|
||||
|
||||
return CommandExecutionResult.task(new EmojiRoleFirstStep(target));
|
||||
return CommandExecutionResult.task(new EmojiRoleFirstStep(taskManager, target));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,22 +9,32 @@ package me.bobert.netneutrality.command.react;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
|
||||
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
|
||||
import me.bobert.netneutrality.tasks.reaction.EmojiRoleModify;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
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
|
||||
public CommandExecutionResult execute(Message target, CommandContext context) {
|
||||
EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
|
||||
EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
|
||||
if (task == null) {
|
||||
context.replyEphemeral("This message is not registered with the bot, please use the create command instead");
|
||||
return CommandExecutionResult.none();
|
||||
}
|
||||
|
||||
return CommandExecutionResult.task(new EmojiRoleModify(target, task));
|
||||
return CommandExecutionResult.task(new EmojiRoleModify(taskManager, target, task));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.context;
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.context.AbstractContext;
|
||||
import me.bobert.netneutrality.core.context.AsyncContext;
|
||||
import me.bobert.netneutrality.core.context.NoMessageContext;
|
||||
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
|
||||
@@ -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.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.context.MessageContext;
|
||||
import me.bobert.netneutrality.utils.Settings;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Mentions;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
@@ -30,8 +30,11 @@ public class MessageCommandContext extends CommandContext implements MessageCont
|
||||
private final MessageReceivedEvent event;
|
||||
private final Message message;
|
||||
|
||||
public MessageCommandContext(MessageReceivedEvent event) {
|
||||
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor());
|
||||
private final SettingsProvider settings;
|
||||
|
||||
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.message = event.getMessage();
|
||||
}
|
||||
@@ -320,7 +323,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
|
||||
@Override
|
||||
public String parseCommandName() {
|
||||
String rawMessage = message.getContentRaw().trim();
|
||||
String prefix = Settings.getInstance().getPrefix();
|
||||
String prefix = settings.getPrefix();
|
||||
|
||||
if (rawMessage.startsWith(prefix)) {
|
||||
return getFullCommand().split(" ", 2)[0];
|
||||
@@ -332,7 +335,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
|
||||
@Override
|
||||
public String getFullCommand() {
|
||||
String rawMessage = message.getContentRaw().trim();
|
||||
String prefix = Settings.getInstance().getPrefix();
|
||||
String prefix = settings.getPrefix();
|
||||
|
||||
return rawMessage.replaceFirst("^[" + prefix + "\\s]+", "").trim();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ package me.bobert.netneutrality.context.command;
|
||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
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.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.events.Event;
|
||||
@@ -20,8 +21,13 @@ import java.util.List;
|
||||
public class MessageMenuContext extends CommandContext implements InteractionContext {
|
||||
private final MessageContextInteractionEvent event;
|
||||
|
||||
public MessageMenuContext(MessageContextInteractionEvent event) {
|
||||
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
|
||||
@Override
|
||||
public boolean isInteraction() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public MessageMenuContext(MessageContextInteractionEvent event, SettingsProvider settings) {
|
||||
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings);
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.command.options.SlashCommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
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.events.interaction.command.SlashCommandInteractionEvent;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
@@ -19,11 +20,21 @@ import java.util.stream.Collectors;
|
||||
public class SlashCommandContext extends CommandContext implements InteractionContext {
|
||||
private final SlashCommandInteractionEvent event;
|
||||
|
||||
public SlashCommandContext(SlashCommandInteractionEvent event) {
|
||||
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
|
||||
public SlashCommandContext(SlashCommandInteractionEvent event, SettingsProvider settings) {
|
||||
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings);
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInteraction() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSkipPermissionCheck() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SlashCommandInteractionEvent getEvent() {
|
||||
return event;
|
||||
|
||||
@@ -9,6 +9,7 @@ package me.bobert.netneutrality.context.command;
|
||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
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.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||
@@ -21,8 +22,13 @@ import java.util.List;
|
||||
public class UserMenuContext extends CommandContext implements InteractionContext {
|
||||
private final UserContextInteractionEvent event;
|
||||
|
||||
public UserMenuContext(UserContextInteractionEvent event) {
|
||||
super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser());
|
||||
@Override
|
||||
public boolean isInteraction() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public UserMenuContext(UserContextInteractionEvent event, SettingsProvider settings) {
|
||||
super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser(), settings);
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.context.tasks;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.AsyncContext;
|
||||
import me.bobert.netneutrality.core.context.AsyncContext;
|
||||
import me.bobert.netneutrality.core.context.NoMessageContext;
|
||||
import me.bobert.netneutrality.core.context.TaskContext;
|
||||
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
||||
|
||||
@@ -9,9 +9,9 @@ import io.sentry.Sentry;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.context.Context;
|
||||
import me.bobert.netneutrality.core.context.TaskContext;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.exceptions.ContextException;
|
||||
import me.bobert.netneutrality.utils.MessageFunction;
|
||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
@@ -20,6 +20,17 @@ import org.apache.logging.log4j.Logger;
|
||||
public class LogHandler {
|
||||
|
||||
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
|
||||
@@ -80,7 +91,8 @@ public class LogHandler {
|
||||
Context context = exception.getContext();
|
||||
|
||||
if (context.getGuild() != null) {
|
||||
if (GuildService.get(context.getGuild()).isDebugEnabled()) {
|
||||
GuildServiceProvider provider = guildServices;
|
||||
if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) {
|
||||
context.getGuild()
|
||||
.getTextChannels()
|
||||
.stream()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ package me.bobert.netneutrality.core.command;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.utils.ReflectionHelper;
|
||||
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
||||
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.interactions.InteractionContextType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
|
||||
@@ -17,15 +18,43 @@ import org.apache.logging.log4j.Logger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
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);
|
||||
public static final Map<String, CommandContainer> commands = new ConcurrentHashMap<>();
|
||||
|
||||
public static void init() {
|
||||
private final Map<String, CommandContainer> commands = new ConcurrentHashMap<>();
|
||||
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..");
|
||||
|
||||
initCommands();
|
||||
@@ -33,12 +62,30 @@ public class CommandManager {
|
||||
logger.info("Loaded {} commands!", commands.size());
|
||||
}
|
||||
|
||||
public static CommandContainer getCommandContainer(String commandString) {
|
||||
return commands.getOrDefault(commandString, CommandContainer.NULL_COMMAND);
|
||||
@Override
|
||||
public CommandContainer getCommandContainer(String name) {
|
||||
return commands.getOrDefault(name, CommandContainer.NULL_COMMAND);
|
||||
}
|
||||
|
||||
public static void initCommands() {
|
||||
ReflectionHelper.loadCommands(Command.class, command -> {
|
||||
@Override
|
||||
public boolean hasCommand(String name) {
|
||||
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()) {
|
||||
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
|
||||
return;
|
||||
@@ -58,10 +105,13 @@ public class CommandManager {
|
||||
if (!commands.containsKey(command.getCommandName())) {
|
||||
commands.put(command.getCommandName(), container);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
|
||||
}
|
||||
|
||||
public static void registerSlashCommands(JDA jda) {
|
||||
@Override
|
||||
public void registerSlashCommands(JDA jda) {
|
||||
CommandListUpdateAction commandListUpdateAction = jda.updateCommands();
|
||||
List<CommandData> slashCommands = new ArrayList<>();
|
||||
|
||||
|
||||
@@ -6,61 +6,74 @@ package me.bobert.netneutrality.core.command;
|
||||
*/
|
||||
|
||||
import io.sentry.Sentry;
|
||||
import me.bobert.netneutrality.context.command.SlashCommandContext;
|
||||
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.InteractionContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.exceptions.CommandExecutionException;
|
||||
import me.bobert.netneutrality.exceptions.GracefulContextError;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
||||
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.Logger;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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;
|
||||
import java.util.Objects;
|
||||
|
||||
public class CommandProcessor {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(CommandProcessor.class);
|
||||
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) {
|
||||
public CommandProcessor(CommandContext context, CommandRegistry commands, TaskManager taskManager) {
|
||||
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.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() {
|
||||
if (!CommandManager.commands.containsKey(context.parseCommandName())) {
|
||||
if (!commands.hasCommand(context.parseCommandName())) {
|
||||
return;
|
||||
}
|
||||
|
||||
CommandContainer container = CommandManager.getCommandContainer(context.parseCommandName());
|
||||
CommandContainer container = commands.getCommandContainer(context.parseCommandName());
|
||||
Command command = container.getCommand();
|
||||
if (command == null) {
|
||||
throw new IllegalArgumentException("Context has no command");
|
||||
}
|
||||
|
||||
context.setCommand(command);
|
||||
if (command.isGuildOnly() && !context.isFromGuild()) {
|
||||
context.replyEphemeral("This command is only available in a guild");
|
||||
context.deleteMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.isSlashCommandOnly() && !(context instanceof InteractionContext)) {
|
||||
context.replyEphemeral("This command is only available as a slash command");
|
||||
return;
|
||||
for (CommandPrecondition precondition : preconditions) {
|
||||
if (!precondition.check(context, command)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -68,12 +81,7 @@ public class CommandProcessor {
|
||||
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
|
||||
configureSentry(command);
|
||||
|
||||
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)) {
|
||||
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,31 +96,7 @@ public class CommandProcessor {
|
||||
}
|
||||
|
||||
CommandExecutionResult commandExecutionResult = command.execute(context);
|
||||
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);
|
||||
}
|
||||
resultHandler.handle(context, commandExecutionResult, taskManager);
|
||||
} catch (GracefulContextError exception) {
|
||||
logger.error(exception.getMessage());
|
||||
context.replyEphemeral(exception.getMessage());
|
||||
@@ -123,111 +107,6 @@ 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) {
|
||||
Sentry.configureScope(scope -> {
|
||||
scope.setTag("command", command.getCommandName());
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@ package me.bobert.netneutrality.core.command.impl;
|
||||
*/
|
||||
|
||||
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.service.GuildService;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
@@ -16,8 +17,45 @@ 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.MessageChannel;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
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
|
||||
public boolean needAdmin() {
|
||||
return false;
|
||||
@@ -41,7 +79,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
||||
|
||||
Guild guild = member.getGuild();
|
||||
|
||||
String roleDb = GuildService.get(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
|
||||
String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
|
||||
|
||||
if (roleDb != null && !roleDb.isEmpty()) {
|
||||
if (UserFunction.isAdmin(member)) {
|
||||
@@ -51,7 +89,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
||||
Role neededRole = guild.getRoleById(roleDb);
|
||||
if (neededRole == null) {
|
||||
// Role doesn't exist on guild anymore, remove from db
|
||||
GuildService.get(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
|
||||
guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -68,7 +106,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
||||
return false;
|
||||
}
|
||||
|
||||
String channelDb = GuildService.get(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL);
|
||||
String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL);
|
||||
if (channelDb != null && !channelDb.isEmpty()) {
|
||||
return channelDb.equals(channel.getId());
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
package me.bobert.netneutrality.context;
|
||||
package me.bobert.netneutrality.core.context;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.context.AbstractContext;
|
||||
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.core.context;
|
||||
|
||||
import me.bobert.netneutrality.core.command.Command;
|
||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.utils.Settings;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
@@ -33,10 +33,12 @@ public abstract class CommandContext extends AbstractContext {
|
||||
private SubcommandData subcommand;
|
||||
private final Map<OptionData, String> invalidOptions = new HashMap<>();
|
||||
private final List<CommandOption> options;
|
||||
private final SettingsProvider settings;
|
||||
|
||||
public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user) {
|
||||
public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user, SettingsProvider settings) {
|
||||
super(jda, guild, channel, member, user);
|
||||
this.options = new ArrayList<>();
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public Command getCommand() {
|
||||
@@ -98,7 +100,7 @@ public abstract class CommandContext extends AbstractContext {
|
||||
|
||||
public CommandOption getOption(String name) {
|
||||
List<CommandOption> options = getOptionsByName(name);
|
||||
return options.isEmpty() ? null : options.get(0);
|
||||
return options.isEmpty() ? null : options.getFirst();
|
||||
}
|
||||
|
||||
public List<CommandOption> getOptionsByName(String name) {
|
||||
@@ -131,7 +133,7 @@ public abstract class CommandContext extends AbstractContext {
|
||||
}
|
||||
|
||||
public boolean isMessageFromCreator() {
|
||||
String creatorId = Settings.getInstance().get("NETNEUTRALITY_BOT_OWNER");
|
||||
String creatorId = settings.get("NETNEUTRALITY_BOT_OWNER");
|
||||
|
||||
return creatorId.equals(getDiscordUser().getId());
|
||||
}
|
||||
@@ -141,4 +143,12 @@ public abstract class CommandContext extends AbstractContext {
|
||||
public abstract String parseCommandName();
|
||||
|
||||
public abstract String getFullCommand();
|
||||
|
||||
public boolean isInteraction() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean canSkipPermissionCheck() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.core.context;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.AsyncContext;
|
||||
import me.bobert.netneutrality.utils.ContextUtils;
|
||||
import me.bobert.netneutrality.shared.util.ContextUtils;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
|
||||
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.context;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.AsyncContext;
|
||||
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
||||
import net.dv8tion.jda.api.interactions.Interaction;
|
||||
import net.dv8tion.jda.api.interactions.InteractionHook;
|
||||
|
||||
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.core.context;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.AsyncContext;
|
||||
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
|
||||
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.core.context;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.AsyncContext;
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.interactions.InteractionHook;
|
||||
import net.dv8tion.jda.api.modals.Modal;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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
-1
@@ -1,4 +1,4 @@
|
||||
package me.bobert.netneutrality.core.tasks;
|
||||
package me.bobert.netneutrality.core.scheduled;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
@@ -0,0 +1,112 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -79,14 +79,4 @@ public interface Task {
|
||||
Lifecycle execute(TaskContext 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,321 +1,120 @@
|
||||
package me.bobert.netneutrality.core.tasks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.context.TaskContext;
|
||||
import me.bobert.netneutrality.core.tasks.trait.WithButton;
|
||||
import me.bobert.netneutrality.db.service.TaskService;
|
||||
|
||||
public class TaskManager {
|
||||
private static final Logger logger = LogManager.getLogger(TaskManager.class);
|
||||
private static TaskManager instance;
|
||||
private final TaskService taskService;
|
||||
private final Map<String, Task> tasks;
|
||||
private final List<Task> pendingTasks;
|
||||
private final Map<String, CommandContext> deletableTasksContext;
|
||||
private final Map<String, ReentrantLock> tasksLock;
|
||||
/**
|
||||
* Owns the in-memory task registry, button/interaction dispatch, and persistable-task loading.
|
||||
*
|
||||
* <p>As a {@link Lifecycle} component it loads built-in and persisted tasks on
|
||||
* {@link #start()}.
|
||||
*/
|
||||
public interface TaskManager extends Lifecycle {
|
||||
|
||||
private TaskManager() {
|
||||
this.tasks = new ConcurrentHashMap<>();
|
||||
this.pendingTasks = new CopyOnWriteArrayList<>();
|
||||
this.deletableTasksContext = new ConcurrentHashMap<>();
|
||||
this.tasksLock = new ConcurrentHashMap<>();
|
||||
this.taskService = new TaskService();
|
||||
}
|
||||
/**
|
||||
* Inserts a newly created task into the registry (and persists it when applicable).
|
||||
*
|
||||
* @param ctx the command context that produced the task
|
||||
* @param task the task to insert
|
||||
*/
|
||||
void insert(CommandContext ctx, Task task);
|
||||
|
||||
public static TaskManager getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new TaskManager();
|
||||
}
|
||||
/**
|
||||
* Looks up a task by its full id.
|
||||
*
|
||||
* @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() {
|
||||
logger.info("Initializing task manager...");
|
||||
/**
|
||||
* Dispatches an interaction/context event to the matching task.
|
||||
*
|
||||
* @param ctx the task context
|
||||
*/
|
||||
void handle(TaskContext ctx);
|
||||
|
||||
loadBuiltInTasks();
|
||||
loadScheduledTasks();
|
||||
taskService.loadTasks();
|
||||
}
|
||||
/**
|
||||
* Promotes a pending step-task into the active registry under its (possibly new) id.
|
||||
*
|
||||
* @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() {
|
||||
return taskService;
|
||||
}
|
||||
/**
|
||||
* Removes a task from the active registry.
|
||||
*
|
||||
* @param task the task to remove
|
||||
*/
|
||||
void removeTask(Task task);
|
||||
|
||||
public Task getTask(String id) {
|
||||
return tasks.get(id);
|
||||
}
|
||||
/**
|
||||
* Inserts a previously persisted task back into the active registry.
|
||||
*
|
||||
* @param task the persistable task
|
||||
*/
|
||||
void insertPersistableTask(PersistableTask task);
|
||||
|
||||
public boolean hasTask(String id) {
|
||||
return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id));
|
||||
}
|
||||
/**
|
||||
* Returns every currently active task.
|
||||
*
|
||||
* @return the active tasks
|
||||
*/
|
||||
Collection<Task> getTasks();
|
||||
|
||||
public synchronized Collection<Task> getTasks() {
|
||||
return tasks.values();
|
||||
}
|
||||
/**
|
||||
* Finds the first task of the given type whose id contains {@code partialId}.
|
||||
*
|
||||
* @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) {
|
||||
String id = task.getId();
|
||||
/**
|
||||
* Finds every task of the given type whose id contains {@code partialId}.
|
||||
*
|
||||
* @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)) {
|
||||
context.reply("A task with the same ID already exists!");
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Finds the first button-bearing task of the given class.
|
||||
*
|
||||
* @param clazz the expected button-task type
|
||||
* @return the matching task, or {@code null}
|
||||
*/
|
||||
WithButton findButtonTaskByClass(Class<? extends WithButton> clazz);
|
||||
|
||||
task.replyToInteraction(context);
|
||||
|
||||
if (task.isInsertable()) {
|
||||
logger.info("Inserting task: {}", task);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns the persistence service used for loadable tasks.
|
||||
*
|
||||
* @return the task service
|
||||
*/
|
||||
TaskService getTaskService();
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package me.bobert.netneutrality.core.tasks;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
||||
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.db.service.TaskService;
|
||||
import me.bobert.netneutrality.exceptions.GracefulContextError;
|
||||
import me.bobert.netneutrality.exceptions.TaskExecutionException;
|
||||
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
|
||||
import net.dv8tion.jda.api.interactions.InteractionHook;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Instance-based {@link TaskManager} owning the in-memory task registry.
|
||||
*/
|
||||
public class TaskManagerImpl implements TaskManager {
|
||||
private static final Logger logger = LogManager.getLogger(TaskManagerImpl.class);
|
||||
private final TaskService taskService;
|
||||
private final Map<String, Task> tasks;
|
||||
private final Map<String, Task> pendingTasks;
|
||||
private final Map<String, CommandContext> deletableTasksContext;
|
||||
private final Map<String, ReentrantLock> tasksLock;
|
||||
private final ApplicationContext context;
|
||||
|
||||
/**
|
||||
* Creates a task manager that can inject context-registered dependencies into reflectively
|
||||
* loaded tasks.
|
||||
*
|
||||
* @param context the application context; must not be {@code null}
|
||||
*/
|
||||
public TaskManagerImpl(ApplicationContext context) {
|
||||
Objects.requireNonNull(context, "ApplicationContext must not be null");
|
||||
this.tasks = new ConcurrentHashMap<>();
|
||||
this.pendingTasks = new ConcurrentHashMap<>();
|
||||
this.deletableTasksContext = new ConcurrentHashMap<>();
|
||||
this.tasksLock = new ConcurrentHashMap<>();
|
||||
this.taskService = new TaskService(this);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int priority() {
|
||||
return 30;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
init();
|
||||
}
|
||||
|
||||
public void init() {
|
||||
logger.info("Initializing task manager...");
|
||||
|
||||
loadBuiltInTasks();
|
||||
taskService.loadTasks();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TaskService getTaskService() {
|
||||
return taskService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task getTask(String id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
Task task = tasks.get(id);
|
||||
if (task != null) {
|
||||
return task;
|
||||
}
|
||||
return pendingTasks.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a task with the given ID exists. Note: this check spans two collections
|
||||
* ({@code tasks} and {@code pendingTasks}) without a single lock, so the result is
|
||||
* best-effort and may be stale by the time the caller acts on it.
|
||||
*/
|
||||
@Override
|
||||
public boolean hasTask(String id) {
|
||||
if (id == null) {
|
||||
return false;
|
||||
}
|
||||
return tasks.containsKey(id) || pendingTasks.containsKey(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task> getTasks() {
|
||||
List<Task> allTasks = new ArrayList<>(tasks.values());
|
||||
allTasks.addAll(pendingTasks.values());
|
||||
return allTasks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insert(CommandContext context, Task task) {
|
||||
String id = task.getId();
|
||||
|
||||
if (hasTask(id)) {
|
||||
context.reply("A task with the same ID already exists!");
|
||||
return;
|
||||
}
|
||||
|
||||
task.replyToInteraction(context);
|
||||
|
||||
if (task.isInsertable()) {
|
||||
logger.info("Inserting task: {}", task);
|
||||
|
||||
if (task instanceof PersistableTask persistableTask) {
|
||||
taskService.persistTask(persistableTask);
|
||||
}
|
||||
|
||||
if (task instanceof StepTask) {
|
||||
pendingTasks.put(id, 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 children", task.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
for (Task child : childrens) {
|
||||
insert(context, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void activateTask(Task task, String oldId) {
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
boolean removed = oldId != null && pendingTasks.remove(oldId) != null;
|
||||
if (!removed) {
|
||||
removed = pendingTasks.remove(task.getId()) != null;
|
||||
}
|
||||
if (!removed) {
|
||||
pendingTasks.values().remove(task);
|
||||
}
|
||||
tasks.put(task.getId(), task);
|
||||
logger.info("Updating task {} with new id: {} (was: {})",
|
||||
task.getName(), task.getId(), oldId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeTask(Task task) {
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
String id = task.getId();
|
||||
|
||||
boolean removed = tasks.remove(id) != null;
|
||||
removed |= pendingTasks.remove(id) != null;
|
||||
removed |= pendingTasks.values().remove(task);
|
||||
|
||||
if (removed) {
|
||||
logger.debug("Removing task: {}", id);
|
||||
|
||||
if (task instanceof PersistableTask persistableTask) {
|
||||
taskService.unpersistTask(persistableTask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
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 = (task instanceof PersistableTask || task instanceof BuiltInTask)
|
||||
? null
|
||||
: tasksLock.computeIfAbsent(lockId, _ -> new ReentrantLock());
|
||||
|
||||
try {
|
||||
if (lock != null) {
|
||||
logger.debug("Acquiring lock for task: {}", taskId);
|
||||
lock.lock();
|
||||
|
||||
if (!tasks.containsKey(taskId)) {
|
||||
logger.debug("Task {} is no longer available after acquiring lock", taskId);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
|
||||
if (clazz == null || partialId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return tasks.values().stream()
|
||||
.filter(clazz::isInstance)
|
||||
.map(clazz::cast)
|
||||
.filter(task -> task.getId() != null && task.getId().contains(partialId))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
|
||||
if (clazz == null || partialId == null) {
|
||||
return List.of();
|
||||
}
|
||||
return tasks.values().stream()
|
||||
.filter(clazz::isInstance)
|
||||
.map(clazz::cast)
|
||||
.filter(task -> task.getId() != null && task.getId().contains(partialId))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public WithButton findButtonTaskByClass(Class<? extends WithButton> clazz) {
|
||||
return tasks.values().stream()
|
||||
.filter(clazz::isInstance)
|
||||
.map(task -> (WithButton) task)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
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...");
|
||||
|
||||
Consumer<BuiltInTask> onLoaded = builtInTask -> {
|
||||
if (builtInTask.isInsertable()) {
|
||||
logger.info("Loaded task: {}", builtInTask.getClass().getName());
|
||||
tasks.put(builtInTask.getId(), builtInTask);
|
||||
}
|
||||
};
|
||||
|
||||
ReflectionHelper.loadTasks(BuiltInTask.class, context, onLoaded);
|
||||
}
|
||||
|
||||
|
||||
private void deleteInteraction(Task task) {
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
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 commandContext = deletableTasksContext.remove(taskId);
|
||||
if (commandContext != null) {
|
||||
InteractionHook hook = commandContext.getInteractionHook();
|
||||
if (hook != null) {
|
||||
if (hook.isExpired()) {
|
||||
commandContext.replyEphemeral("This interaction has expired, can't remove it");
|
||||
} else {
|
||||
hook.deleteOriginal().queue(null, error ->
|
||||
logger.warn("Failed to delete interaction for task {}: {}", taskId, error.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 WithParent<?> taskWithParent) {
|
||||
Task parent = taskWithParent.getParentTask();
|
||||
if (parent != null) {
|
||||
if (parent instanceof WithChildren parentWithChildren) {
|
||||
List<Task> childTasks = parentWithChildren.getChildTasks();
|
||||
if (childTasks != null) {
|
||||
for (Task child : childTasks) {
|
||||
removeTask(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
removeTask(parent);
|
||||
} else {
|
||||
removeTask(task);
|
||||
}
|
||||
} else if (task instanceof WithChildren taskWithChildren) {
|
||||
List<Task> childTasks = taskWithChildren.getChildTasks();
|
||||
if (childTasks != null) {
|
||||
for (Task child : childTasks) {
|
||||
removeTask(child);
|
||||
}
|
||||
}
|
||||
removeTask(task);
|
||||
} else {
|
||||
removeTask(task);
|
||||
}
|
||||
break;
|
||||
|
||||
case KEEP:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -1,4 +1,4 @@
|
||||
package me.bobert.netneutrality.tasks.generic;
|
||||
package me.bobert.netneutrality.core.tasks.generic;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.tasks.generic;
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.context.TaskContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
|
||||
import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
|
||||
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
|
||||
@@ -24,26 +25,26 @@ public class AcceptButton<P extends WithChildren> extends ButtonTask implements
|
||||
private final P parentTask;
|
||||
private final Function<TaskContext, Lifecycle> function;
|
||||
|
||||
protected AcceptButton(P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
super(buttonStyle, buttonLabel);
|
||||
protected AcceptButton(TaskManager taskManager, P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
super(taskManager, buttonStyle, buttonLabel);
|
||||
this.parentTask = parentTask;
|
||||
this.function = function;
|
||||
}
|
||||
|
||||
public static <P extends WithChildren> AcceptButton<P> createPrimaryButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(parentTask, ButtonStyle.PRIMARY, buttonLabel, function);
|
||||
public static <P extends WithChildren> AcceptButton<P> createPrimaryButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(taskManager, parentTask, ButtonStyle.PRIMARY, buttonLabel, function);
|
||||
}
|
||||
|
||||
public static <P extends WithChildren> AcceptButton<P> createSecondaryButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(parentTask, ButtonStyle.SECONDARY, buttonLabel, function);
|
||||
public static <P extends WithChildren> AcceptButton<P> createSecondaryButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(taskManager, parentTask, ButtonStyle.SECONDARY, buttonLabel, function);
|
||||
}
|
||||
|
||||
public static <P extends WithChildren> AcceptButton<P> createSuccessButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(parentTask, ButtonStyle.SUCCESS, buttonLabel, function);
|
||||
public static <P extends WithChildren> AcceptButton<P> createSuccessButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(taskManager, parentTask, ButtonStyle.SUCCESS, buttonLabel, function);
|
||||
}
|
||||
|
||||
public static <P extends WithChildren> AcceptButton<P> createDangerButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(parentTask, ButtonStyle.DANGER, buttonLabel, function);
|
||||
public static <P extends WithChildren> AcceptButton<P> createDangerButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new AcceptButton<>(taskManager, parentTask, ButtonStyle.DANGER, buttonLabel, function);
|
||||
}
|
||||
|
||||
@Override
|
||||
+6
-5
@@ -1,4 +1,4 @@
|
||||
package me.bobert.netneutrality.tasks.generic;
|
||||
package me.bobert.netneutrality.core.tasks.generic;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.tasks.generic;
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.context.TaskContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
|
||||
import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
|
||||
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
|
||||
@@ -19,14 +20,14 @@ public class CancelButton<P extends WithChildren> extends ButtonTask implements
|
||||
private final P parentTask;
|
||||
private final Function<TaskContext, Lifecycle> function;
|
||||
|
||||
protected CancelButton(P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
super(buttonStyle, buttonLabel);
|
||||
protected CancelButton(TaskManager taskManager, P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
super(taskManager, buttonStyle, buttonLabel);
|
||||
this.parentTask = parentTask;
|
||||
this.function = function;
|
||||
}
|
||||
|
||||
public static <P extends WithChildren> CancelButton<P> create(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new CancelButton<>(parentTask, ButtonStyle.DANGER, buttonLabel, function);
|
||||
public static <P extends WithChildren> CancelButton<P> create(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
|
||||
return new CancelButton<>(taskManager, parentTask, ButtonStyle.DANGER, buttonLabel, function);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -8,9 +8,14 @@ package me.bobert.netneutrality.core.tasks.impl;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.context.TaskContext;
|
||||
import me.bobert.netneutrality.core.tasks.BuiltInTask;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
|
||||
public abstract class AbstractBuiltInTask extends AbstractTask implements BuiltInTask {
|
||||
|
||||
public AbstractBuiltInTask(TaskManager taskManager) {
|
||||
super(taskManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return getId();
|
||||
|
||||
@@ -12,6 +12,15 @@ import org.json.JSONObject;
|
||||
|
||||
public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask {
|
||||
|
||||
/**
|
||||
* Preferred constructor for DI.
|
||||
*
|
||||
* @param taskManager the application task manager, must not be {@code null}
|
||||
*/
|
||||
public AbstractPersistableTask(TaskManager taskManager) {
|
||||
super(taskManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInsertable() {
|
||||
return true;
|
||||
@@ -29,26 +38,26 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe
|
||||
|
||||
@Override
|
||||
public void insert(CommandContext context) {
|
||||
if (TaskManager.getInstance().hasTask(getId())) {
|
||||
if (taskManager().hasTask(getId())) {
|
||||
throw new RuntimeException("Cannot insert a task that already exists");
|
||||
}
|
||||
|
||||
TaskManager.getInstance().insert(context, this);
|
||||
taskManager().insert(context, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save() {
|
||||
if (!TaskManager.getInstance().hasTask(getId())) {
|
||||
if (!taskManager().hasTask(getId())) {
|
||||
throw new RuntimeException("Cannot save a task that doesn't exist yet");
|
||||
}
|
||||
|
||||
TaskManager.getInstance().getTaskService().persistTask(this);
|
||||
taskManager().getTaskService().persistTask(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete() {
|
||||
TaskManager.getInstance().getTaskService().unpersistTask(this);
|
||||
TaskManager.getInstance().removeTask(this);
|
||||
taskManager().getTaskService().unpersistTask(this);
|
||||
taskManager().removeTask(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -31,6 +31,15 @@ public abstract class AbstractStepTask extends AbstractTask implements StepTask
|
||||
private Guild guild;
|
||||
private String id = UUID.randomUUID().toString();
|
||||
|
||||
/**
|
||||
* Preferred constructor for DI.
|
||||
*
|
||||
* @param taskManager the application task manager, must not be {@code null}
|
||||
*/
|
||||
public AbstractStepTask(TaskManager taskManager) {
|
||||
super(taskManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return id;
|
||||
@@ -156,7 +165,7 @@ public abstract class AbstractStepTask extends AbstractTask implements StepTask
|
||||
String oldId = id;
|
||||
synchronized (this) {
|
||||
this.id = newId;
|
||||
TaskManager.getInstance().activateTask(this, oldId);
|
||||
taskManager().activateTask(this, oldId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,29 @@ import java.util.Objects;
|
||||
|
||||
public abstract class AbstractTask implements Task {
|
||||
private final OffsetDateTime creationTime;
|
||||
/**
|
||||
* Injected task manager when constructed via DI.
|
||||
*/
|
||||
private final TaskManager taskManager;
|
||||
|
||||
public AbstractTask() {
|
||||
creationTime = OffsetDateTime.now();
|
||||
/**
|
||||
* Preferred constructor for DI: stores the shared {@link TaskManager} for insert/activate/remove.
|
||||
*
|
||||
* @param taskManager the application task manager, must not be {@code null}
|
||||
*/
|
||||
public AbstractTask(TaskManager taskManager) {
|
||||
Objects.requireNonNull(taskManager, "TaskManager must not be null");
|
||||
this.creationTime = OffsetDateTime.now();
|
||||
this.taskManager = taskManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the injected task manager.
|
||||
*
|
||||
* @return the task manager to use
|
||||
*/
|
||||
protected final TaskManager taskManager() {
|
||||
return taskManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -26,7 +46,7 @@ public abstract class AbstractTask implements Task {
|
||||
|
||||
@Override
|
||||
public void insert(CommandContext context) {
|
||||
TaskManager.getInstance().insert(context, this);
|
||||
taskManager().insert(context, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.core.tasks.impl;
|
||||
|
||||
import me.bobert.netneutrality.core.tasks.Task;
|
||||
import me.bobert.netneutrality.core.tasks.trait.WithButton;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import net.dv8tion.jda.api.components.buttons.Button;
|
||||
import net.dv8tion.jda.api.components.buttons.ButtonStyle;
|
||||
import net.dv8tion.jda.api.entities.emoji.Emoji;
|
||||
@@ -18,7 +19,8 @@ public abstract class ButtonTask extends GenericRandomIdTask implements WithButt
|
||||
private final String buttonLabel;
|
||||
private Emoji emoji;
|
||||
|
||||
public ButtonTask(ButtonStyle buttonStyle, String buttonLabel) {
|
||||
public ButtonTask(TaskManager taskManager, ButtonStyle buttonStyle, String buttonLabel) {
|
||||
super(taskManager);
|
||||
this.buttonStyle = buttonStyle;
|
||||
this.buttonLabel = buttonLabel;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.tasks.impl;
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -16,7 +17,8 @@ public abstract class EmojiTask extends AbstractPersistableTask {
|
||||
private final String channelId;
|
||||
private final String messageId;
|
||||
|
||||
public EmojiTask(String guildId, String channelId, String messageId) {
|
||||
public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) {
|
||||
super(taskManager);
|
||||
this.guildId = guildId;
|
||||
this.channelId = channelId;
|
||||
this.messageId = messageId;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
package me.bobert.netneutrality.core.tasks.impl;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
public abstract class GenericCustomIdTask extends AbstractTask {
|
||||
private final String customId;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
|
||||
public GenericCustomIdTask(String id) {
|
||||
this.customId = id;
|
||||
public abstract class GenericCustomIdTask extends AbstractTask {
|
||||
private final String id;
|
||||
|
||||
public GenericCustomIdTask(TaskManager taskManager, String id) {
|
||||
super(taskManager);
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return customId;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
package me.bobert.netneutrality.core.tasks.impl;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public abstract class GenericRandomIdTask extends GenericCustomIdTask {
|
||||
public GenericRandomIdTask() {
|
||||
super(UUID.randomUUID().toString());
|
||||
public GenericRandomIdTask(TaskManager taskManager) {
|
||||
super(taskManager, UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user