Compare commits
41
Commits
4cf7c47645
...
4b006956b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b006956b5 | ||
|
|
2293629f6f | ||
|
|
5b1df7038c | ||
|
|
88389f3db0 | ||
|
|
c2a24f0133 | ||
|
|
db86c133e6 | ||
|
|
44b284fde8 | ||
|
|
9812e6da41 | ||
|
|
30ff1602b2 | ||
|
|
470f5a33aa | ||
|
|
9cd1317659 | ||
|
|
27aa0a1d55 | ||
|
|
715c5a5fb9 | ||
|
|
cd83fcdae6 | ||
|
|
1dca5b898a | ||
|
|
a605a834b4 | ||
|
|
0e68886eb2 | ||
|
|
d53cbbecd4 | ||
|
|
6d62a38b0b | ||
|
|
22621e3912 | ||
|
|
88298380f8 | ||
|
|
0f96051fd0 | ||
|
|
e9c6f403e5 | ||
|
|
0145383dfd | ||
|
|
9c8da6ab7b | ||
|
|
41276c40f6 | ||
|
|
57b8e4669b | ||
|
|
60d7a66b42 | ||
|
|
a8b353ea73 | ||
|
|
35fc415d91 | ||
|
|
ffc15e8044 | ||
|
|
38bbf01e9f | ||
|
|
6abda9196b | ||
|
|
bceab4b2b9 | ||
|
|
677b3f8013 | ||
|
|
62de32ecfc | ||
|
|
05b4a2ab85 | ||
|
|
9f2e15a622 | ||
|
|
ffb1095f23 | ||
|
|
4277db3922 | ||
|
|
36c4acb13a |
No files matched your search
@@ -5,6 +5,7 @@ NETNEUTRALITY_BOT_PREFIX=$
|
|||||||
NETNEUTRALITY_BOT_OWNER=
|
NETNEUTRALITY_BOT_OWNER=
|
||||||
NETNEUTRALITY_BOT_INVITE=
|
NETNEUTRALITY_BOT_INVITE=
|
||||||
NETNEUTRALITY_API_PORT=9000
|
NETNEUTRALITY_API_PORT=9000
|
||||||
|
NETNEUTRALITY_API_KEY=
|
||||||
|
|
||||||
MYSQL_HOST=127.0.0.1
|
MYSQL_HOST=127.0.0.1
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
|
|||||||
+1
-1
@@ -33,12 +33,12 @@ hs_err_pid*
|
|||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
*.iws
|
*.iws
|
||||||
|
.junie/plans
|
||||||
|
|
||||||
# Mac
|
# Mac
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Maven
|
# Maven
|
||||||
log/
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
#log4j
|
#log4j
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260910-075746-nw8q
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
Refactor `CommandProcessor` to break its tightly coupled `dispatch()` method into composable, extensible pieces so that new preconditions, permission strategies, and result handlers can be added without modifying the processor itself.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
**In Scope**
|
|
||||||
- Decompose `CommandProcessor.dispatch()` into a pipeline of single-responsibility components
|
|
||||||
- Eliminate `instanceof` checks for `InteractionContext` / `SlashCommandContext` inside the processor
|
|
||||||
- Preserve all existing behavior and keep the existing test suite green
|
|
||||||
|
|
||||||
**Out of Scope**
|
|
||||||
- Changes to the `Command` interface or individual command implementations
|
|
||||||
- Changes to `CommandContext` subclass hierarchy
|
|
||||||
- Changes to `CommandContainer`, `CommandRegistry`, or `CommandExecutionResult`
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
`CommandProcessor.dispatch()` (~80 lines, `CommandProcessor.java:48-129`) performs **five concerns** sequentially:
|
|
||||||
1. **Command lookup** — resolve command from registry
|
|
||||||
2. **Precondition checks** — guild-only, slash-only, via `instanceof` checks against `InteractionContext`
|
|
||||||
3. **Permission check** — `canExecute()` with `instanceof SlashCommandContext`
|
|
||||||
4. **Usage/argument validation** — `checkUsage()` + `checkOptions()`, again branching on `instanceof InteractionContext`
|
|
||||||
5. **Execution & result handling** — execute command, handle TASK/ERROR/WARN/NOACTION results, catch exceptions
|
|
||||||
|
|
||||||
The `instanceof` checks (lines 66, 197, 220) tightly couple the processor to specific context subtypes, making it impossible to add new context types without modifying the processor.
|
|
||||||
|
|
||||||
### Key Decisions
|
|
||||||
1. **Introduce a `CommandPrecondition` interface** — each check (guild-only, slash-only, permissions) becomes a standalone, testable precondition that can be composed into an ordered list. This replaces the inline if-chains and eliminates instanceof checks from the processor.
|
|
||||||
2. **Move context-type-specific logic into the context itself** — instead of `instanceof` checks in the processor, add polymorphic methods to `CommandContext` (e.g., `supportsSlashOnly()`, `canSkipPermissionCheck()`, `validateOptions()`). Each context subclass overrides as needed.
|
|
||||||
3. **Extract result handling into a `ResultHandler`** — the if-chain for TASK/ERROR/WARN/NOACTION becomes a dedicated component, making it easy to add new result types.
|
|
||||||
4. **Keep changes incremental** — refactor in place without changing the public API of `CommandProcessor` (constructor + `dispatch()` remain the same). The pipeline is internal.
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
#### 1. `CommandPrecondition` interface
|
|
||||||
```java
|
|
||||||
// New file: core/command/precondition/CommandPrecondition.java
|
|
||||||
public interface CommandPrecondition {
|
|
||||||
/** Returns null if passed, or an error message to reply with. */
|
|
||||||
String check(CommandContext context, Command command);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Concrete implementations:
|
|
||||||
- `GuildOnlyPrecondition` — replaces lines 60-64
|
|
||||||
- `SlashCommandOnlyPrecondition` — replaces lines 66-69, uses `context.supportsSlashOnly()` instead of instanceof
|
|
||||||
- `PermissionPrecondition` — replaces `canExecute()` (lines 215-229), uses `context.canSkipPermissionCheck()` instead of instanceof
|
|
||||||
|
|
||||||
#### 2. Polymorphic methods on `CommandContext`
|
|
||||||
Add to `CommandContext`:
|
|
||||||
- `boolean isInteraction()` — default `false`, overridden to `true` in `InteractionContext`
|
|
||||||
- `boolean canSkipPermissionCheck()` — default `false`, overridden in `SlashCommandContext`
|
|
||||||
|
|
||||||
This eliminates all `instanceof` checks from the processor.
|
|
||||||
|
|
||||||
#### 3. `CommandResultHandler`
|
|
||||||
```java
|
|
||||||
// New file: core/command/CommandResultHandler.java
|
|
||||||
public class CommandResultHandler {
|
|
||||||
void handle(CommandContext context, CommandExecutionResult result, TaskManager taskManager);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Encapsulates the TASK/ERROR/WARN/NOACTION/message reply logic from lines 100-120.
|
|
||||||
|
|
||||||
#### 4. Refactored `CommandProcessor.dispatch()`
|
|
||||||
The method becomes:
|
|
||||||
```java
|
|
||||||
public void dispatch() {
|
|
||||||
CommandContainer container = resolveCommand();
|
|
||||||
if (container == null) return;
|
|
||||||
|
|
||||||
Command command = container.getCommand();
|
|
||||||
context.setCommand(command);
|
|
||||||
configureSentry(command);
|
|
||||||
|
|
||||||
for (CommandPrecondition precondition : preconditions) {
|
|
||||||
String error = precondition.check(context, command);
|
|
||||||
if (error != null) { context.replyEphemeral(error); return; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usageValidator.validate(context, container)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
CommandExecutionResult result = command.execute(context);
|
|
||||||
resultHandler.handle(context, result, taskManager);
|
|
||||||
} catch (...) { /* existing error handling */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Architecture Diagram
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
D[dispatch] --> R[resolveCommand]
|
|
||||||
R --> P[Preconditions Loop]
|
|
||||||
P --> GuildOnly
|
|
||||||
P --> SlashOnly
|
|
||||||
P --> Permission
|
|
||||||
P --> UV[UsageValidator]
|
|
||||||
UV --> E[command.execute]
|
|
||||||
E --> RH[ResultHandler]
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `core/command/precondition/CommandPrecondition.java` | **New** — interface |
|
|
||||||
| `core/command/precondition/GuildOnlyPrecondition.java` | **New** |
|
|
||||||
| `core/command/precondition/SlashCommandOnlyPrecondition.java` | **New** |
|
|
||||||
| `core/command/precondition/PermissionPrecondition.java` | **New** |
|
|
||||||
| `core/command/CommandResultHandler.java` | **New** |
|
|
||||||
| `core/command/UsageValidator.java` | **New** — extracted from `checkUsage()`/`checkOptions()` |
|
|
||||||
| `core/command/CommandProcessor.java` | **Modified** — delegate to above components |
|
|
||||||
| `core/context/CommandContext.java` | **Modified** — add `isInteraction()`, `canSkipPermissionCheck()` |
|
|
||||||
| `core/context/InteractionContext.java` | **Modified** — override `isInteraction()` |
|
|
||||||
| `context/command/SlashCommandContext.java` | **Modified** — override `canSkipPermissionCheck()` |
|
|
||||||
| `CommandProcessorTest.java` | **Modified** — update construction, add unit tests for new components |
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
- **Behavioral regression** — the refactor must preserve exact reply messages and ordering. Mitigated by the existing 1000+ line test suite.
|
|
||||||
- **Precondition ordering matters** — guild-only must run before permission check. The list order is explicit and documented.
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
|
|
||||||
### Validation Approach
|
|
||||||
- All existing `CommandProcessorTest` tests must pass unchanged (behavior preservation).
|
|
||||||
- New unit tests for each precondition, the result handler, and the usage validator in isolation.
|
|
||||||
|
|
||||||
### Key Scenarios
|
|
||||||
- Preconditions short-circuit in order (guild-only before permissions)
|
|
||||||
- Custom precondition can be added to the list and is invoked
|
|
||||||
- ResultHandler correctly dispatches TASK to TaskManager, logs ERROR/WARN
|
|
||||||
- UsageValidator handles all subcommand/group/option combinations
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
- Null command from container
|
|
||||||
- Empty precondition list (all checks pass)
|
|
||||||
- InteractionContext skips option validation in UsageValidator
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Add polymorphic methods to CommandContext hierarchy
|
|
||||||
CommandContext subclasses expose their capabilities without requiring instanceof checks.
|
|
||||||
|
|
||||||
- Add `isInteraction()` (default `false`) and `canSkipPermissionCheck()` (default `false`) to `CommandContext.java`
|
|
||||||
- Override `isInteraction()` to return `true` in `InteractionContext.java`
|
|
||||||
- Override `canSkipPermissionCheck()` to return `true` in `SlashCommandContext.java`
|
|
||||||
- Verify existing tests still compile and pass
|
|
||||||
|
|
||||||
### ✓ Step 2: Extract preconditions into CommandPrecondition interface and implementations
|
|
||||||
Precondition checks become standalone, composable components.
|
|
||||||
|
|
||||||
- Create `CommandPrecondition` interface in `core/command/precondition/`
|
|
||||||
- Implement `GuildOnlyPrecondition` — checks `command.isGuildOnly()` and `context.isFromGuild()`
|
|
||||||
- Implement `SlashCommandOnlyPrecondition` — checks `command.isSlashCommandOnly()` and `context.isInteraction()`
|
|
||||||
- Implement `PermissionPrecondition` — replaces `canExecute()`, uses `context.canSkipPermissionCheck()` instead of instanceof
|
|
||||||
- Add unit tests for each precondition
|
|
||||||
|
|
||||||
### ✓ Step 3: Extract UsageValidator and CommandResultHandler
|
|
||||||
Usage validation and result handling become dedicated classes.
|
|
||||||
|
|
||||||
- Extract `checkUsage()` and `checkOptions()` into `UsageValidator.java`, replacing `instanceof InteractionContext` with `context.isInteraction()`
|
|
||||||
- Extract result-handling if-chain (TASK/ERROR/WARN/NOACTION + reply) into `CommandResultHandler.java`
|
|
||||||
- Add unit tests for both classes
|
|
||||||
|
|
||||||
### ✓ Step 4: Refactor CommandProcessor to use the new components
|
|
||||||
CommandProcessor.dispatch() becomes a thin orchestrator delegating to the extracted components.
|
|
||||||
|
|
||||||
- Replace inline precondition checks with a `List<CommandPrecondition>` loop
|
|
||||||
- Replace `checkUsage()`/`checkOptions()` calls with `UsageValidator`
|
|
||||||
- Replace result-handling if-chain with `CommandResultHandler`
|
|
||||||
- Remove the now-unused private methods (`canExecute`, `checkUsage`, `checkOptions`)
|
|
||||||
- Keep constructor backward-compatible or provide a builder/factory for custom precondition lists
|
|
||||||
- Run full `CommandProcessorTest` suite to confirm behavioral equivalence
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260826-092009-17wb
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
|
|
||||||
Replace the singleton/static-heavy bootstrap of the bot with an explicit, deterministic **lifecycle-managed application** driven by a small hand-rolled `ApplicationContext` and a `Lifecycle` contract. Dependencies are wired through **constructors** (including for reflectively-loaded commands and tasks), and **interfaces** are introduced for the key services so the code becomes testable, maintainable, and mockable — without adopting Spring Boot or any DI framework.
|
|
||||||
|
|
||||||
The current design (documented in `docs/architecture.md`) uses:
|
|
||||||
- `NetNeutrality` as a static singleton holding `instance` and `jda`, with a manual, order-sensitive `startup()`/`shutdown()` chain.
|
|
||||||
- Static singletons everywhere: `Settings.getInstance()`, `SessionManager` (static `getSessionFactory`/`shutdown`), `CommandManager` (static `init`/maps), `TaskManager.getInstance()`, `MusicManager` (static factory + static `playerManager`), `ThreadManager` (static executors), `ScheduledTaskManager` (static), `GuildService.get(...)` (static cache).
|
|
||||||
- `ReflectionHelper` instantiating commands/tasks via **no-arg constructors**, which forces them to reach out to singletons.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
**In Scope**
|
|
||||||
- A `Lifecycle` interface (`start()`/`stop()`/`priority()`) and an `ApplicationContext` that owns instances, wires them, and drives ordered startup/shutdown.
|
|
||||||
- Converting the core singletons to constructor-injected **instances**.
|
|
||||||
- Introducing **interfaces for key services** (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider) — decision confirmed with the user.
|
|
||||||
- Reworking `ReflectionHelper` + command/task base classes to use **constructor injection** (decision confirmed).
|
|
||||||
- Symmetric, deterministic startup/shutdown replacing `NetNeutrality.startup()/shutdown()` and the JVM shutdown hook.
|
|
||||||
- **Reorganizing the package layout** into `bootstrap` / `framework` / `platform` / `feature` / `data` / `shared` (see the Package Structure tab), done in the same effort as the DI refactor so imports are touched once. This includes renaming the entry point `NetNeutrality` → `App`.
|
|
||||||
|
|
||||||
**Out of Scope**
|
|
||||||
- Introducing Spring Boot, Guice, or Dagger (explicitly rejected — manual context chosen).
|
|
||||||
- Full interface-per-service/repository abstraction (only key services get interfaces).
|
|
||||||
- Dropping the `O*`/`G*` entity name prefixes (optional cosmetic churn, deferred).
|
|
||||||
- Behavioral/feature changes to commands, tasks, music, or the web API — this is a structural refactor only.
|
|
||||||
- Database schema / Flyway migration changes.
|
|
||||||
|
|
||||||
### User Stories
|
|
||||||
- As a maintainer, I want services created and wired in one explicit place so the dependency graph and startup order are obvious and not implicit.
|
|
||||||
- As a maintainer, I want symmetric `start()`/`stop()` for every component so shutdown reliably tears down what startup created, in reverse order.
|
|
||||||
- As a developer, I want commands/tasks to receive their dependencies via constructor so they no longer call `getInstance()` and can be unit-tested with mocks.
|
|
||||||
- As a developer, I want key services behind interfaces so I can mock them in tests (building on the existing JUnit 5 + Mockito setup).
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
- The bot must start and shut down with identical externally-observable behavior to today (same ordered steps from `docs/architecture.md`).
|
|
||||||
- Startup order must be preserved: persistence/session → migrations → executors → command/task registries → music sources → JDA → web API → presence.
|
|
||||||
- Shutdown must run in reverse of startup: cancel scheduled tasks → disconnect music players → shut down executors → shut down JDA → release the Hibernate registry.
|
|
||||||
- Commands and tasks discovered by `ReflectionHelper` must continue to auto-load ("drop a class in the package" model) but now receive dependencies via constructor.
|
|
||||||
- No public command/task behavior changes.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
- **No new runtime dependencies** (manual container only).
|
|
||||||
- **Incremental & low-risk**: migrate one area at a time using temporary static shims that delegate to the context, so the build stays green throughout.
|
|
||||||
- **Compatibility**: fat-JAR packaging and `main` entry point (`me.bobert.netneutrality.NetNeutrality`) remain unchanged.
|
|
||||||
- **Testability**: instance + interface-based services must be mockable with Mockito.
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
|
|
||||||
- **Bootstrap** — `NetNeutrality` (`src/main/java/me/bobert/netneutrality/NetNeutrality.java`): static `instance`, static `jda`, `main()` builds `Settings` implicitly, constructor calls `startup()` which chains `SessionManager.getSessionFactory()` → `DatabaseMigration.migrate()` → `CommandManager.init()` → `MusicManager.initPlayerManager()` → `TaskManager.getInstance().init()` → `initJDA()` → `CommandManager.registerSlashCommands(jda)` → `new EventsHandler()` → `new WebApi(port)` → presence → shutdown hook. `shutdown()` runs `ScheduledTaskManager.cancelAll()` → `MusicManager.disconnectPlayers()` → `ThreadManager.shutdownExecutor()` → `disconnect()` → `SessionManager.shutdown()`.
|
|
||||||
- **Singletons/statics**: `Settings` (`utils/Settings.java`), `SessionManager` (`core/db/SessionManager.java`, all-static), `CommandManager` (`core/command/CommandManager.java`, static `commands` map + static `init`), `TaskManager` (`core/tasks/TaskManager.java`, `getInstance()`), `MusicManager` (`music/MusicManager.java`, static `managers` map + static `playerManager` + static `get`/`initPlayerManager`/`disconnectPlayers`), `ThreadManager` (`core/thread/ThreadManager.java`, static executors), `ScheduledTaskManager`, `GuildService` (`db/service/GuildService.java`, static `services` cache).
|
|
||||||
- **Reflective instantiation** — `ReflectionHelper.loadClassesFromPackage(...)` calls `s.getConstructor().newInstance()` (no-arg), used by `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()`.
|
|
||||||
- **Consumers of singletons** — ~110 call sites across commands (`command/**`), tasks (`tasks/**`), contexts (`context/**`, `core/context/**`), HTTP handlers (`http/server/**`), `EventsHandler`, repositories/services (`db/**`), etc.
|
|
||||||
|
|
||||||
### Key Decisions (confirmed with user)
|
|
||||||
1. **Manual `ApplicationContext` + `Lifecycle`** — no DI framework; the context owns instances, wires them via constructors, and drives ordered `start()`/`stop()`.
|
|
||||||
2. **Constructor injection for commands/tasks** — `ReflectionHelper` resolves a constructor that accepts the context (or specific dependencies) and passes them in; commands/tasks store deps as final fields instead of calling `getInstance()`.
|
|
||||||
3. **Lifecycle + key service interfaces** — introduce a `Lifecycle` interface plus interfaces for the main services (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider). Repositories and minor helpers stay concrete.
|
|
||||||
4. **Package restructure to `bootstrap`/`framework`/`platform`/`feature`/`data`/`shared`** — separate code by reason-of-change (engine vs. cross-cutting infra vs. concrete features vs. persistence model vs. leaf helpers), consolidate each feature vertical (e.g. music) into one package, and dissolve the catch-all `utils`. Performed right after the DI wiring, via the IDE's safe "Move Class" refactor, so the ~110 import updates happen once. Full details in the Package Structure tab.
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
**1. Lifecycle contract** — new `me.bobert.netneutrality.bootstrap.Lifecycle`:
|
|
||||||
```java
|
|
||||||
public interface Lifecycle {
|
|
||||||
void start() throws Exception;
|
|
||||||
default void stop() {}
|
|
||||||
default int priority() { return 0; } // lower = started earlier, stopped later
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Application context** — new `me.bobert.netneutrality.bootstrap.ApplicationContext`:
|
|
||||||
```java
|
|
||||||
public final class ApplicationContext {
|
|
||||||
private final List<Lifecycle> components = new ArrayList<>();
|
|
||||||
public <T> T register(T component) {
|
|
||||||
if (component instanceof Lifecycle l) components.add(l);
|
|
||||||
return component;
|
|
||||||
}
|
|
||||||
public void start() throws Exception {
|
|
||||||
components.sort(Comparator.comparingInt(Lifecycle::priority));
|
|
||||||
for (Lifecycle c : components) c.start();
|
|
||||||
}
|
|
||||||
public void stop() {
|
|
||||||
var reversed = new ArrayList<>(components);
|
|
||||||
Collections.reverse(reversed);
|
|
||||||
reversed.forEach(Lifecycle::stop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
The context also exposes typed getters for wired services so `ReflectionHelper`/`EventsHandler`/contexts can resolve dependencies.
|
|
||||||
|
|
||||||
**3. Convert singletons to instances behind interfaces** (priority values in parentheses set start order):
|
|
||||||
- `SettingsProvider` ← `Settings` (config; constructed first, not itself a `Lifecycle`).
|
|
||||||
- `SessionProvider`/persistence ← `SessionManager` instance implementing `Lifecycle` (10): `start()` builds the `SessionFactory` + runs `DatabaseMigration.migrate()`; `stop()` destroys the registry.
|
|
||||||
- `ExecutorProvider` ← `ThreadManager` instance implementing `Lifecycle` (20): `stop()` = current `shutdownExecutor()`.
|
|
||||||
- `CommandRegistry` ← `CommandManager` instance implementing `Lifecycle` (30): `start()` = `init()`; `registerSlashCommands(jda)` called after JDA is ready.
|
|
||||||
- `TaskManager` interface ← impl implementing `Lifecycle` (30): `start()` = current `init()`.
|
|
||||||
- Scheduled tasks ← `ScheduledTaskManager` instance implementing `Lifecycle`: `stop()` = `cancelAll()`.
|
|
||||||
- `MusicService` ← new factory owning the shared `AudioPlayerManager` + per-guild `MusicManager` map, implementing `Lifecycle` (40): `start()` = `initPlayerManager()`; `stop()` = `disconnectPlayers()`. Per-guild `MusicManager` becomes a plain instance created by the factory.
|
|
||||||
- `GuildServiceProvider` ← registry owning the guild cache (replaces static `GuildService.get`).
|
|
||||||
- `WebApi` becomes a `Lifecycle` (60): `start()` binds the HTTP server; `stop()` stops it.
|
|
||||||
- JDA is built by the context (replacing static `jda`) and exposed via a getter/provider.
|
|
||||||
|
|
||||||
**4. Rework reflective instantiation** — extend `ReflectionHelper` so `loadClassesFromPackage` accepts a factory/`ApplicationContext` and instantiates via a constructor that takes the needed dependencies (falling back to no-arg for classes that declare it). `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` pass the context so commands/tasks are constructor-injected.
|
|
||||||
|
|
||||||
**5. Migrate consumers off `getInstance()`** — command/task base classes (`core/tasks/impl/AbstractTask.java`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand.java`) and contexts (`core/context/AbstractContext.java`, `context/**`) take dependencies via constructor. `EventsHandler` is constructed with the context and passes services when building contexts. During migration, keep **temporary static shims** (e.g. `Settings.getInstance()` delegating to the context instance) so untouched call sites keep compiling; remove them at the end.
|
|
||||||
|
|
||||||
**6. New bootstrap** — `NetNeutrality.main()` builds the `ApplicationContext`, constructs and `register()`s services in dependency order, calls `context.start()`, and installs **one** shutdown hook calling `context.stop()`. Static `instance`/`jda` are removed.
|
|
||||||
|
|
||||||
### Data Models / Contracts
|
|
||||||
Key new interfaces (illustrative signatures):
|
|
||||||
```java
|
|
||||||
interface SettingsProvider { String get(String key); String get(String key, String def); String getPrefix(); }
|
|
||||||
interface SessionProvider extends Lifecycle { org.hibernate.SessionFactory getSessionFactory(); }
|
|
||||||
interface ExecutorProvider extends Lifecycle { java.util.concurrent.ExecutorService getExecutor(String name); java.util.concurrent.ScheduledExecutorService getScheduledExecutor(); }
|
|
||||||
interface CommandRegistry extends Lifecycle { CommandContainer getCommandContainer(String name); void registerSlashCommands(JDA jda); }
|
|
||||||
interface TaskManager extends Lifecycle { Task getTask(String id); void insert(CommandContext ctx, Task task); void handle(TaskContext ctx); /* ... */ }
|
|
||||||
interface MusicService extends Lifecycle { MusicManager get(net.dv8tion.jda.api.entities.Guild guild); }
|
|
||||||
interface GuildServiceProvider { GuildService get(String guildId); }
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
New DI classes are created directly in their **target** packages (from the Package Structure tab) so they are not moved twice.
|
|
||||||
- **Added**: `bootstrap/Lifecycle.java`, `bootstrap/ApplicationContext.java`; service interfaces `platform/config/SettingsProvider.java`, `platform/persistence/SessionProvider.java`, `platform/concurrency/ExecutorProvider.java`, `framework/command/CommandRegistry.java`, `framework/task/TaskManager.java` [interface] + impl, `feature/music/MusicService.java`, `data/service/GuildServiceProvider.java`.
|
|
||||||
- **Moved / renamed (package restructure)**: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data`; `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`; `utils` dissolved into `platform.config` / `platform.persistence` / `bootstrap` / `shared.util`; `NetNeutrality` → `App`. See the Package Structure tab.
|
|
||||||
- **Modified**: `App.java` (bootstrap), `ReflectionHelper.java` (constructor injection), all converted managers/services, command/task base classes, `EventsHandler.java`, `WebApi.java`, HTTP handlers, `getInstance()` call sites, and `pom.xml` / `Dockerfile` / `startup.sh` for the `mainClass` rename.
|
|
||||||
- **Docs**: update `docs/architecture.md` (startup/shutdown + package map) and `docs/utilities-and-bootstrap.md` (bootstrap) to reflect the context and new layout.
|
|
||||||
|
|
||||||
### Architecture Diagram
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
Main["NetNeutrality.main()"] --> Ctx[ApplicationContext]
|
|
||||||
Ctx -->|constructs & wires| Settings[SettingsProvider]
|
|
||||||
Ctx -->|register + start/stop| Session[SessionProvider]
|
|
||||||
Ctx -->|register + start/stop| Exec[ExecutorProvider]
|
|
||||||
Ctx -->|register + start/stop| Cmd[CommandRegistry]
|
|
||||||
Ctx -->|register + start/stop| Tasks[TaskManager]
|
|
||||||
Ctx -->|register + start/stop| Music[MusicService]
|
|
||||||
Ctx -->|register + start/stop| Web[WebApi]
|
|
||||||
Ctx -->|builds| JDA[JDA]
|
|
||||||
Cmd -->|constructor-injects| Refl[ReflectionHelper]
|
|
||||||
Tasks -->|constructor-injects| Refl
|
|
||||||
Refl -->|ctor deps| Impl[Commands / Tasks]
|
|
||||||
JDA -->|events| Events[EventsHandler]
|
|
||||||
Events -->|builds contexts with deps| Cmd
|
|
||||||
Events --> Tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
- **Large call-site surface (~110 usages)** — mitigated by temporary static shims delegating to context instances, migrating area-by-area, keeping the build green.
|
|
||||||
- **Startup ordering regressions** — mitigated by encoding order via `Lifecycle.priority()` and mirroring the exact sequence from `docs/architecture.md`.
|
|
||||||
- **Reflective constructor resolution** — commands/tasks must expose a consistent injectable constructor; mitigate by supporting a no-arg fallback and validating all classes load at startup.
|
|
||||||
- **Per-guild `MusicManager` vs shared factory** — clarify that the shared `AudioPlayerManager` and cache move to the `MusicService`, while per-guild `MusicManager` stays a lightweight instance.
|
|
||||||
- **`SessionManager.System.exit(1)` on DB failure** — preserve fail-fast behavior inside the new `SessionProvider.start()`.
|
|
||||||
|
|
||||||
# Package Structure
|
|
||||||
|
|
||||||
### Current Layout Problems
|
|
||||||
The tree today mixes three organizing principles at once: abstraction-vs-impl (`core.command`↔`command`, `core.context`↔`context`, `core.tasks`↔`tasks`), infra-vs-data (`core.db`↔`db`), and no split at all (`music`, `http`, `utils`, `hooks`). Consequences:
|
|
||||||
- `core` is simultaneously "the engine" and "misc" — it holds the framework sub-packages **and** loose classes (`LogHandler`, `BotState`).
|
|
||||||
- A single feature is scattered: music lives in `core.command.impl.MusicCommand`, `command.music`, `context.MusicChannelContext`, `tasks.music`, and `music/` — five places.
|
|
||||||
- `utils` is a catch-all mixing config (`Settings`), reflection (`ReflectionHelper`), JPA glue (`JpaUtils`), and formatting (`MessageFunction`, `UserFunction`).
|
|
||||||
- Naming drift: `db.entities` (plural) vs. the singular convention elsewhere.
|
|
||||||
|
|
||||||
### Guiding Principle
|
|
||||||
Separate code by the reason it changes:
|
|
||||||
- **framework** — the reusable bot engine (command/task/context machinery); rarely changes, knows nothing about specific commands.
|
|
||||||
- **platform** — cross-cutting technical services (persistence, concurrency, config, logging, event dispatch, web); changes when tech/tooling changes.
|
|
||||||
- **feature** — concrete commands + tasks + contexts + domain logic, grouped **by feature vertical**; changes most often.
|
|
||||||
- **data** — the persistence model (entities, repositories, services).
|
|
||||||
- **shared** — genuinely cross-feature leaf helpers.
|
|
||||||
|
|
||||||
### Target Layout
|
|
||||||
```
|
|
||||||
me.bobert.netneutrality
|
|
||||||
├── App.java // was NetNeutrality (main + bootstrap entry)
|
|
||||||
├── bootstrap/ // ApplicationContext, Lifecycle, ReflectionHelper (composition root)
|
|
||||||
├── framework/ // the reusable engine (was core, minus the misc)
|
|
||||||
│ ├── command/ // Command, CommandProcessor, CommandRegistry, options, build, impl bases
|
|
||||||
│ ├── context/ // Context hierarchy, reply/
|
|
||||||
│ └── task/ // Task, TaskManager, StepTask/PersistableTask, persistence/, trait/, impl bases
|
|
||||||
├── platform/ // cross-cutting technical services (horizontals)
|
|
||||||
│ ├── config/ // Settings / SettingsProvider
|
|
||||||
│ ├── persistence/ // SessionProvider, DatabaseMigration, Repository base, JpaUtils
|
|
||||||
│ ├── concurrency/ // ExecutorProvider, ScheduledTaskManager, BaseThreadFactory
|
|
||||||
│ ├── logging/ // LogHandler
|
|
||||||
│ ├── event/ // EventsHandler (was hooks)
|
|
||||||
│ └── web/ // WebApi, handlers, WebModule (was http)
|
|
||||||
├── feature/ // concrete features, one vertical per package
|
|
||||||
│ ├── music/ // MusicService/MusicManager, QueueManager, scheduler, source/, + music commands/tasks/contexts
|
|
||||||
│ ├── moderation/ // blacklist, purge, admin commands + related services
|
|
||||||
│ ├── reaction/ // reaction commands + emoji-role tasks
|
|
||||||
│ ├── git/ // report-issue command + task
|
|
||||||
│ ├── crypto/
|
|
||||||
│ └── help/
|
|
||||||
├── data/ // the persistence model (was the "db" half of the split)
|
|
||||||
│ ├── entity/ // O* entities (entities → entity, singular)
|
|
||||||
│ ├── repository/
|
|
||||||
│ └── service/ // GuildService, GuildServiceProvider, BlacklistService, …
|
|
||||||
├── shared/util/ // MiscUtils, MessageFunction, UserFunction, GuildUtils, ContextUtils
|
|
||||||
└── exceptions/ // unchanged (already coherent)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Moves
|
|
||||||
- `core` → `framework`, now containing **only** the engine (no `LogHandler`, no `BotState`, no `db`, no `thread`).
|
|
||||||
- `core.db` (infra) → `platform.persistence`; `db` (model) → `data` — resolves the "`db` means two things" problem. `db.entities` → `data.entity`.
|
|
||||||
- `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
|
|
||||||
- `utils` dissolved: config → `platform.config`, JPA glue → `platform.persistence`, reflection → `bootstrap`, formatting/misc → `shared.util`.
|
|
||||||
- All music code consolidates under `feature.music`; the other verticals group their command/task/context/service triplets similarly.
|
|
||||||
- `LogHandler` → `platform.logging`; `BotState` → `bootstrap` (composition-root-adjacent state).
|
|
||||||
|
|
||||||
### Sequencing & Rationale
|
|
||||||
The repackage is done **immediately after the DI wiring, in the same effort**: the DI refactor already renames managers (`CommandManager`→`CommandRegistry`, `SessionManager`→`SessionProvider`, `ThreadManager`→`ExecutorProvider`) and touches imports at ~110 call sites, so combining them avoids editing the same imports twice. Moves use the IDE's safe **"Move Class"** refactor (references update automatically), one subsystem at a time, building after each. New DI classes are created directly in their target packages rather than moved later.
|
|
||||||
|
|
||||||
### Lower-Risk Variant (optional fallback)
|
|
||||||
If full feature-first grouping feels too large in one pass, a high-value subset fixes ~80% of the confusion with far fewer moves: rename `core` → `framework`, extract `platform` (evict `LogHandler`, `BotState`, `core.db`, `core.thread`, `hooks`, `http`), and dissolve `utils` — while leaving the concrete `command`/`task`/`context` packages where they are. The trade-off is that a single feature stays spread across those layer packages.
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
- **Move touches nearly every import** — mitigated by IDE "Move Class" (auto-updates references), one subsystem per step, and a build after each move.
|
|
||||||
- **Main-class rename `NetNeutrality` → `App`** — must update the Shade `mainClass` in `pom.xml`, the `Dockerfile`/`startup.sh` entry, and docs atomically, or the fat JAR won't launch.
|
|
||||||
- **Feature-first churn** — grouping music/moderation/etc. is the largest move; the lower-risk variant above is the fallback if needed.
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
|
|
||||||
### Validation Approach
|
|
||||||
The refactor is behavior-preserving, so validation focuses on: (a) the project compiles and the fat JAR builds after each stage, (b) existing tests still pass, and (c) new unit tests prove services are now mockable/instance-based. Use the existing Maven + JUnit 5 + Mockito setup (`./mvnw test`); Checkstyle runs in the `test` phase and must stay green.
|
|
||||||
|
|
||||||
### Key Scenarios
|
|
||||||
- **Build & Checkstyle** — `./mvnw package` succeeds after every stage (shade fat JAR, main class unchanged).
|
|
||||||
- **ApplicationContext ordering** — a unit test registers fake `Lifecycle` components with different `priority()` values and asserts `start()` runs ascending and `stop()` runs descending.
|
|
||||||
- **Command/task loading** — assert `CommandManager`/`TaskManager` still discover and instantiate all implementations via `ReflectionHelper` with constructor injection (no `getInstance()` calls at runtime).
|
|
||||||
- **Existing tests** — `CommandProcessorTest`, `SettingsTest`, and any others continue to pass; update them to use injected instances/mocks instead of wrestling with singletons.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
- Missing `NETNEUTRALITY_BOT_TOKEN` → startup still fails fast with a clear error.
|
|
||||||
- DB connection failure inside `SessionProvider.start()` → preserves current fail-fast (Sentry capture in non-dev + exit).
|
|
||||||
- A command/task class that only declares a no-arg constructor → `ReflectionHelper` still loads it via the fallback path.
|
|
||||||
- Shutdown with services that never fully started → `stop()` must be null-safe (mirroring current guards like `jda != null`, `registry != null`).
|
|
||||||
|
|
||||||
### Test Changes
|
|
||||||
- **Add**: `ApplicationContextTest` (ordering + symmetric start/stop), a `ReflectionHelper` constructor-injection test.
|
|
||||||
- **Update**: `SettingsTest` and `CommandProcessorTest` to construct instances/mocks of the new interfaces instead of relying on static singletons.
|
|
||||||
- **Skip**: no integration test against a live Discord gateway or real MySQL (out of scope).
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Introduce Lifecycle contract and ApplicationContext bootstrap
|
|
||||||
The app boots through an `ApplicationContext` that drives ordered start/stop, while behavior stays identical via temporary shims.
|
|
||||||
|
|
||||||
- Add `me.bobert.netneutrality.bootstrap.Lifecycle` interface with `start()`, `stop()` (default no-op), and `priority()` (default 0, lower = started earlier / stopped later).
|
|
||||||
- Add `me.bobert.netneutrality.bootstrap.ApplicationContext` that registers components, sorts by `priority()`, starts ascending and stops descending, and exposes typed getters for wired services.
|
|
||||||
- Refactor `NetNeutrality.main()`/`startup()` to build the context and register the existing (still static) steps as thin `Lifecycle` adapters, install a single shutdown hook calling `context.stop()`, and remove static `instance`/`jda` (temporarily bridged where needed).
|
|
||||||
- Add `ApplicationContextTest` asserting ascending start and descending stop ordering.
|
|
||||||
|
|
||||||
### ✓ Step 2: Convert foundational services to instances behind interfaces
|
|
||||||
Configuration, persistence, and executors are constructor-injected instances behind interfaces, wired into the context with correct lifecycle order.
|
|
||||||
|
|
||||||
- Introduce `SettingsProvider` implemented by `Settings` (instance, constructed first); keep `Settings.getInstance()` as a temporary shim delegating to the context instance.
|
|
||||||
- Convert `core/db/SessionManager` into a `SessionProvider` instance implementing `Lifecycle` (priority ~10): `start()` builds the `SessionFactory` and runs `DatabaseMigration.migrate()`, `stop()` destroys the registry; preserve fail-fast on DB errors.
|
|
||||||
- Convert `core/thread/ThreadManager` into an `ExecutorProvider` instance implementing `Lifecycle` (priority ~20) with `stop()` = current `shutdownExecutor()`.
|
|
||||||
- Update `JpaUtils`/`Repository`/`db/**` call sites andhihims to use the injected providers.
|
|
||||||
|
|
||||||
### ✓ Step 3: Convert domain managers (command, task, scheduled, music, guild) to instances behind interfaces
|
|
||||||
The command/task/music/guild managers are instance-based `Lifecycle` components behind interfaces, registered in the context.
|
|
||||||
|
|
||||||
- Introduce `CommandRegistry` (impl = `CommandManager`, priority ~30): `start()` = `init()`; `registerSlashCommands(jda)` invoked after JDA is ready.
|
|
||||||
- Introduce a `TaskManager` interface + impl (priority ~30) implementing `Lifecycle` with `start()` = current `init()`; convert `ScheduledTaskManager` to an instance whose `stop()` = `cancelAll()`.
|
|
||||||
- Introduce `MusicService` factory owning the shared `AudioPlayerManager` and per-guild `MusicManager` map (priority ~40): `start()` = `initPlayerManager()`, `stop()` = `disconnectPlayers()`; make per-guild `MusicManager` a plain instance created by the factory.
|
|
||||||
- Introduce `GuildServiceProvider` owning the guild cache, replacing static `GuildService.get(...)`.
|
|
||||||
- Keep static shims on each so untouched call sites keep compiling.
|
|
||||||
|
|
||||||
### ✓ Step 4: Enable constructor injection for reflectively-loaded commands and tasks
|
|
||||||
Commands and tasks receive their dependencies via constructor instead of calling `getInstance()`.
|
|
||||||
|
|
||||||
- Extend `ReflectionHelper.loadClassesFromPackage` to accept the `ApplicationContext` and instantiate via a constructor taking the required dependencies, with a no-arg fallback for classes that declare it.
|
|
||||||
- Update `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` to pass the context.
|
|
||||||
- Update command/task base classes (`core/tasks/impl/AbstractTask`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand`) and contexts (`core/context/AbstractContext`, `context/**`, `EventsHandler`) to store injected dependencies as final fields.
|
|
||||||
- Add a `ReflectionHelper` test proving constructor injection and no-arg fallback both work.
|
|
||||||
|
|
||||||
### ✓ Step 5: Remove static shims, finalize interfaces, and update tests/docs
|
|
||||||
All singletons/`getInstance()` accessors are gone; services are wired only through the context, and tests/docs reflect the new model.
|
|
||||||
|
|
||||||
- Migrate remaining `getInstance()`/static call sites (commands, tasks, HTTP handlers in `http/server/**`, `LogHandler`, `BotState`) to injected interfaces and delete the temporary static shims and singleton fields.
|
|
||||||
- Convert `WebApi` into a `Lifecycle` component (priority ~60) with `start()`/`stop()` for the HTTP server.
|
|
||||||
- Update `SettingsTest` and `CommandProcessorTest` to use injected instances/mocks of the new interfaces; ensure `./mvnw package` (including Checkstyle) is green.
|
|
||||||
- Update `docs/architecture.md` (startup/shutdown lifecycle) and `docs/utilities-and-bootstrap.md` (bootstrap) to describe the `ApplicationContext` + `Lifecycle` design.
|
|
||||||
|
|
||||||
### ✓ Step 6: Reorganize packages into bootstrap/framework/platform/feature/data/shared
|
|
||||||
The codebase is regrouped by reason-of-change into the target layout (see the Package Structure tab), with all references updated and the build green.
|
|
||||||
|
|
||||||
- Rename the entry point `NetNeutrality` → `App`, and update the Shade `mainClass` in `pom.xml`, plus the `Dockerfile`/`startup.sh` launch entry.
|
|
||||||
- Using the IDE's safe "Move Class" refactor, one subsystem per step: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data` (`db.entities` → `data.entity`); `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
|
|
||||||
- Dissolve `utils`: `Settings`/`SettingsProvider` → `platform.config`, `JpaUtils` → `platform.persistence`, `ReflectionHelper` → `bootstrap`, formatting/misc helpers → `shared.util`; move `LogHandler` → `platform.logging` and `BotState` → `bootstrap`.
|
|
||||||
- Consolidate each feature vertical (music, moderation, reaction, git, crypto, help) into `feature.*`, pulling its commands/tasks/contexts/services together.
|
|
||||||
- Build and run tests after each subsystem move; update `docs/architecture.md` "Package map" to the new layout.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260905-173932-36te
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
Improve the `MusicManager` class for better maintainability, testability, and separation of concerns.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
**In Scope:**
|
|
||||||
- Refactor `MusicManager` to reduce responsibilities
|
|
||||||
- Extract vote-skip logic into a dedicated service
|
|
||||||
- Fix synchronization strategy
|
|
||||||
- Improve error handling
|
|
||||||
- Eliminate magic numbers
|
|
||||||
|
|
||||||
**Out of Scope:**
|
|
||||||
- Changing `QueueManager` or `TrackScheduler` internals (beyond reducing circular refs)
|
|
||||||
- Changing external behavior or command interfaces
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Issues
|
|
||||||
|
|
||||||
1. **Vote-skip business logic in MusicManager** (lines 113–145): The `voteSkip()` method contains complex voting logic (threshold calculation, member counting, skip decisions) that doesn't belong in a player manager. Magic number `2.1` is used as a vote divisor with no explanation.
|
|
||||||
|
|
||||||
2. **Circular dependencies**: `MusicManager` → `TrackScheduler` → `QueueManager` → `TrackScheduler` → `MusicManager`. This makes testing and reasoning about the code difficult.
|
|
||||||
|
|
||||||
3. **Overly broad `synchronized` methods**: Nearly every public method is `synchronized` on `this`, even simple getters like `getCurrentlyPlaying()` and `isCurrentlyPlaying()`. This creates unnecessary contention. Methods like `getQueueSize()` aren't synchronized at all, creating inconsistency.
|
|
||||||
|
|
||||||
4. **Poor error handling in `connect()`** (line 80): Throws a bare `RuntimeException` when `audioChannel` is null instead of using `IllegalArgumentException` or returning a `CommandExecutionResult`.
|
|
||||||
|
|
||||||
5. **`baseVolume` parsed from String without error handling** (line 44): `Integer.parseInt()` can throw `NumberFormatException` with no fallback.
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
#### 1. Extract `VoteSkipService`
|
|
||||||
- New class: `me.bobert.netneutrality.music.VoteSkipService`
|
|
||||||
- Moves all vote-skip logic out of `MusicManager`
|
|
||||||
- Replace magic `2.1` with a named constant `VOTE_THRESHOLD_DIVISOR` or configurable guild setting
|
|
||||||
- `MusicManager.voteSkip()` delegates to the new service
|
|
||||||
|
|
||||||
#### 2. Reduce circular dependencies
|
|
||||||
- `TrackScheduler` and `QueueManager` should not need to call back into `MusicManager` for `disconnect()`. Instead, use a callback/listener interface (e.g., `MusicManagerCallback`) that `TrackScheduler` holds, breaking the direct circular reference.
|
|
||||||
|
|
||||||
#### 3. Fix synchronization
|
|
||||||
- Remove `synchronized` from simple delegating methods (`getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`) — `AudioPlayer` is already thread-safe.
|
|
||||||
- Keep `synchronized` only on methods that coordinate multiple state changes (`connect()`, `disconnect()`, `play()`).
|
|
||||||
- Make `getQueueSize()` consistent with the chosen strategy.
|
|
||||||
|
|
||||||
#### 4. Improve error handling
|
|
||||||
- `connect()`: Replace `RuntimeException` with `IllegalArgumentException("audioChannel must not be null")`
|
|
||||||
- `baseVolume`: Add try-catch with a sensible default (e.g., 50) and a warning log.
|
|
||||||
|
|
||||||
#### 5. Extract constants
|
|
||||||
- `VOTE_THRESHOLD_DIVISOR = 2.1` (or make configurable)
|
|
||||||
- `MIN_MEMBERS_FOR_VOTE = 3`
|
|
||||||
- `DEFAULT_VOLUME = 50`
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `music/MusicManager.java` | Modify — remove vote logic, fix sync, fix error handling |
|
|
||||||
| `music/VoteSkipService.java` | **New** — extracted vote-skip logic |
|
|
||||||
| `music/MusicManagerCallback.java` | **New** — interface to break circular dependency |
|
|
||||||
| `music/TrackScheduler.java` | Modify — use callback interface instead of direct `MusicManager` reference where possible |
|
|
||||||
| `music/QueueManager.java` | Modify — remove `getMusicManager()` call chain for disconnect |
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Extract VoteSkipService and constants
|
|
||||||
Vote-skip logic is isolated in a new service class and magic numbers are replaced with named constants.
|
|
||||||
|
|
||||||
- Create `VoteSkipService.java` in `me.bobert.netneutrality.music`
|
|
||||||
- Move the vote-skip logic from `MusicManager.voteSkip()` into `VoteSkipService.voteSkip(CommandContext, QueueManager, AudioChannel)`
|
|
||||||
- Define constants: `VOTE_THRESHOLD_DIVISOR`, `MIN_MEMBERS_FOR_VOTE`
|
|
||||||
- Update `MusicManager.voteSkip()` to delegate to the new service
|
|
||||||
- Add `DEFAULT_VOLUME` constant and wrap `baseVolume` parsing in try-catch with fallback
|
|
||||||
|
|
||||||
### ✓ Step 2: Fix synchronization and error handling
|
|
||||||
Synchronization is applied only where needed and error handling is improved.
|
|
||||||
|
|
||||||
- Remove `synchronized` from simple delegating methods: `getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`, `setVolume()`
|
|
||||||
- Keep `synchronized` on `connect()`, `disconnect()`, `play()`, `isNotConnectedTo()`
|
|
||||||
- Replace `RuntimeException` in `connect()` with `IllegalArgumentException`
|
|
||||||
- Ensure `getQueueSize()` follows the same synchronization strategy as other methods
|
|
||||||
|
|
||||||
### ✓ Step 3: Reduce circular dependencies between MusicManager, TrackScheduler, and QueueManager
|
|
||||||
The circular reference chain is broken using a callback interface.
|
|
||||||
|
|
||||||
- Create `MusicManagerCallback.java` interface with method `void onQueueEmpty()` (or similar) to replace direct `MusicManager.disconnect()` calls from `QueueManager`
|
|
||||||
- Have `MusicManager` implement the callback interface
|
|
||||||
- Update `TrackScheduler` constructor to accept the callback interface instead of the full `MusicManager` where only disconnect/connection behavior is needed
|
|
||||||
- Update `QueueManager.clearQueue()` to use the callback instead of `scheduler.getMusicManager().disconnect()`
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260919-132150-1vmv
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
The `TaskManager` subsystem in Net Neutrality manages the in-memory registry, execution dispatch, locking, persistence, and lifecycle transitions for Discord interaction tasks (buttons, modals, reactions, step-by-step flows, and scheduled background tasks).
|
|
||||||
|
|
||||||
This plan addresses several bugs, synchronization oversights, memory leak potentials, and architectural discrepancies between `docs/tasks.md` and `TaskManagerImpl.java`:
|
|
||||||
1. **Fix Parent-Child Lifecycle Propagation**: Correctly handle `REMOVE_SELF_AND_CHILDREN` when triggered by child tasks (`WithParent`).
|
|
||||||
2. **Synchronize DB Persistence on Removal**: Ensure `PersistableTask` entries are purged from the database via `TaskService.unpersistTask()` when removed.
|
|
||||||
3. **Prevent Memory Leaks in Pending Tasks**: Unify `pendingTasks` tracking and ensure `TaskCleanup` cleans up expired pending step tasks.
|
|
||||||
4. **Fix Concurrency & Lock Cleanup Hazards**: Eliminate race conditions where lock eviction during task execution allows duplicate concurrent runs.
|
|
||||||
5. **Optimize Lookup Performance**: Replace eager full-collection queries with short-circuiting streams for partial ID lookups.
|
|
||||||
6. **Improve Test Coverage**: Introduce thorough unit tests covering all task lifecycle paths, locking behavior, and persistence hooks.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
- **In Scope**:
|
|
||||||
- `me.bobert.netneutrality.core.tasks.TaskManager` interface.
|
|
||||||
- `me.bobert.netneutrality.core.tasks.TaskManagerImpl` implementation.
|
|
||||||
- `me.bobert.netneutrality.scheduled.cleanup.TaskCleanup` expiration handling.
|
|
||||||
- Interaction deletion error-handling callbacks on `InteractionHook`.
|
|
||||||
- Comprehensive unit test suite for `TaskManagerImpl`.
|
|
||||||
- **Out of Scope**:
|
|
||||||
- Refactoring concrete task business logic (e.g., individual music or reaction commands).
|
|
||||||
- Schema changes to the `OTask` entity or database migrations.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
- **FR-1**: When any child task implementing `WithParent` returns `Lifecycle.REMOVE_SELF_AND_CHILDREN`, `TaskManager` must remove the parent task and all associated child tasks from the registry.
|
|
||||||
- **FR-2**: When any `PersistableTask` is removed from `TaskManager` (via lifecycle completion, manual removal, or cancellation), `TaskManager` must call `taskService.unpersistTask()` so the task is deleted from persistent storage.
|
|
||||||
- **FR-3**: Pending step tasks must be tracked reliably, exposed for expiration checks, and cleaned up by `TaskCleanup` if abandoned.
|
|
||||||
- **FR-4**: Concurrent task invocations sharing the same `lockId` must be serialized safely without lock collisions or premature lock disposal.
|
|
||||||
- **FR-5**: `findTaskByPartialId` must short-circuit upon finding the first match without allocating full intermediate lists.
|
|
||||||
- **FR-6**: Interaction deletion via `Deletable` must gracefully handle expired hooks and Discord API errors asynchronously.
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
`TaskManagerImpl` (`me.bobert.netneutrality.core.tasks.TaskManagerImpl`) implements `TaskManager` and `Lifecycle`. It maintains:
|
|
||||||
- `tasks`: `ConcurrentHashMap<String, Task>` for active tasks.
|
|
||||||
- `pendingTasks`: `CopyOnWriteArrayList<Task>` for unassigned step tasks.
|
|
||||||
- `deletableTasksContext`: `ConcurrentHashMap<String, CommandContext>` for tracking interaction hooks to delete.
|
|
||||||
- `tasksLock`: `ConcurrentHashMap<String, ReentrantLock>` for per-task/per-parent synchronization.
|
|
||||||
- `taskService`: `TaskService` for loading and persisting `PersistableTask` entities.
|
|
||||||
|
|
||||||
### Key Issues & Decisions
|
|
||||||
|
|
||||||
#### 1. Parent/Child Task Lifecycle Resolution
|
|
||||||
- **Issue**: `handleTaskLifecycle` only checks `if (task instanceof WithChildren)` for `REMOVE_SELF_AND_CHILDREN`. When a button task (which implements `WithParent<P>`, such as `LoadTrackFromPlaylistTask`) returns `REMOVE_SELF_AND_CHILDREN`, the check evaluates to `false`, leaving the parent and sibling buttons active in memory.
|
|
||||||
- **Decision**: In `handleTaskLifecycle`, check if the task implements `WithParent<?>`. If so, obtain the parent task (`taskWithParent.getParentTask()`) and remove the parent along with all of its child tasks (`parent.getChildTasks()`). If the task directly implements `WithChildren`, remove all its child tasks and itself.
|
|
||||||
|
|
||||||
#### 2. Automatic Persistence Synchronization
|
|
||||||
- **Issue**: `docs/tasks.md` specifies that persistable tasks removed after execution are deleted from the database. Currently `TaskManagerImpl.removeTask` only removes from `tasks` map and never calls `taskService.unpersistTask(persistableTask)`.
|
|
||||||
- **Decision**: In `removeTask(Task task)`, check `if (task instanceof PersistableTask persistableTask)` and invoke `taskService.unpersistTask(persistableTask)`.
|
|
||||||
|
|
||||||
#### 3. Pending Task Registry & Cleanup
|
|
||||||
- **Issue**: `pendingTasks` is stored in a `CopyOnWriteArrayList<Task>`. `getTasks()` ignores it, preventing `TaskCleanup` from pruning expired step tasks. `removeTask()` also ignores `pendingTasks`.
|
|
||||||
- **Decision**: Store `pendingTasks` in a `ConcurrentHashMap<String, Task>` (or unify with active tasks / pending lookup). Update `getTasks()` or provide a method to include pending tasks in cleanup routines. Update `removeTask(Task task)` to remove from `pendingTasks` as well as `tasks`.
|
|
||||||
|
|
||||||
#### 4. Concurrency & Lock Management
|
|
||||||
- **Issue**: `removeTask` calls `tasksLock.remove(task.getLockId())` immediately. If another thread is waiting or about to acquire the lock for a sibling task, it can get a new lock instance from `computeIfAbsent`, breaking mutual exclusion.
|
|
||||||
- **Decision**: Maintain lock instances safely; do not remove locks eagerly while in the middle of executing handlers. Persistable tasks with state should also participate in locking if needed, or maintain clear locking semantics.
|
|
||||||
|
|
||||||
#### 5. Lookup API Optimization
|
|
||||||
- **Issue**: `findTaskByPartialId` calls `findTasksByPartialId`, instantiating and populating a full `ArrayList` before taking the first element.
|
|
||||||
- **Decision**: Implement `findTaskByPartialId` with `.filter(...).findFirst()` directly on the stream.
|
|
||||||
|
|
||||||
### Architecture & Component Interaction Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Discord Events
|
|
||||||
BE[ButtonInteractionEvent] --> EH[EventsHandler]
|
|
||||||
ME[ModalInteractionEvent] --> EH
|
|
||||||
RE[MessageReactionAddEvent] --> EH
|
|
||||||
MSG[MessageReceivedEvent] --> EH
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph TaskManager Subsystem
|
|
||||||
EH -->|TaskContext| TM[TaskManagerImpl]
|
|
||||||
TM -->|Acquire Lock| TL[tasksLock Map]
|
|
||||||
TM -->|Lookup Task| TR[tasks & pendingTasks Maps]
|
|
||||||
TM -->|Execute| T[Task.execute]
|
|
||||||
T -->|Return Lifecycle| TM
|
|
||||||
TM -->|Cascade Cleanup| LC[handleTaskLifecycle]
|
|
||||||
LC -->|WithParent / WithChildren| RM[removeTask]
|
|
||||||
RM -->|Unpersist| TS[TaskService]
|
|
||||||
TS -->|Delete| DB[(Database / OTask)]
|
|
||||||
RM -->|Deletable| DH[InteractionHook.deleteOriginal]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Background Maintenance
|
|
||||||
TC[TaskCleanup] -->|Periodic Poll| TM
|
|
||||||
TM -->|Prune Expired| RM
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Proposed Code Changes & Contracts
|
|
||||||
|
|
||||||
#### `TaskManagerImpl.java`
|
|
||||||
```java
|
|
||||||
private void handleTaskLifecycle(Task task, Task.Lifecycle status) {
|
|
||||||
if (status == null) {
|
|
||||||
throw new IllegalArgumentException("Task lifecycle status cannot be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case REMOVE_SELF -> removeTask(task);
|
|
||||||
case REMOVE_SELF_AND_CHILDREN -> {
|
|
||||||
if (task instanceof WithParent<?> taskWithParent) {
|
|
||||||
Task parent = taskWithParent.getParentTask();
|
|
||||||
if (parent instanceof WithChildren parentWithChildren) {
|
|
||||||
for (Task child : parentWithChildren.getChildTasks()) {
|
|
||||||
removeTask(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
removeTask(parent);
|
|
||||||
} else if (task instanceof WithChildren taskWithChildren) {
|
|
||||||
for (Task child : taskWithChildren.getChildTasks()) {
|
|
||||||
removeTask(child);
|
|
||||||
}
|
|
||||||
removeTask(task);
|
|
||||||
} else {
|
|
||||||
removeTask(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case KEEP, PARENT, BUILTIN -> {
|
|
||||||
// Maintained in active registry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void removeTask(Task task) {
|
|
||||||
if (task == null) return;
|
|
||||||
String id = task.getId();
|
|
||||||
boolean removed = (tasks.remove(id) != null);
|
|
||||||
removed |= (pendingTasks.remove(task) != null);
|
|
||||||
|
|
||||||
if (removed) {
|
|
||||||
logger.debug("Removing task: {}", id);
|
|
||||||
if (task instanceof PersistableTask persistableTask) {
|
|
||||||
taskService.unpersistTask(persistableTask);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Structure Changes
|
|
||||||
- **Modified**: `src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java`
|
|
||||||
- **Modified**: `src/main/java/me/bobert/netneutrality/scheduled/cleanup/TaskCleanup.java`
|
|
||||||
- **Added**: `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java`
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
|
|
||||||
### Validation Approach
|
|
||||||
Automated unit tests will be created in `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` utilizing JUnit 5, AssertJ, and Mockito to validate all functional requirements without requiring live Discord connections or database instances.
|
|
||||||
|
|
||||||
### Key Scenarios
|
|
||||||
1. **Task Registration & Lookup**:
|
|
||||||
- Insert standard `Task`, verify `getTask(id)` and `hasTask(id)` return `true`.
|
|
||||||
- Insert duplicate task ID, verify rejected with user notification.
|
|
||||||
2. **Parent / Child Lifecycle (`REMOVE_SELF_AND_CHILDREN`)**:
|
|
||||||
- Create parent task implementing `WithChildren` and child task implementing `WithParent`.
|
|
||||||
- Execute child task returning `REMOVE_SELF_AND_CHILDREN`.
|
|
||||||
- Verify parent task and all sibling child tasks are removed from the registry.
|
|
||||||
3. **Persistable Task DB Synchronization**:
|
|
||||||
- Insert `PersistableTask`, verify `TaskService.persistTask` is called.
|
|
||||||
- Remove `PersistableTask` via lifecycle completion or `removeTask()`, verify `TaskService.unpersistTask` is invoked.
|
|
||||||
4. **Step Task Flow**:
|
|
||||||
- Insert `StepTask` into pending registry.
|
|
||||||
- Reassign ID via `activateTask(task, oldId)`, verify task moves from pending to active registry under new ID.
|
|
||||||
5. **Partial ID Search**:
|
|
||||||
- Query `findTaskByPartialId` and verify it matches correct instance without scanning beyond first match.
|
|
||||||
6. **Task Expiration & Cleanup**:
|
|
||||||
- Mock expired active and pending tasks, trigger `TaskCleanup.run()`, verify expired tasks are pruned from both collections.
|
|
||||||
7. **Concurrency & Locking**:
|
|
||||||
- Simulate concurrent executions for tasks sharing a `lockId`, confirming mutual exclusion.
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Fix Task Lifecycle Hierarchy and DB Persistence Synchronization
|
|
||||||
`TaskManagerImpl` correctly propagates parent-child lifecycle actions and synchronizes task deletions with `TaskService`.
|
|
||||||
|
|
||||||
- Update `handleTaskLifecycle` in `TaskManagerImpl.java` to support `REMOVE_SELF_AND_CHILDREN` for tasks implementing `WithParent`, resolving the parent task and removing both the parent and all sibling child tasks.
|
|
||||||
- Modify `removeTask(Task task)` in `TaskManagerImpl.java` to invoke `taskService.unpersistTask(persistableTask)` when `task instanceof PersistableTask`, ensuring persisted records in the database are purged when tasks finish.
|
|
||||||
- Refactor `deleteInteraction(Task task)` in `TaskManagerImpl.java` to cleanly handle `Deletable` logic for both standalone and parented tasks, adding failure logging to `InteractionHook.deleteOriginal().queue()`.
|
|
||||||
|
|
||||||
### ✓ Step 2: Refactor Pending Task Management and Concurrency Locking
|
|
||||||
Pending tasks and active task locks are managed safely without race conditions or memory leaks.
|
|
||||||
|
|
||||||
- Refactor `pendingTasks` in `TaskManagerImpl.java` to use a `ConcurrentHashMap<String, Task>` keyed by task ID for O(1) lookups and consistent state management.
|
|
||||||
- Update `removeTask(Task task)`, `getTask(String id)`, and `getTasks()` in `TaskManagerImpl.java` to include pending tasks so they can be retrieved, removed, and cleaned up when expired.
|
|
||||||
- Update `TaskCleanup.java` to ensure expired pending tasks are identified and cleaned up.
|
|
||||||
- Improve lock management in `TaskManagerImpl.handle(TaskContext context)` so that lock eviction does not interfere with concurrently waiting threads acquiring new lock instances.
|
|
||||||
|
|
||||||
### ✓ Step 3: Optimize Task Query Performance and Diagnostic Logging
|
|
||||||
Task lookup methods use short-circuiting stream pipelines, and diagnostic logging is corrected.
|
|
||||||
|
|
||||||
- Optimize `findTaskByPartialId` in `TaskManagerImpl.java` to use `.findFirst()` directly rather than allocating a full list via `findTasksByPartialId`.
|
|
||||||
- Fix typos in log messages and warnings across `TaskManagerImpl.java` (e.g. `"Aquiring lock"`, `"childs"`).
|
|
||||||
- Ensure consistent ephemeral fallback messages and handling when unacknowledged interactions or missing tasks are encountered.
|
|
||||||
|
|
||||||
### ✓ Step 4: Add Comprehensive Unit Tests for Task Management
|
|
||||||
A complete unit test suite validates `TaskManagerImpl` functionality across all task types, lifecycles, and concurrency scenarios.
|
|
||||||
|
|
||||||
- Create `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` using JUnit 5 and Mockito.
|
|
||||||
- Implement tests for standard task registration (`insert`), lookup (`getTask`, `findTaskByPartialId`), and removal (`removeTask`).
|
|
||||||
- Implement tests for parent-child tasks (`WithChildren`, `WithParent`), verifying lock inheritance and `REMOVE_SELF_AND_CHILDREN` cascading removals.
|
|
||||||
- Implement tests for `StepTask` lifecycle (pending insertion, ID reassignment via `activateTask`, and expiration).
|
|
||||||
- Implement tests for `PersistableTask` lifecycle, verifying automatic database persistence on insert and unpersisting on removal.
|
|
||||||
- Implement tests verifying thread synchronization and lock acquisition during task execution.
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260923-111902-5uwc
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
Replace the hand-rolled `com.sun.net.httpserver` stack in `me.bobert.netneutrality.http.server` with Javalin, so the bot's embedded API needs far fewer custom classes and gains correct status codes, JSON handling and error responses. Additionally simplify the outbound `WebModule` HTTP client.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
**In scope**
|
|
||||||
- `WebApi` becomes a thin Javalin-based `Lifecycle` component (same priority 70, same `NETNEUTRALITY_API_PORT` setting).
|
|
||||||
- Delete `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler`; their logic moves into small route-registration units.
|
|
||||||
- Standardise the API layer on **Gson** — remove `org.json` (`JSONObject`/`JSONArray`) usage from the HTTP server package.
|
|
||||||
- Correct HTTP semantics: 404 for unknown guilds/paths, 405 for wrong methods, 500 on exceptions, UTF-8 safe bodies.
|
|
||||||
- Simplify `WebModule` (outbound OkHttp wrapper): extract the static cache, remove duplicated/unused members.
|
|
||||||
|
|
||||||
**Out of scope**
|
|
||||||
- Changing the public URL shape of existing endpoints (`/`, `/actions/*`, `/guilds/*`) — paths and payload keys stay backwards compatible.
|
|
||||||
- Authentication/authorisation for the API.
|
|
||||||
- Migrating `org.json` usage elsewhere in the project (e.g. task classes using `WebModule.getAsObject()`).
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
- `GET /` → HTML liveness page (unchanged text).
|
|
||||||
- `GET /actions/status` → `{"health":...,"ping":...}`.
|
|
||||||
- `POST /actions/shutdown` → runs the shutdown hook and exits (accept `GET` too for compatibility if desired).
|
|
||||||
- `GET /actions/settings/current/{guildId}` → `{"status":"ok","settings":{...}}`, or 404 + `{"status":"error"}` for an unknown guild.
|
|
||||||
- `POST /actions/settings/reload/{guildId}` → reloads the guild setting cache, `{"status":"true"}`; 404 for an unknown guild.
|
|
||||||
- `GET /guilds/owner` → array of `{guild_id, owner_id}`.
|
|
||||||
- `GET /guilds/channels/{guildId}` and `GET /guilds/roles/{guildId}` → arrays of id/name objects; 404 for an unknown guild.
|
|
||||||
- Requests arriving before JDA is ready must return 503 rather than throwing an NPE.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
- Startup/shutdown stays deterministic through `ApplicationContext` — `stop()` must release the port.
|
|
||||||
- Shaded jar must still run (`maven-shade-plugin`); Javalin/Jetty service files need merging.
|
|
||||||
- No behavioural regression for existing consumers of the endpoints.
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
- `WebApi.java` (92 lines) implements `Lifecycle` (priority 70), creates an `HttpServer`, registers three contexts and a fixed pool built from `BaseThreadFactory("web-api")`. Constructed in `App.java:154`.
|
|
||||||
- `AbstractHandler` offers `sendStringResponse`/`sendJsonResponse`; both hardcode status 200 and use `str.length()` instead of byte length (breaks non-ASCII).
|
|
||||||
- `ActionHandler` / `GuildHandler` route via `if (path.startsWith(...))` chains and parse ids with `getRequestURI().toString().split("/")`; they mix Gson and `org.json`, never check the HTTP method, and call `jda.getGuildById()` twice in places.
|
|
||||||
- `WebModule` is an unrelated *outbound* OkHttp wrapper with a builder, a static LRU cache and a `CachedResponse` record; it builds a brand-new `OkHttpClient` per request and has three redundant timeout setters plus unused `elapsedTime`/`errorMessage` accessors.
|
|
||||||
|
|
||||||
### Key Decisions
|
|
||||||
1. **Javalin** (6.x, JDK 17+ / Java 25 compatible) as the embedded server — removes routing, method matching, error mapping and content-type handling code.
|
|
||||||
2. **Gson only** in the HTTP layer: register a `GsonJsonMapper` on the Javalin config so `ctx.json(obj)` works with plain records/maps; `org.json` disappears from `http/server`.
|
|
||||||
3. **Route groups instead of handler classes**: `ActionRoutes` and `GuildRoutes` expose a single `static void register(Javalin app, deps...)`, so `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler` are all deleted (4 classes → 2 tiny registrars).
|
|
||||||
4. **Guild lookup guard**: one private helper resolves `{guildId}` → `Guild` or throws `NotFoundResponse`, replacing repeated null checks.
|
|
||||||
5. **WebModule**: keep the builder API used by callers, but move the cache into a package-private `ResponseCache`, share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()`.
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
**`http/server/WebApi.java`**
|
|
||||||
```java
|
|
||||||
public class WebApi implements Lifecycle {
|
|
||||||
private Javalin app;
|
|
||||||
@Override public int priority() { return 70; }
|
|
||||||
@Override public void start() {
|
|
||||||
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
|
|
||||||
app = Javalin.create(cfg -> cfg.jsonMapper(new GsonJsonMapper()));
|
|
||||||
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
|
|
||||||
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
|
|
||||||
GuildRoutes.register(app, jdaSupplier);
|
|
||||||
app.exception(Exception.class, (e, ctx) -> { log.error(...); ctx.status(500).json(Map.of("status","error")); });
|
|
||||||
app.start(port);
|
|
||||||
}
|
|
||||||
@Override public void stop() { if (app != null) { app.stop(); app = null; } }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`http/server/GuildRoutes.java`** — `/guilds/owner`, `/guilds/channels/{guildId}`, `/guilds/roles/{guildId}` mapped to `List<Map<String,String>>` and returned via `ctx.json(...)`.
|
|
||||||
|
|
||||||
**`http/server/ActionRoutes.java`** — `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
|
|
||||||
|
|
||||||
**`http/server/GsonJsonMapper.java`** — ~15 lines implementing Javalin's `JsonMapper` over a shared `Gson`.
|
|
||||||
|
|
||||||
**`http/WebModule.java`** — shared client, cache extracted, redundant setters/accessors dropped; public builder methods used by callers remain.
|
|
||||||
|
|
||||||
### Data Models / Contracts
|
|
||||||
Small records replace ad-hoc JSON building, serialized by Gson with the existing key names:
|
|
||||||
```java
|
|
||||||
record GuildOwner(String guild_id, String owner_id) {}
|
|
||||||
record NamedEntity(String channel_id, String channel_name) {}
|
|
||||||
record StatusResponse(String health, String ping) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
```
|
|
||||||
src/main/java/me/bobert/netneutrality/http/
|
|
||||||
WebModule.java (modified)
|
|
||||||
server/WebApi.java (rewritten, Javalin)
|
|
||||||
server/GsonJsonMapper.java (new)
|
|
||||||
server/ActionRoutes.java (new)
|
|
||||||
server/GuildRoutes.java (new)
|
|
||||||
server/AbstractHandler.java (deleted)
|
|
||||||
server/RootHandler.java (deleted)
|
|
||||||
server/ActionHandler.java (deleted)
|
|
||||||
server/GuildHandler.java (deleted)
|
|
||||||
pom.xml (+ io.javalin:javalin, shade transformer)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Architecture Diagram
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
App-->ApplicationContext
|
|
||||||
ApplicationContext-->WebApi
|
|
||||||
WebApi-->Javalin
|
|
||||||
WebApi-->GsonJsonMapper
|
|
||||||
Javalin-->ActionRoutes
|
|
||||||
Javalin-->GuildRoutes
|
|
||||||
ActionRoutes-->GuildServiceProvider
|
|
||||||
GuildRoutes-->JDA
|
|
||||||
```
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
- **Shaded jar**: Jetty/Javalin rely on `META-INF/services`; the shade plugin needs `ServicesResourceTransformer`, otherwise startup fails only in the packaged build.
|
|
||||||
- **Jar size / SLF4J binding**: Jetty logs through SLF4J; project already has log4j + slf4j — check for duplicate bindings.
|
|
||||||
- **JDA not ready**: `jdaSupplier.get()` may be null; guard with a 503 mapping.
|
|
||||||
- `System.exit(0)` in the shutdown route must run after the response is flushed.
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
|
|
||||||
### Validation Approach
|
|
||||||
Build with `./mvnw -q verify` (checkstyle included), then start the app/`WebApi` on an ephemeral port and exercise every endpoint with `curl`, comparing payload shape against the pre-refactor behaviour.
|
|
||||||
|
|
||||||
### Key Scenarios
|
|
||||||
- `GET /` returns 200 and the HTML liveness text.
|
|
||||||
- `GET /actions/status` returns 200 `application/json` with `health` and `ping`.
|
|
||||||
- `GET /guilds/owner` returns a JSON array with `guild_id`/`owner_id` keys.
|
|
||||||
- `GET /guilds/channels/{id}` and `/guilds/roles/{id}` return arrays with `channel_id`/`channel_name`.
|
|
||||||
- `GET /actions/settings/current/{id}` returns `{"status":"ok","settings":{...}}`.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
- Unknown guild id → 404 with `{"status":"error"}` (previously 200).
|
|
||||||
- Unknown path → 404 (previously echoed the path with 200).
|
|
||||||
- Wrong method on a route → 405.
|
|
||||||
- Non-ASCII content in settings values round-trips correctly (old `str.length()` bug).
|
|
||||||
- `Lifecycle.stop()` frees the port — a second `start()` on the same port succeeds.
|
|
||||||
|
|
||||||
### Test Changes
|
|
||||||
- Add a lightweight JUnit test starting `WebApi` on port 0 with a mocked `Supplier<JDA>` and `GuildServiceProvider`, hitting the routes with Javalin's test client or `java.net.http.HttpClient`.
|
|
||||||
- Add a `WebModule` test covering cache hit/expiry after the cache extraction.
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Add Javalin dependency and Gson JSON mapper
|
|
||||||
The project builds with Javalin available and a Gson-backed JSON mapper ready for use.
|
|
||||||
|
|
||||||
- Add `io.javalin:javalin` (6.x) to `pom.xml` dependencies.
|
|
||||||
- Add `ServicesResourceTransformer` to the `maven-shade-plugin` configuration so Jetty/Javalin service files survive shading.
|
|
||||||
- Create `http/server/GsonJsonMapper.java` implementing Javalin's `JsonMapper` over a shared `Gson` instance.
|
|
||||||
- Verify `./mvnw -q package` succeeds and the shaded jar still starts.
|
|
||||||
|
|
||||||
### ✓ Step 2: Rewrite WebApi as a Javalin Lifecycle component
|
|
||||||
`WebApi` starts and stops a Javalin server while keeping its Lifecycle contract and the root endpoint.
|
|
||||||
|
|
||||||
- Replace the `HttpServer`/`ExecutorService` fields with a `Javalin app` field.
|
|
||||||
- Build the instance in `start()` with the `GsonJsonMapper`, reading `NETNEUTRALITY_API_PORT` from `SettingsProvider` as today.
|
|
||||||
- Register `GET /` returning the liveness HTML and delete `RootHandler.java`.
|
|
||||||
- Add global `exception` and `error` handlers returning 500/404 JSON, plus a 503 guard when `jdaSupplier.get()` is null.
|
|
||||||
- Implement `stop()` via `app.stop()`; keep `priority() == 70` and the `App.java` construction site unchanged.
|
|
||||||
|
|
||||||
### ✓ Step 3: Migrate guild endpoints to GuildRoutes
|
|
||||||
All `/guilds/*` endpoints are served by Javalin route lambdas with proper status codes and Gson serialization.
|
|
||||||
|
|
||||||
- Create `http/server/GuildRoutes.register(Javalin, Supplier<JDA>)`.
|
|
||||||
- Map `GET /guilds/owner`, `GET /guilds/channels/{guildId}`, `GET /guilds/roles/{guildId}` using path params instead of `split("/")`.
|
|
||||||
- Return `List<record>` payloads via `ctx.json(...)`, preserving the existing `guild_id`/`owner_id`/`channel_id`/`channel_name` keys.
|
|
||||||
- Resolve guilds through a shared helper that throws `NotFoundResponse` for unknown ids (single `getGuildById` call).
|
|
||||||
- Delete `GuildHandler.java`.
|
|
||||||
|
|
||||||
### ✓ Step 4: Migrate action endpoints to ActionRoutes
|
|
||||||
All `/actions/*` endpoints run on Javalin, with `org.json` fully removed from the HTTP server package.
|
|
||||||
|
|
||||||
- Create `http/server/ActionRoutes.register(Javalin, Supplier<JDA>, GuildServiceProvider, Runnable)`.
|
|
||||||
- Map `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
|
|
||||||
- Replace `JSONObject`/`JSONArray` with Gson-serialized maps/records keeping the same response keys.
|
|
||||||
- Ensure the shutdown route flushes its response before running the hook and exiting.
|
|
||||||
- Delete `ActionHandler.java` and `AbstractHandler.java`, and add endpoint tests starting `WebApi` on port 0 with mocked JDA/guild services.
|
|
||||||
|
|
||||||
### ✓ Step 5: Simplify the outbound WebModule client
|
|
||||||
`WebModule` keeps its builder API but sheds duplicated state and its inlined cache.
|
|
||||||
|
|
||||||
- Extract the static LRU cache and `CachedResponse` into a package-private `ResponseCache` class in `http/`.
|
|
||||||
- Share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()` instead of constructing a client per build.
|
|
||||||
- Collapse the redundant `timeout`/`connectTimeout`/`readTimeout`/`writeTimeout` setters and remove unused accessors (`getErrorMessage`, `elapsedTime` exposure) after checking call sites.
|
|
||||||
- Add a unit test covering cache hit, expiry and POST bypass; verify existing `WebModule` callers still compile.
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260923-114918-ce7w
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
|
|
||||||
Replace the in-process **Lavaplayer** audio stack with a remote **Lavalink v4** node driven by the official `dev.arbjerg:lavalink-client`. Audio decoding, source resolution and voice sending move out of the bot process to the Lavalink node, which can then be extended with server-side plugins (YouTube source, LavaSrc, etc.) without touching the bot.
|
|
||||||
|
|
||||||
All dependency/plugin examples from the Lavalink docs are Gradle-based and will be **translated to Maven** (`pom.xml` `<repositories>` / `<dependencies>`); the Lavalink node itself is configured via a YAML file mounted into the container, not via any build script.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
**In Scope**
|
|
||||||
- Swap `dev.arbjerg:lavaplayer` + `dev.lavalink.youtube:v2` for `dev.arbjerg:lavalink-client` in `pom.xml`.
|
|
||||||
- Rewrite the `me.bobert.netneutrality.music` package against Lavalink: `MusicServiceImpl`, `MusicManager`, `TrackScheduler`, `QueueManager`, `TrackInfo`, `TrackLoadingHandler`, `StuckTrackRecoveryHandler`; delete `AudioPlayerSendHandler`.
|
|
||||||
- Route voice connections through Lavalink's `JDAVoiceUpdateListener` / `DirectAudioController` instead of `Guild#getAudioManager()`.
|
|
||||||
- Add a `lavalink` service plus `lavalink/application.yml` to `docker-compose.yml`, wired to the existing `ejs-api` (yt-cipher) container.
|
|
||||||
- Update `MessageFunction`, `LoadPlaylistTask`, `LoadTrackFromPlaylistTask` and the music command classes to the new track type.
|
|
||||||
- Update the affected tests and the docs under `docs/`.
|
|
||||||
|
|
||||||
**Out of Scope**
|
|
||||||
- Porting `music/source/UdioAudioSourceManager` to a Lavalink server plugin — captured as a documented follow-up. Udio playback is temporarily unavailable; the existing code stays in the repo as the porting reference.
|
|
||||||
- Any change to command UX, queue semantics, DJ rules or vote-skip behaviour.
|
|
||||||
- Audio filters/equalizer, node load balancing across multiple nodes, and session resuming (Lavalink supports these; not enabled here).
|
|
||||||
|
|
||||||
### User Stories
|
|
||||||
- As a bot operator, I want audio to be served by a separate Lavalink node so that I can add source plugins and restart the bot without interrupting the audio pipeline's configuration.
|
|
||||||
- As a user, I want `/play`, `/skip`, `/queue`, `/volume`, `/stop`, `/resume`, `/shuffle` and vote-skip to behave exactly as before the migration.
|
|
||||||
- As a maintainer, I want YouTube cipher/poToken configuration to live in the node's `application.yml` rather than in Java code.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
- All 13 commands in `command/music/` keep their current signatures, replies and `CommandExecutionResult` semantics.
|
|
||||||
- `SourceFinder` keeps classifying URLs and prefixing searches with `ytsearch:`; identifiers are passed to Lavalink's `loadItem` unchanged.
|
|
||||||
- Queue ordering, duplicate rejection, auto-start on first enqueue, auto-disconnect on empty queue and the two-strike stuck-track recovery are preserved.
|
|
||||||
- If the Lavalink node is unreachable at startup, the bot still starts; music commands reply with a clear error instead of crashing.
|
|
||||||
- Udio URLs reply with a "temporarily unsupported" message rather than failing silently.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
- The bot no longer ships native audio encoders: the `jdave-api` / `jdave-native-*` dependencies and the `AudioModuleConfig` DAVE wiring in `App.initJDA()` are removed, since DAVE is handled by the node (Lavalink ≥ 4.2.0).
|
|
||||||
- New settings are read through the existing `SettingsProvider` and documented in `bot.properties.example`.
|
|
||||||
- Checkstyle (`checkstyle.xml`, bound to the `test` phase) must keep passing.
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
|
|
||||||
- `MusicServiceImpl` (priority-40 `Lifecycle`, registered in `App.buildContext()` line 140) owns a `DefaultAudioPlayerManager` and registers YouTube (with `setRemoteCipher(...)`), SoundCloud, Udio and HTTP source managers in `start()`; `stop()` clears queues and calls `player.destroy()`.
|
|
||||||
- `MusicManager` creates an `AudioPlayer` per guild, sets `AudioPlayerSendHandler` on `guild.getAudioManager()`, opens/closes the voice connection itself, and calls `playerManager.loadItemOrdered(this, identifier, new TrackLoadingHandler(...))`.
|
|
||||||
- `TrackScheduler extends AudioEventAdapter` and receives `onTrackStart/End/Exception/Stuck` callbacks directly from the player.
|
|
||||||
- `QueueManager` holds a `Deque<TrackInfo>` and drives playback with `player.startTrack(track.makeClone(), false)` / `player.stopTrack()`.
|
|
||||||
- `StuckTrackRecoveryHandler` relies on `AudioTrack#makeClone`, `getPosition()` and `setPosition()`.
|
|
||||||
- `com.sedmelluq.*` types appear in 14 main sources and 5 tests (`QueueManagerTest`, `TrackInfoTest`, `TrackSchedulerTest`, `VoteSkipServiceTest`, `UdioAudioSourceManagerTest`).
|
|
||||||
- `pom.xml` already declares the `https://maven.lavalink.dev/releases` repository (lines 136–140).
|
|
||||||
- `docker-compose.yml` runs `db`, `bot` and `ejs_signature_api` (yt-cipher) on the `netneutrality` network; there is no Lavalink node.
|
|
||||||
|
|
||||||
### Key Decisions
|
|
||||||
|
|
||||||
1. **Official client** — `dev.arbjerg:lavalink-client` (v4-only). `VoiceDispatchInterceptor` is verified present in the bundled JDA 6.4.1 jar, so `JDAVoiceUpdateListener` attaches cleanly.
|
|
||||||
2. **Central event dispatcher** — `MusicServiceImpl` holds the single subscription to the client's event flux and routes each event to the owning guild's `TrackScheduler`. One `Disposable` to dispose in `stop()`.
|
|
||||||
3. **Synchronous facade** — `MusicManager` subscribes to the returned `Mono`s internally; command classes keep their current synchronous signatures. Failures are reported via `LogHandler` and the attached `MusicChannelContext`.
|
|
||||||
4. **Lavalink `Track` everywhere** — `dev.arbjerg.lavalink.protocol.v4.Track` replaces `AudioTrack` in `TrackInfo`, `QueueManager`, `MessageFunction` and the playlist tasks. `Track` is an immutable data class, so `makeClone()` disappears and `setPosition` becomes `player.setPosition(...)` on the Lavalink player.
|
|
||||||
5. **Node in compose** — a `lavalink` service with a mounted `application.yml`; YouTube cipher/poToken config moves out of `MusicServiceImpl.start()` into that YAML.
|
|
||||||
6. **Udio deferred** — `music/source/**` is excluded from compilation-relevant wiring and the `Udio` branch of `SourceFinder.getIdentifier` returns `null` with a friendly reply; the classes remain for the future plugin port.
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
**Maven (`pom.xml`)** — translated from the docs' Gradle snippets:
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.arbjerg</groupId>
|
|
||||||
<artifactId>lavalink-client</artifactId>
|
|
||||||
<version>3.4.0</version>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
resolved from the already-present `arbjergDev-releases` repository. Removed: `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2`, `club.minnced:jdave-*`.
|
|
||||||
|
|
||||||
**`MusicServiceImpl`**
|
|
||||||
- Field `LavalinkClient client = new LavalinkClient(Helpers.getUserIdFromToken(token))`.
|
|
||||||
- `start()` registers one node (`client.addNode(...)` with URI, password and name from settings) and opens the dispatcher subscription:
|
|
||||||
`client.on(TrackStartEvent.class)`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`, `WebSocketClosedEvent` → look up `managers.get(event.getGuildId())` → forward to its `TrackScheduler`.
|
|
||||||
- `stop()` disposes the subscriptions, clears queues, destroys players and closes the client.
|
|
||||||
- Exposes `LavalinkClient getClient()` in place of `getPlayerManager()`.
|
|
||||||
|
|
||||||
**`App`**
|
|
||||||
- `initJDA()` adds `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` and drops `.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))`.
|
|
||||||
- Ordering: the `LavalinkClient` must exist before JDA is built, which the existing priorities already guarantee (music = 40, JDA = 60).
|
|
||||||
- Dev log levels switch from `com.sedmelluq.discord` / `dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
|
|
||||||
|
|
||||||
**`MusicManager`**
|
|
||||||
- Holds `LavalinkClient client` and resolves `Link link = client.getOrCreateLink(guildId)`.
|
|
||||||
- `connect(AudioChannel)` → `jda.getDirectAudioController().connect(channel)` + `setSelfDeafened`; `disconnect()` → `getDirectAudioController().disconnect(guild)` and `link.destroyPlayer()`.
|
|
||||||
- `isConnected()` / `isNotConnectedTo(...)` read `guild.getSelfMember().getVoiceState().getChannel()` — **`Guild#getAudioManager()` must not be used** with Lavalink.
|
|
||||||
- `play(...)` → `link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))`.
|
|
||||||
- `getCurrentlyPlaying()` returns the cached `Track` from the last `TrackStartEvent` (the REST player state is not synchronously readable).
|
|
||||||
- `setVolume` / `stopTrack` → `link.getPlayer().flatMap(p -> p.setVolume(v).setEncodedTrack(null))` subscribed internally.
|
|
||||||
|
|
||||||
**`TrackScheduler`**
|
|
||||||
- No longer extends `AudioEventAdapter`; exposes `onTrackStart(Track)`, `onTrackEnd(Track, AudioTrackEndReason)`, `onTrackException`, `onTrackStuck(Track, long)` called by the dispatcher.
|
|
||||||
- The existing `endReason` branching maps 1:1 onto Lavalink's `AudioTrackEndReason` (`FINISHED`, `LOAD_FAILED`, `STOPPED`, `REPLACED`, `CLEANUP`).
|
|
||||||
|
|
||||||
**`QueueManager`**
|
|
||||||
- `player.getPlayingTrack() == null` becomes a `volatile Track currentTrack` maintained by scheduler events (avoids a blocking REST call inside the `synchronized (queue)` blocks).
|
|
||||||
- `startTrack(track, noInterrupt)` becomes `link.createOrUpdatePlayer().setTrack(track).setNoReplace(...)`; `makeClone()` calls are dropped since `Track` is immutable.
|
|
||||||
|
|
||||||
**`TrackLoadingHandler`** — implements lavalink-client's `AbstractAudioLoadResultHandler`: `ontrackLoaded` / `onPlaylistLoaded` / `onSearchResultLoaded` / `noMatches` / `loadFailed(LoadFailed)`. `onSearchResultLoaded` takes the first result, matching today's `ytsearch:` behaviour.
|
|
||||||
|
|
||||||
**`StuckTrackRecoveryHandler`** — re-queues the same immutable `Track` and restores position via `player.setPosition(lastPosition)`; the second consecutive strike still skips.
|
|
||||||
|
|
||||||
### Data Models / Contracts
|
|
||||||
```java
|
|
||||||
// MusicManager (synchronous facade, unchanged call sites)
|
|
||||||
public synchronized void play(String query, CommandContext ctx, boolean silent);
|
|
||||||
public synchronized void stopTrack();
|
|
||||||
public synchronized void setVolume(int volume);
|
|
||||||
public synchronized Track getCurrentlyPlaying();
|
|
||||||
|
|
||||||
// TrackScheduler (invoked by the dispatcher)
|
|
||||||
void onTrackStart(Track track);
|
|
||||||
void onTrackEnd(Track track, AudioTrackEndReason endReason);
|
|
||||||
void onTrackStuck(Track track, long thresholdMs);
|
|
||||||
|
|
||||||
// TrackInfo
|
|
||||||
public record TrackInfo(Track track, Member requester) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
New settings (read via `SettingsProvider`, documented in `bot.properties.example`):
|
|
||||||
`NETNEUTRALITY_LAVALINK_URI` (default `ws://lavalink:2333`), `NETNEUTRALITY_LAVALINK_PASSWORD`, `NETNEUTRALITY_LAVALINK_NAME`.
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
| Path | Change |
|
|
||||||
|---|---|
|
|
||||||
| `pom.xml` | swap lavaplayer/youtube-v2/jdave → `lavalink-client` |
|
|
||||||
| `docker-compose.yml` | add `lavalink` service |
|
|
||||||
| `lavalink/application.yml` | **new** — node + youtube-source plugin + yt-cipher config |
|
|
||||||
| `bot.properties.example` | new Lavalink settings |
|
|
||||||
| `music/MusicServiceImpl.java` | client lifecycle + event dispatcher |
|
|
||||||
| `music/MusicManager.java` | `Link`/`DirectAudioController` based |
|
|
||||||
| `music/TrackScheduler.java` | plain event sink |
|
|
||||||
| `music/QueueManager.java`, `TrackInfo.java`, `TrackLoadingHandler.java`, `StuckTrackRecoveryHandler.java` | `Track` type + REST player calls |
|
|
||||||
| `music/AudioPlayerSendHandler.java` | **deleted** |
|
|
||||||
| `music/SourceFinder.java` | Udio → unsupported |
|
|
||||||
| `App.java` | voice interceptor, drop DAVE wiring, log levels |
|
|
||||||
| `shared/util/MessageFunction.java`, `tasks/music/LoadPlaylistTask.java`, `tasks/music/LoadTrackFromPlaylistTask.java` | `Track` type |
|
|
||||||
| `src/test/.../music/*` | updated fixtures |
|
|
||||||
| `docs/music.md`, `docs/architecture.md`, `docs/README.md` | rewritten sections |
|
|
||||||
|
|
||||||
### Architecture Diagram
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
JDA[JDA gateway] -->|voice state| VIL[JDAVoiceUpdateListener]
|
|
||||||
VIL --> C[LavalinkClient]
|
|
||||||
C -->|REST and WS| N[Lavalink node]
|
|
||||||
N --> V[Discord voice]
|
|
||||||
CMD[music commands] --> MM[MusicManager]
|
|
||||||
MM -->|Link ops| C
|
|
||||||
MM --> QM[QueueManager]
|
|
||||||
C -->|event flux| DISP[MusicServiceImpl dispatcher]
|
|
||||||
DISP --> TS[TrackScheduler]
|
|
||||||
TS --> QM
|
|
||||||
```
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
- **Voice API confusion**: any remaining `guild.getAudioManager()` call silently breaks playback; all call sites (`MusicManager`, `MusicUtils`, `BotState`) must be swept.
|
|
||||||
- **Blocking on reactor**: subscribing inside `synchronized` blocks in `QueueManager` risks deadlock — hence the cached `currentTrack` instead of a REST read.
|
|
||||||
- **Node availability**: `MusicServiceImpl.start()` must not throw when the node is down, or `App.startup()` exits with code 1.
|
|
||||||
- **Feature regressions**: Udio support pauses; direct-MP3 and SoundCloud must be re-verified against the node's enabled sources.
|
|
||||||
- **DAVE**: removing JDave requires Lavalink ≥ 4.2.0 — the compose image must be pinned accordingly.
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
|
|
||||||
### Validation Approach
|
|
||||||
- `./mvnw -q verify` must compile and pass Checkstyle plus the updated unit tests.
|
|
||||||
- Unit tests keep using mocks: `QueueManagerTest` and `TrackSchedulerTest` mock the Lavalink `Link`/`LavalinkPlayer` and assert on the interactions instead of on `AudioPlayer`.
|
|
||||||
- A grep sweep confirms no `com.sedmelluq` / `dev.lavalink.youtube` import survives outside `music/source/**`.
|
|
||||||
- Manual smoke run with `docker compose up` to confirm the bot reaches the node and plays a YouTube URL.
|
|
||||||
|
|
||||||
### Key Scenarios
|
|
||||||
- Enqueue on an empty queue starts playback immediately; enqueue while playing appends.
|
|
||||||
- `TrackEndEvent(FINISHED)` advances to the next track; an empty queue triggers `onQueueEmpty()` → disconnect.
|
|
||||||
- `skip(n)`, `shuffle`, `clearQueue`, `removeTracksFromUser` and `removeFromQueue(index)` return the same `CommandExecutionResult` messages as before.
|
|
||||||
- `ytsearch:` queries resolve through `onSearchResultLoaded` and queue the first hit.
|
|
||||||
- Volume from `GSettings.MUSIC_VOLUME` is applied on connect, with the 50 fallback on a malformed value.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
- Lavalink node unreachable at startup → bot boots, music commands reply with an error.
|
|
||||||
- Node disconnects mid-playback → `WebSocketClosedEvent` is logged and the guild state is cleaned up.
|
|
||||||
- Stuck track: first event re-queues and seeks, second consecutive event skips.
|
|
||||||
- Udio URL → explicit "temporarily unsupported" reply.
|
|
||||||
- Duplicate track enqueue is still rejected by `TrackInfo` equality.
|
|
||||||
|
|
||||||
### Test Changes
|
|
||||||
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest`, `VoteSkipServiceTest` to the Lavalink `Track` type and mocked players.
|
|
||||||
- Keep `UdioAudioSourceManagerTest` as-is while the source classes remain in the repo for the future plugin port.
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Swap Maven dependencies and stand up a Lavalink node
|
|
||||||
The project builds against `lavalink-client` and `docker compose up` starts a reachable Lavalink v4 node.
|
|
||||||
|
|
||||||
- Replace `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2` and the four `club.minnced:jdave-*` entries in `pom.xml` with `dev.arbjerg:lavalink-client` (Gradle docs snippet translated to Maven, using the existing `arbjergDev-releases` repository).
|
|
||||||
- Add a `lavalink` service to `docker-compose.yml` on the `netneutrality` network, pinned to an image with DAVE support (≥ 4.2.0), with the bot depending on it.
|
|
||||||
- Create `lavalink/application.yml` mounted into the node: server port/password, the youtube-source plugin, and the remote cipher pointed at the existing `ejs-api` container plus poToken/visitorData.
|
|
||||||
- Add `NETNEUTRALITY_LAVALINK_URI`, `_PASSWORD` and `_NAME` to `bot.properties.example`.
|
|
||||||
|
|
||||||
### ✓ Step 2: Wire LavalinkClient into the bootstrap and JDA voice handling
|
|
||||||
The bot connects to the node on startup and routes Discord voice through Lavalink instead of JDA's audio manager.
|
|
||||||
|
|
||||||
- Rework `MusicServiceImpl` to own a `LavalinkClient`, registering the configured node in `start()` without throwing when it is unreachable.
|
|
||||||
- Dispose the client and its subscriptions in `stop()`, keeping the priority-40 `Lifecycle` contract used by `App.buildContext()`.
|
|
||||||
- Add `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` in `App.initJDA()` and remove the `AudioModuleConfig`/`JDaveSessionFactory` wiring.
|
|
||||||
- Update the dev-mode log levels in `App.main` from `com.sedmelluq.discord`/`dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
|
|
||||||
- Replace `getPlayerManager()` with `getClient()` and fix `BotState`'s usage.
|
|
||||||
|
|
||||||
### ✓ Step 3: Rebuild MusicManager on Links and the DirectAudioController
|
|
||||||
Per-guild connect, disconnect and player control go through Lavalink while command signatures stay unchanged.
|
|
||||||
|
|
||||||
- Replace the `AudioPlayer` field in `MusicManager` with a `Link` obtained from `client.getOrCreateLink(guildId)`.
|
|
||||||
- Implement `connect`/`disconnect` via `jda.getDirectAudioController()` and `link.destroyPlayer()`, deleting the `guild.getAudioManager()` calls and the `AudioPlayerSendHandler` class.
|
|
||||||
- Base `isConnected()` and `isNotConnectedTo(...)` on `getSelfMember().getVoiceState().getChannel()`, and update `MusicUtils.connect`/`checkIfConnected` accordingly.
|
|
||||||
- Keep the synchronous facade: `stopTrack`, `setVolume` and `play` subscribe to the returned `Mono`s internally and report failures through `LogHandler`.
|
|
||||||
- Apply the `GSettings.MUSIC_VOLUME` base volume on connect with the existing default-50 fallback.
|
|
||||||
|
|
||||||
### ✓ Step 4: Migrate queue, track loading and message rendering to the Lavalink Track type
|
|
||||||
Tracks are represented by `dev.arbjerg.lavalink.protocol.v4.Track` end to end and loading uses Lavalink's REST resolver.
|
|
||||||
|
|
||||||
- Change `TrackInfo` to wrap `Track` + `Member`, preserving equality-based duplicate rejection.
|
|
||||||
- Update `QueueManager` to drive playback with `createOrUpdatePlayer().setTrack(...)`/`setNoReplace(...)`, dropping `makeClone()` and replacing `player.getPlayingTrack()` checks with a scheduler-maintained `volatile Track currentTrack`.
|
|
||||||
- Reimplement `TrackLoadingHandler` on `AbstractAudioLoadResultHandler`, mapping `onSearchResultLoaded` to the first hit to preserve the `ytsearch:` behaviour.
|
|
||||||
- Update `MessageFunction`, `LoadPlaylistTask` and `LoadTrackFromPlaylistTask` to the new track type.
|
|
||||||
- Make `SourceFinder` return no identifier for Udio URLs and have `MusicManager.play` reply that Udio is temporarily unsupported.
|
|
||||||
|
|
||||||
### ✓ Step 5: Route Lavalink events to TrackScheduler and restore stuck-track recovery
|
|
||||||
Track lifecycle events from the node drive queue advancement, error reporting and stuck recovery exactly as before.
|
|
||||||
|
|
||||||
- Add a central dispatcher in `MusicServiceImpl` subscribing once to `TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent` and `WebSocketClosedEvent`, routing by guild id to the cached `MusicManager`.
|
|
||||||
- Convert `TrackScheduler` from an `AudioEventAdapter` subclass into a plain sink with `onTrackStart`/`onTrackEnd`/`onTrackException`/`onTrackStuck`, keeping the existing `FINISHED`/`LOAD_FAILED`/`STOPPED` branching and `MusicChannelContext` attachment.
|
|
||||||
- Rework `StuckTrackRecoveryHandler` to re-queue the immutable `Track` and seek with `player.setPosition(...)`, retaining the retry-then-skip rule.
|
|
||||||
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest` and `VoteSkipServiceTest` to mock `Link`/`LavalinkPlayer`, and verify `./mvnw verify` passes Checkstyle and the suite.
|
|
||||||
|
|
||||||
### ✓ Step 6: Update documentation and record the Udio plugin follow-up
|
|
||||||
The docs describe the Lavalink-based architecture and the deferred Udio work is explicit.
|
|
||||||
|
|
||||||
- Rewrite the Lavaplayer sections of `docs/music.md` and `docs/architecture.md` to cover the node, the client, the dispatcher and the new settings.
|
|
||||||
- Refresh the music mentions in `docs/README.md`, `docs/threading.md` and `docs/utilities-and-bootstrap.md`.
|
|
||||||
- Document how to run the node locally and how to add server-side plugins via `lavalink/application.yml`.
|
|
||||||
- Add a follow-up note stating that `music/source/UdioAudioSourceManager` and `UdioAudioTrack` remain only as the reference for a future Lavalink server plugin and that Udio playback is disabled until then.
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260906-073726-sbxb
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
Improve the code quality, correctness, and maintainability of `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService` — the core music queue and playback scheduling subsystem.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
**In Scope:**
|
|
||||||
- Fix thread-safety issues (check-then-act race conditions in `QueueManager`)
|
|
||||||
- Simplify confusing queue insertion logic in `QueueManager.queue()`
|
|
||||||
- Separate concerns: extract command-result formatting from queue data operations
|
|
||||||
- Fix `TrackInfo` using wrong logger (`QueueManager.class` instead of `TrackInfo.class`)
|
|
||||||
- Clean up `TrackScheduler.onTrackStuck()` complexity
|
|
||||||
- Add unit tests for `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService`
|
|
||||||
- Expose skip-vote state through `TrackInfo` more cleanly (encapsulate the `Set<String>` skips)
|
|
||||||
|
|
||||||
**Out of Scope:**
|
|
||||||
- Changes to `MusicManager`, `MusicServiceImpl`, or audio source/loading classes
|
|
||||||
- New user-facing features or commands
|
|
||||||
- Changes to `MusicManagerCallback` interface
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
|
|
||||||
**`QueueManager`** (`src/main/java/me/bobert/netneutrality/music/QueueManager.java`, 286 lines):
|
|
||||||
- Manages a `LinkedBlockingDeque<TrackInfo>` with manual `synchronized` blocks
|
|
||||||
- Multiple methods check state outside the synchronized block, then re-check inside (e.g., `clearQueue()` L81 vs L86, `skip()` L197 vs L203, `shuffle()` L229 vs L233, `removeFromQueue()` L254 vs L259) — the outer checks are racy and misleading
|
|
||||||
- `queue()` method (L135-158) has confusing branching: `addFirst` + `add` can result in the same track being added twice when `player.getPlayingTrack() == null && !queue.isEmpty()`
|
|
||||||
- `clearQueue()` calls `callback.onQueueEmpty()` before checking if queue is empty (L79)
|
|
||||||
- `removeTracksFromUser()` iterates the queue outside synchronized (L97), then again inside (L102)
|
|
||||||
- `skip(int)` silently resets `nbTrack` to 1 if it exceeds queue size (L209) — surprising behavior
|
|
||||||
- Mixes queue data operations with `CommandExecutionResult` formatting
|
|
||||||
|
|
||||||
**`TrackScheduler`** (`src/main/java/me/bobert/netneutrality/music/TrackScheduler.java`, 175 lines):
|
|
||||||
- `onTrackStuck()` (L129-173) has deeply nested logic with inline async recovery — hard to follow and test
|
|
||||||
- Circular dependency: creates `QueueManager` in constructor, which takes `this` back
|
|
||||||
- `context` field is accessed from multiple threads but only `attachContext`/`detachContext` are synchronized — `getContext()`, `getDj()` are not
|
|
||||||
|
|
||||||
**`TrackInfo`** (`src/main/java/me/bobert/netneutrality/music/TrackInfo.java`, 83 lines):
|
|
||||||
- Logger incorrectly uses `QueueManager.class` (L26) instead of `TrackInfo.class`
|
|
||||||
- `getSkips()` returns mutable `Set<String>` — breaks encapsulation
|
|
||||||
- Duplicate copyright block (L1-5 and L7-11)
|
|
||||||
|
|
||||||
**`VoteSkipService`** (`src/main/java/me/bobert/netneutrality/music/VoteSkipService.java`, 57 lines):
|
|
||||||
- Relatively clean, but directly calls `queueManager.skip()` mixing vote logic with queue mutation
|
|
||||||
- `info.addSkip()` uses `context.getGuildUser()` but `info.getSkips().size()` check uses raw set — no race protection
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
#### QueueManager
|
|
||||||
1. **Remove all outer unsynchronized checks** — move all logic inside a single `synchronized` block per method to eliminate check-then-act races
|
|
||||||
2. **Rewrite `queue()` method** — simplify to: add to queue, if nothing playing start the head track. Remove the confusing `addFirst`/`add` dual-path
|
|
||||||
3. **Fix `clearQueue()`** — move `callback.onQueueEmpty()` call to after the queue is confirmed non-empty and cleared
|
|
||||||
4. **Fix `skip(int)`** — when `nbTrack > queue.size()`, skip all remaining tracks instead of silently resetting to 1
|
|
||||||
5. **Fix `removeTracksFromUser()`** — do all filtering inside synchronized block
|
|
||||||
6. **Return queue operation results as data** (e.g., number removed, track name) and let callers build `CommandExecutionResult` — or at minimum, document that this class returns command results
|
|
||||||
|
|
||||||
#### TrackScheduler
|
|
||||||
1. **Extract stuck-track recovery** into a dedicated `StuckTrackRecoveryHandler` class to reduce `onTrackStuck()` complexity
|
|
||||||
2. **Make `context` access thread-safe** — synchronize `getContext()` and `getDj()`, or use `volatile`
|
|
||||||
3. **Add `lastStuckTrack` reset** on track start or successful recovery to prevent stale state
|
|
||||||
|
|
||||||
#### TrackInfo
|
|
||||||
1. **Fix logger** — change to `TrackInfo.class`
|
|
||||||
2. **Remove duplicate copyright block**
|
|
||||||
3. **Encapsulate skips** — return `Collections.unmodifiableSet(skips)` from `getSkips()`, add `getSkipCount()` method
|
|
||||||
|
|
||||||
#### VoteSkipService
|
|
||||||
1. **Synchronize vote-skip check** — ensure `addSkip` + threshold check is atomic (or accept eventual consistency and document it)
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
All changes in `src/main/java/me/bobert/netneutrality/music/`:
|
|
||||||
- **Modified:** `QueueManager.java`, `TrackScheduler.java`, `TrackInfo.java`, `VoteSkipService.java`
|
|
||||||
- **New:** `StuckTrackRecoveryHandler.java`
|
|
||||||
- **New tests:** `src/test/java/me/bobert/netneutrality/command/music/QueueManagerTest.java`, `TrackSchedulerTest.java`, `TrackInfoTest.java`, `VoteSkipServiceTest.java`
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
|
|
||||||
### Validation Approach
|
|
||||||
Add unit tests for all four classes using JUnit 5 and Mockito (already in project dependencies via the existing test classes).
|
|
||||||
|
|
||||||
### Key Scenarios
|
|
||||||
- **QueueManager:** queue/dequeue ordering, skip single/multiple, shuffle preserves current track, removeFromQueue bounds, clearQueue idempotency, removeTracksFromUser correctness
|
|
||||||
- **TrackInfo:** skip vote tracking, duplicate skip prevention, unmodifiable skips set, equals/hashCode
|
|
||||||
- **VoteSkipService:** threshold calculation, skip when below min members, vote counting, duplicate vote handling
|
|
||||||
- **TrackScheduler:** onTrackEnd triggers correct queue action per end reason, onTrackStuck recovery flow
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
- Queue with single track: skip, shuffle, remove
|
|
||||||
- Skip count exceeding queue size
|
|
||||||
- Empty queue operations
|
|
||||||
- Concurrent vote-skip from same user
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Fix TrackInfo issues
|
|
||||||
TrackInfo has correct logger, clean copyright, and encapsulated skip state.
|
|
||||||
|
|
||||||
- Fix logger from `QueueManager.class` to `TrackInfo.class` (line 26)
|
|
||||||
- Remove duplicate copyright block (lines 7-11)
|
|
||||||
- Change `getSkips()` to return `Collections.unmodifiableSet(skips)`
|
|
||||||
- Add `getSkipCount()` convenience method
|
|
||||||
- Add `TrackInfoTest.java` covering skip tracking, duplicate prevention, equals/hashCode, and unmodifiable set enforcement
|
|
||||||
|
|
||||||
### ✓ Step 2: Fix QueueManager thread-safety and queue logic
|
|
||||||
QueueManager has correct synchronization and simplified queue insertion.
|
|
||||||
|
|
||||||
- Remove all outer unsynchronized pre-checks in `clearQueue()`, `skip()`, `shuffle()`, `removeFromQueue()`, `removeTracksFromUser()` — consolidate into single synchronized blocks
|
|
||||||
- Rewrite `queue(AudioTrack, Member)` to eliminate the confusing `addFirst`/`add` dual-path: simply add to tail, then start playback if nothing is playing
|
|
||||||
- Fix `clearQueue()` to call `callback.onQueueEmpty()` only after confirming queue was non-empty
|
|
||||||
- Fix `skip(int)` to skip all remaining when `nbTrack > queue.size()` instead of silently resetting to 1
|
|
||||||
- Fix `removeTracksFromUser()` to do all filtering inside synchronized block
|
|
||||||
- Add `QueueManagerTest.java` covering queue ordering, skip, shuffle, remove, clear, and edge cases
|
|
||||||
|
|
||||||
### ✓ Step 3: Extract stuck-track recovery and fix TrackScheduler thread-safety
|
|
||||||
TrackScheduler is simplified with extracted recovery logic and thread-safe context access.
|
|
||||||
|
|
||||||
- Create `StuckTrackRecoveryHandler.java` encapsulating the retry-or-skip logic currently in `onTrackStuck()` (lines 129-173)
|
|
||||||
- Simplify `onTrackStuck()` to delegate to the new handler
|
|
||||||
- Reset `lastStuckTrack` in `onTrackStart()` to prevent stale state
|
|
||||||
- Synchronize `getContext()` and `getDj()` methods (or make `context` volatile)
|
|
||||||
- Add `TrackSchedulerTest.java` covering onTrackEnd behavior per end reason and stuck-track recovery delegation
|
|
||||||
|
|
||||||
### ✓ Step 4: Improve VoteSkipService robustness and add tests
|
|
||||||
VoteSkipService has documented concurrency behavior and full test coverage.
|
|
||||||
|
|
||||||
- Add documentation comment about the vote-then-check atomicity assumption
|
|
||||||
- Add `VoteSkipServiceTest.java` covering: threshold math, auto-skip below min members, vote accumulation, duplicate vote from same user, skip trigger when threshold exceeded
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260909-154047-1h6n
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
Remove the singleton pattern from `Settings` so it becomes a plain instantiable class. All access should go through constructor injection of `SettingsProvider`, except for the single bootstrap site in `App.main()` which creates the instance.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
**In Scope:**
|
|
||||||
- Remove `static INSTANCE` field and `getInstance()` from `Settings`
|
|
||||||
- Remove static helper methods `getDiscordInvite()` and `getVersion()` from `Settings`
|
|
||||||
- Migrate all remaining `Settings.getInstance()` call sites to use injected `SettingsProvider`
|
|
||||||
- Update the test to construct `Settings` directly
|
|
||||||
|
|
||||||
**Out of Scope:**
|
|
||||||
- Changing the `SettingsProvider` interface
|
|
||||||
- Introducing a DI framework
|
|
||||||
- Changing how properties are loaded
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
- `Settings` is a lazy singleton (`getInstance()`) implementing `SettingsProvider`
|
|
||||||
- `SettingsProvider` interface already exists and is injected into most classes via `ApplicationContext`
|
|
||||||
- `App.buildContext()` already registers the Settings instance as `SettingsProvider` in the context
|
|
||||||
- **Remaining direct singleton usages (9 call sites):**
|
|
||||||
- `App.java` — `VERSION` static field (line 51), `main()` bootstrap (lines 61-62), `startup()` (line 89), `buildContext()` (line 112), `initJDA()` (line 190)
|
|
||||||
- `CommandContext.java` — `isMessageFromCreator()` (line 134)
|
|
||||||
- `WebModule.java` — `createRequest()` (line 289) via `Settings.getVersion()`
|
|
||||||
- `ReportBug.java` — `executeTask()` (line 34) via `Settings.getDiscordInvite()`
|
|
||||||
- `SettingsTest.java` — `getMavenVersion()` test (line 17)
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
#### 1. `Settings.java`
|
|
||||||
- Make constructor `public`
|
|
||||||
- Remove `static INSTANCE` field, `getInstance()`, `getDiscordInvite()`, `getVersion()`
|
|
||||||
- Keep `getMavenVersion()` as an instance method
|
|
||||||
- Add `getDiscordInvite()` as an instance method on `SettingsProvider` (or keep it as a convenience on `Settings`)
|
|
||||||
|
|
||||||
#### 2. `App.java`
|
|
||||||
- Create `Settings` via `new Settings()` at the top of `main()`; pass it into `App` constructor or store as a field
|
|
||||||
- Replace `VERSION` static field with an instance field initialized from the settings instance
|
|
||||||
- Thread the `SettingsProvider` through `startup()`, `buildContext()`, `initJDA()`
|
|
||||||
|
|
||||||
#### 3. `CommandContext.java`
|
|
||||||
- Add a `SettingsProvider` field, accept it via constructor parameter
|
|
||||||
- Update `isMessageFromCreator()` to use the injected provider
|
|
||||||
- Update subclasses (`MessageCommandContext`, `SlashCommandContext`, etc.) to pass it through
|
|
||||||
|
|
||||||
#### 4. `WebModule.java`
|
|
||||||
- Accept a version string or `SettingsProvider` in the `Builder`; replace `Settings.getVersion()` call
|
|
||||||
|
|
||||||
#### 5. `ReportBug.java`
|
|
||||||
- Accept `SettingsProvider` via constructor injection; replace `Settings.getDiscordInvite()` with `settings.get("NETNEUTRALITY_BOT_INVITE", ...)`
|
|
||||||
|
|
||||||
#### 6. `SettingsTest.java`
|
|
||||||
- Use `new Settings()` instead of `Settings.getInstance()`
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
- `CommandContext` is abstract with multiple subclasses — all constructors need updating
|
|
||||||
- `WebModule.Builder` is used in many places including inside `Settings.getGitVersion()` itself — the version string approach (pass a `String` rather than the full provider) keeps this simpler
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Remove singleton from Settings class
|
|
||||||
Settings can be instantiated with `new Settings()` and no longer holds static state.
|
|
||||||
|
|
||||||
- Make the `Settings` constructor `public`
|
|
||||||
- Remove the `static INSTANCE` field and `getInstance()` method
|
|
||||||
- Remove static helpers `getDiscordInvite()` and `getVersion()`
|
|
||||||
- Move `getDiscordInvite()` logic to an instance method (or inline at call sites)
|
|
||||||
- Keep `getMavenVersion()` as-is (already instance method)
|
|
||||||
|
|
||||||
### ✓ Step 2: Migrate App.java bootstrap to explicit instantiation
|
|
||||||
App creates and threads a single Settings instance through its lifecycle methods.
|
|
||||||
|
|
||||||
- Create `new Settings()` at the start of `main()` and store as a field on `App`
|
|
||||||
- Replace `Settings.getInstance()` calls in `main()`, `startup()`, `buildContext()`, `initJDA()` with the field
|
|
||||||
- Replace the `VERSION` static field with an instance field or a local derived from the settings instance
|
|
||||||
|
|
||||||
### ✓ Step 3: Inject SettingsProvider into remaining call sites
|
|
||||||
CommandContext, WebModule, and ReportBug no longer reach for the singleton.
|
|
||||||
|
|
||||||
- Add `SettingsProvider` constructor parameter to `CommandContext`; update `isMessageFromCreator()` to use it
|
|
||||||
- Update `CommandContext` subclasses (`MessageCommandContext`, `SlashCommandContext`, etc.) to pass the provider through
|
|
||||||
- In `WebModule.Builder`, accept a version string parameter; replace `Settings.getVersion()` in `createRequest()`
|
|
||||||
- In `ReportBug`, add `SettingsProvider` constructor parameter; replace `Settings.getDiscordInvite()` with `settings.get(...)`
|
|
||||||
- Update `SettingsTest` to use `new Settings()` directly
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260905-145156-xyio
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
|
|
||||||
Revise the package structure section of the lifecycle refactor plan to use a hybrid layout that keeps the new `bootstrap/` package but reverts the rest to the familiar old-style naming and organization.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
**In Scope**
|
|
||||||
- Reverting `framework/` → `core/` (engine: command, context, task machinery)
|
|
||||||
- Reverting `platform/` hierarchy → flat old-style packages: `db/` (persistence + entities + repositories + services), `hooks/` (events), `http/` (web API), `music/` (music domain)
|
|
||||||
- Reverting `feature/` vertical grouping → separate top-level `command/`, `tasks/`, `context/` packages with subcategory folders (e.g. `command/music/`, `tasks/scheduled/`)
|
|
||||||
- Restoring `utils/` for remaining helpers (Settings, JpaUtils, MusicUtils, ChannelMessagePurger)
|
|
||||||
- Keeping `bootstrap/` (ApplicationContext, Lifecycle, BotState, ReflectionHelper) — this is new and stays
|
|
||||||
- Keeping `exceptions/` — unchanged
|
|
||||||
- Keeping `shared/util/` (ContextUtils, GuildUtils, MessageFunction, MiscUtils, UserFunction)
|
|
||||||
|
|
||||||
**Out of Scope**
|
|
||||||
- Any behavioral or DI/lifecycle logic changes — this is purely a package restructure
|
|
||||||
- Changing the DI refactor plan's other sections (Lifecycle contract, ApplicationContext, constructor injection, interfaces)
|
|
||||||
|
|
||||||
### Target Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
me.bobert.netneutrality
|
|
||||||
├── App.java
|
|
||||||
├── bootstrap/ # NEW — keep as-is
|
|
||||||
│ ├── ApplicationContext.java
|
|
||||||
│ ├── BotState.java
|
|
||||||
│ ├── Lifecycle.java
|
|
||||||
│ └── ReflectionHelper.java
|
|
||||||
├── core/ # was framework/ → revert to core/
|
|
||||||
│ ├── command/ # Command, CommandProcessor, CommandRegistry, CommandManager, options/, impl/
|
|
||||||
│ ├── context/ # Context hierarchy, abstract bases
|
|
||||||
│ └── tasks/ # Task, TaskManager, StepTask, PersistableTask, persistence/, trait/, impl/
|
|
||||||
├── command/ # concrete command implementations (was feature/*/command/)
|
|
||||||
│ ├── admin/
|
|
||||||
│ ├── basic/
|
|
||||||
│ ├── bot/
|
|
||||||
│ ├── crypto/
|
|
||||||
│ ├── custom/
|
|
||||||
│ ├── git/
|
|
||||||
│ ├── music/
|
|
||||||
│ └── react/
|
|
||||||
├── context/ # concrete context implementations (was framework/context/command/ + task/)
|
|
||||||
│ ├── command/
|
|
||||||
│ ├── tasks/
|
|
||||||
│ └── MusicChannelContext.java
|
|
||||||
├── tasks/ # concrete task implementations (was feature/*/task/)
|
|
||||||
│ ├── emoji/
|
|
||||||
│ ├── generic/
|
|
||||||
│ ├── git/
|
|
||||||
│ ├── help/
|
|
||||||
│ ├── music/
|
|
||||||
│ ├── reaction/
|
|
||||||
│ └── scheduled/
|
|
||||||
├── db/ # persistence model (was data/ + platform/persistence/)
|
|
||||||
│ ├── entities/ # O* entities
|
|
||||||
│ ├── repository/
|
|
||||||
│ ├── service/ # GuildService, GuildServiceProvider, BlacklistService, etc.
|
|
||||||
│ ├── GSettings.java
|
|
||||||
│ ├── Repository.java
|
|
||||||
│ ├── DatabaseMigration.java
|
|
||||||
│ ├── SessionManager.java # SessionProvider impl
|
|
||||||
│ └── JpaUtils.java
|
|
||||||
├── hooks/ # was platform/event/
|
|
||||||
│ └── EventsHandler.java
|
|
||||||
├── http/ # was platform/web/
|
|
||||||
│ ├── server/
|
|
||||||
│ └── WebModule.java
|
|
||||||
├── music/ # music domain (was feature/music/ minus commands/tasks)
|
|
||||||
│ ├── AudioPlayerSendHandler.java
|
|
||||||
│ ├── MusicManager.java
|
|
||||||
│ ├── MusicService.java
|
|
||||||
│ ├── QueueManager.java
|
|
||||||
│ ├── SourceFinder.java
|
|
||||||
│ ├── source/
|
|
||||||
│ ├── TrackInfo.java
|
|
||||||
│ ├── TrackLoadingHandler.java
|
|
||||||
│ └── TrackScheduler.java
|
|
||||||
├── utils/ # remaining helpers not in shared/util/
|
|
||||||
│ ├── Settings.java # SettingsProvider impl
|
|
||||||
│ ├── ChannelMessagePurger.java
|
|
||||||
│ ├── JpaUtils.java # (or in db/, see note)
|
|
||||||
│ └── MusicUtils.java
|
|
||||||
├── shared/util/ # NEW — keep as-is
|
|
||||||
│ ├── ContextUtils.java
|
|
||||||
│ ├── GuildUtils.java
|
|
||||||
│ ├── MessageFunction.java
|
|
||||||
│ ├── MiscUtils.java
|
|
||||||
│ └── UserFunction.java
|
|
||||||
├── exceptions/ # unchanged
|
|
||||||
└── core/thread/ OR thread/ # ThreadManager, ScheduledTaskManager, BaseThreadFactory
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** `core/thread/` was the old location for threading. The old layout also had a top-level `thread/` for `MusicManagerCleanupThread`. Both can be restored as they were.
|
|
||||||
|
|
||||||
### Key Moves (current → proposed)
|
|
||||||
|
|
||||||
| Current (new) | Proposed (hybrid) |
|
|
||||||
|---|---|
|
|
||||||
| `framework/command/` | `core/command/` |
|
|
||||||
| `framework/context/` | `core/context/` |
|
|
||||||
| `framework/task/` | `core/tasks/` |
|
|
||||||
| `framework/NetNeutralityBot.java` | remove or merge into App |
|
|
||||||
| `platform/config/Settings.java` | `utils/Settings.java` |
|
|
||||||
| `platform/persistence/*` | `db/` (SessionManager, DatabaseMigration, JpaUtils, Repository) |
|
|
||||||
| `platform/concurrency/*` | `core/thread/` |
|
|
||||||
| `platform/logging/LogHandler.java` | `core/LogHandler.java` |
|
|
||||||
| `platform/event/EventsHandler.java` | `hooks/EventsHandler.java` |
|
|
||||||
| `platform/web/*` | `http/*` |
|
|
||||||
| `data/entity/` | `db/entities/` |
|
|
||||||
| `data/repository/` | `db/repository/` |
|
|
||||||
| `data/service/` | `db/service/` |
|
|
||||||
| `feature/music/command/*` | `command/music/` |
|
|
||||||
| `feature/music/*.java` (domain) | `music/` |
|
|
||||||
| `feature/moderation/command/*` | `command/admin/` |
|
|
||||||
| `feature/moderation/ChannelMessagePurger.java` | `utils/ChannelMessagePurger.java` |
|
|
||||||
| `feature/reaction/command/*` | `command/react/` |
|
|
||||||
| `feature/reaction/task/*` | `tasks/reaction/` |
|
|
||||||
| `feature/git/command/*` | `command/git/` |
|
|
||||||
| `feature/git/task/*` | `tasks/git/` |
|
|
||||||
| `feature/crypto/command/*` | `command/crypto/` |
|
|
||||||
| `feature/help/command/*` | `command/basic/` + `command/bot/` |
|
|
||||||
| `feature/help/task/*` | `tasks/help/` |
|
|
||||||
| `feature/custom/command/*` | `command/custom/` |
|
|
||||||
| `bootstrap/` | `bootstrap/` (no change) |
|
|
||||||
| `shared/util/` | `shared/util/` (no change) |
|
|
||||||
| `exceptions/` | `exceptions/` (no change) |
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Implementation
|
|
||||||
|
|
||||||
The codebase currently uses the post-refactor layout with:
|
|
||||||
- `bootstrap/` — ApplicationContext, Lifecycle, BotState, ReflectionHelper
|
|
||||||
- `framework/` — command, context, task engine
|
|
||||||
- `platform/` — config, persistence, concurrency, logging, event, web
|
|
||||||
- `feature/` — music, moderation, reaction, git, crypto, help, custom (feature-vertical grouping)
|
|
||||||
- `data/` — entity, repository, service
|
|
||||||
- `shared/util/` — cross-cutting helpers
|
|
||||||
- `exceptions/`
|
|
||||||
|
|
||||||
All DI/lifecycle wiring (constructor injection, interfaces like `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, `CommandRegistry`, `TaskManager`, `MusicService`, `GuildServiceProvider`) remains intact — only package locations change.
|
|
||||||
|
|
||||||
### Key Decisions
|
|
||||||
1. **Revert engine package name**: `framework/` → `core/` — matches the original naming the team is familiar with.
|
|
||||||
2. **Flatten infrastructure**: dissolve `platform/` hierarchy back to `db/`, `hooks/`, `http/`, `core/thread/`, `utils/` — removes the extra nesting that added cognitive overhead.
|
|
||||||
3. **Separate top-level command/tasks/context**: undo feature-vertical grouping in `feature/` — concrete implementations go back to `command/<category>/`, `tasks/<category>/`, `context/` as before.
|
|
||||||
4. **Keep bootstrap/ and shared/util/**: these are genuinely new and useful additions with no old equivalent.
|
|
||||||
5. **Restore `db/entities/`**: revert `data/entity/` back to `db/entities/` (plural, matching old convention).
|
|
||||||
6. **Service interfaces stay**: `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, etc. remain — they just live in their reverted package locations (e.g. `SettingsProvider` in `utils/` alongside `Settings`).
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
This is a pure move/rename refactor using IDE safe "Move Class" operations. No logic changes. The DI wiring, lifecycle priorities, constructor injection, and all interfaces remain exactly as they are — only their package declarations and import statements change.
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
- **Import churn** — same risk as the original restructure; mitigated by IDE "Move Class" refactor, one subsystem at a time, build after each.
|
|
||||||
- **Interface co-location** — service interfaces (e.g. `SessionProvider`) now live alongside their impls in `db/` or `utils/` rather than in a separate `platform/` package. This is acceptable for this project's size.
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Move engine packages from framework/ back to core/
|
|
||||||
The `framework/` package is renamed back to `core/` with its original subpackage names.
|
|
||||||
|
|
||||||
- Move `framework/command/` → `core/command/` (all engine classes: Command, CommandProcessor, CommandRegistry, CommandManager, CommandCategory, CommandContainer, CommandExecutionResult, CommandOptionsBuilder, impl/, options/)
|
|
||||||
- Move `framework/context/` → `core/context/` (Context, AbstractContext, CommandContext, TaskContext, InteractionContext, MessageContext, NoMessageContext, AsyncContext)
|
|
||||||
- Move `framework/task/` → `core/tasks/` (Task, TaskManager interface + impl, StepTask, PersistableTask, BuiltInTask, ScheduledTask, TaskCleanup, persistence/, trait/, impl/, generic/)
|
|
||||||
- Move `framework/NetNeutralityBot.java` into `core/` or merge into App if it's just a shell
|
|
||||||
- Move `platform/logging/LogHandler.java` → `core/LogHandler.java`
|
|
||||||
- Move `platform/concurrency/*` → `core/thread/` (ThreadManager/ExecutorProvider, ScheduledTaskManager, BaseThreadFactory)
|
|
||||||
- Update all imports; verify build with `./mvnw compile`
|
|
||||||
|
|
||||||
### ✓ Step 2: Move persistence and infrastructure packages back to db/, hooks/, http/, utils/
|
|
||||||
Infrastructure packages are flattened back to their original top-level locations.
|
|
||||||
|
|
||||||
- Move `data/entity/` → `db/entities/` (restore plural naming)
|
|
||||||
- Move `data/repository/` → `db/repository/`
|
|
||||||
- Move `data/service/` → `db/service/` (GuildService, GuildServiceProvider, GuildSettingService, BlacklistService, TaskService)
|
|
||||||
- Move `data/GSettings.java` → `db/GSettings.java`
|
|
||||||
- Move `platform/persistence/` contents → `db/` (SessionManager/SessionProvider, DatabaseMigration, Repository, JpaUtils, SessionProvider interface)
|
|
||||||
- Move `platform/config/Settings.java` + `SettingsProvider` → `utils/`
|
|
||||||
- Move `platform/event/EventsHandler.java` → `hooks/EventsHandler.java`
|
|
||||||
- Move `platform/web/` → `http/` (WebApi, server/, WebModule)
|
|
||||||
- Move `feature/moderation/ChannelMessagePurger.java` → `utils/ChannelMessagePurger.java`
|
|
||||||
- Move `feature/music/MusicUtils.java` → `utils/MusicUtils.java` (if it exists)
|
|
||||||
- Delete empty `platform/` and `data/` directories
|
|
||||||
- Update all imports; verify build with `./mvnw compile`
|
|
||||||
|
|
||||||
### ✓ Step 3: Restore music domain package and split feature/ into command/, tasks/, context/
|
|
||||||
Feature-vertical grouping is dissolved back into separate top-level packages.
|
|
||||||
|
|
||||||
- Move `feature/music/` domain classes (MusicManager, MusicService, QueueManager, AudioPlayerSendHandler, SourceFinder, source/, TrackInfo, TrackLoadingHandler, TrackScheduler, MusicChannelContext) → `music/`
|
|
||||||
- Move concrete context classes from `framework/context/command/` → `context/command/` and `framework/context/task/` → `context/tasks/`; move MusicChannelContext → `context/MusicChannelContext.java`
|
|
||||||
- Move `feature/music/command/*` → `command/music/`
|
|
||||||
- Move `feature/moderation/command/*` → `command/admin/`
|
|
||||||
- Move `feature/reaction/command/*` → `command/react/`
|
|
||||||
- Move `feature/git/command/*` → `command/git/`
|
|
||||||
- Move `feature/crypto/command/*` → `command/crypto/`
|
|
||||||
- Move `feature/help/command/*` → `command/basic/` and `command/bot/` (matching old categorization)
|
|
||||||
- Move `feature/custom/command/*` → `command/custom/`
|
|
||||||
- Move `feature/reaction/task/*` → `tasks/reaction/`
|
|
||||||
- Move `feature/git/task/*` → `tasks/git/`
|
|
||||||
- Move `feature/help/task/*` → `tasks/help/`
|
|
||||||
- Move `feature/music/task/*` → `tasks/music/`
|
|
||||||
- Move scheduled tasks → `tasks/scheduled/`
|
|
||||||
- Delete empty `feature/` directory
|
|
||||||
- Update all imports; verify full build with `./mvnw package`
|
|
||||||
- Update `docs/architecture.md` package map table to reflect the new hybrid layout
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
---
|
|
||||||
sessionId: session-260910-054543-zckm
|
|
||||||
---
|
|
||||||
|
|
||||||
# Requirements
|
|
||||||
|
|
||||||
### Overview & Goals
|
|
||||||
Analyze and fix thread-safety issues and improve the `ExecutorProvider` / `ThreadManager` infrastructure and all code that interacts with it.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
#### In Scope
|
|
||||||
- Fix thread-safety bugs in `ThreadManager`, `TaskManagerImpl`, `ChannelMessagePurger`, `MusicManager`
|
|
||||||
- Improve executor pool configuration (bounded pools, proper shutdown)
|
|
||||||
- Clean up inconsistent synchronization patterns
|
|
||||||
|
|
||||||
#### Out of Scope
|
|
||||||
- Rewriting the task/command architecture
|
|
||||||
- Changing the `QueueManager` synchronization (it's consistent and correct, just verbose)
|
|
||||||
|
|
||||||
# Technical Design
|
|
||||||
|
|
||||||
### Current Issues Found
|
|
||||||
|
|
||||||
#### 1. `ThreadManager` (`core/thread/ThreadManager.java`)
|
|
||||||
- **Unbounded cached thread pools** — `newCachedThreadPool()` creates unlimited threads; under load (e.g., many guilds purging simultaneously) this can exhaust memory.
|
|
||||||
- **Shutdown bug (line 66-69)** — If `awaitTermination` throws `InterruptedException` on the *first* executor, the `catch` block exits the loop and remaining executors only get `shutdownNow()` without a prior `shutdown()` call. Each executor should be shut down independently.
|
|
||||||
- **No daemon threads** — `BaseThreadFactory` doesn't set `thread.setDaemon(true)`, so leaked threads can prevent JVM exit.
|
|
||||||
|
|
||||||
#### 2. `TaskManagerImpl` (`core/tasks/TaskManagerImpl.java`)
|
|
||||||
- **Null values in `ConcurrentHashMap` (line 178-184)** — `computeIfAbsent` returns `null` for `PersistableTask`/`BuiltInTask`, but `ConcurrentHashMap` does **not** permit null values. This will throw `NullPointerException` at runtime.
|
|
||||||
- **Mixed synchronization** — `getTasks()` is `synchronized` on `this`, but all mutations use `ConcurrentHashMap` operations (no `synchronized`). The `synchronized` keyword on `getTasks()` provides no actual protection since `insert()`, `removeTask()`, etc. don't synchronize on `this`.
|
|
||||||
- **`hasTask()` race (line 93-95)** — Check-then-act across two collections (`tasks` and `pendingTasks`) without synchronization.
|
|
||||||
- **`handle()` lock cleanup race (line 191-194)** — After acquiring the lock, it checks `tasks.containsKey()` and removes from `tasksLock`, but another thread could be waiting on the same lock and will find it removed.
|
|
||||||
|
|
||||||
#### 3. `ChannelMessagePurger` (`utils/ChannelMessagePurger.java`)
|
|
||||||
- **Double unlock bug** — `purgeMessages()` acquires `lock` via `tryLock()` (line 68) and releases it in `finally` (line 79). But `safePurge()` also calls `lock.unlock()` (line 154), causing an `IllegalMonitorStateException` or releasing a lock that a *different* thread now holds.
|
|
||||||
- **Non-thread-safe static `HashMap`** (line 40) — `instances` is a plain `HashMap` accessed from multiple threads via `computeIfAbsent`. Should be `ConcurrentHashMap`.
|
|
||||||
- **Static mutable `ExecutorProvider` binding** — The `volatile` field + `bind()` pattern works but is fragile; a null check race exists between `canLockBeAcquired` callers and `purgeMessages`.
|
|
||||||
|
|
||||||
#### 4. `MusicManager` (`music/MusicManager.java`)
|
|
||||||
- **Inconsistent synchronization** — `isConnected()`, `connect()`, `disconnect()`, `play()` are `synchronized`, but `isCurrentlyPlaying()` (line 114), `getCurrentlyPlaying()` (line 110), `stopTrack()` (line 138), `resume()` (line 142) are not. If these are called from event threads concurrently with `disconnect()`, they can see stale state.
|
|
||||||
|
|
||||||
#### 5. `BaseThreadFactory` (`core/thread/BaseThreadFactory.java`)
|
|
||||||
- Threads are not set as daemon threads, which can prevent clean JVM shutdown if an executor leaks.
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
#### `ThreadManager`
|
|
||||||
- Replace `newCachedThreadPool()` with `newFixedThreadPool()` or a bounded `ThreadPoolExecutor` with a reasonable max (e.g., `availableProcessors() * 2`).
|
|
||||||
- Fix shutdown loop: wrap each executor's `shutdown()`/`awaitTermination()` in its own try-catch so one failure doesn't skip the rest.
|
|
||||||
- Consider adding a `getExecutorStatus()` method for observability.
|
|
||||||
|
|
||||||
#### `BaseThreadFactory`
|
|
||||||
- Set `thread.setDaemon(true)` to prevent JVM hang on unclean shutdown.
|
|
||||||
|
|
||||||
#### `TaskManagerImpl`
|
|
||||||
- Fix `computeIfAbsent` null return: use a sentinel or simply don't store an entry for tasks that don't need locking (use `get()` + null check instead).
|
|
||||||
- Remove misleading `synchronized` on `getTasks()` or make all task mutations consistently synchronized.
|
|
||||||
- Guard `hasTask()` with proper synchronization or accept eventual consistency and document it.
|
|
||||||
- Fix lock cleanup race in `handle()` — don't remove from `tasksLock` inside the lock-held section; let `removeTask()` handle cleanup.
|
|
||||||
|
|
||||||
#### `ChannelMessagePurger`
|
|
||||||
- Remove the `lock.unlock()` call on line 154 inside `safePurge()` — the `finally` block in `purgeMessages()` already handles it.
|
|
||||||
- Change `instances` from `HashMap` to `ConcurrentHashMap`.
|
|
||||||
|
|
||||||
#### `MusicManager`
|
|
||||||
- Either synchronize all public methods that touch `player`/`AudioManager` state, or remove `synchronized` from methods where JDA already guarantees thread safety and document the threading model.
|
|
||||||
|
|
||||||
# Delivery Steps
|
|
||||||
|
|
||||||
### ✓ Step 1: Fix ThreadManager and BaseThreadFactory
|
|
||||||
ThreadManager uses bounded pools and has correct shutdown; BaseThreadFactory produces daemon threads.
|
|
||||||
|
|
||||||
- Replace `Executors.newCachedThreadPool()` in `ThreadManager.getExecutor()` with a bounded `ThreadPoolExecutor` (core=0, max=availableProcessors*2, 60s keepAlive, LinkedBlockingQueue).
|
|
||||||
- Fix `stop()` shutdown loop: wrap each executor's `shutdown()`+`awaitTermination()` in its own try-catch so an `InterruptedException` on one executor doesn't skip the rest.
|
|
||||||
- In `BaseThreadFactory.newThread()`, add `thread.setDaemon(true)`.
|
|
||||||
- Verify the `getScheduledExecutor()` pool size is still appropriate.
|
|
||||||
|
|
||||||
### ✓ Step 2: Fix TaskManagerImpl thread-safety bugs
|
|
||||||
TaskManagerImpl has no null-in-ConcurrentHashMap violations and consistent synchronization.
|
|
||||||
|
|
||||||
- Replace the `computeIfAbsent` that returns null (line 178-184) with a pattern that avoids storing null: check task type first, only call `computeIfAbsent` when a lock is actually needed.
|
|
||||||
- Remove the misleading `synchronized` keyword from `getTasks()`, or switch to a consistent locking strategy across all mutation methods.
|
|
||||||
- Fix the lock cleanup race in `handle()`: do not remove from `tasksLock` at line 193; let `removeTask()` be the sole owner of lock cleanup.
|
|
||||||
- Document the threading contract for `hasTask()` (check-then-act across two collections is inherently racy).
|
|
||||||
|
|
||||||
### ✓ Step 3: Fix ChannelMessagePurger concurrency bugs
|
|
||||||
ChannelMessagePurger has no double-unlock and uses thread-safe collections.
|
|
||||||
|
|
||||||
- Remove the redundant `lock.unlock()` call on line 154 in `safePurge()` — the `finally` block in `purgeMessages()` (line 79) already releases the lock.
|
|
||||||
- Change the static `instances` field (line 40) from `HashMap` to `ConcurrentHashMap`.
|
|
||||||
- Remove the `synchronized` keyword from `countAllChannelMessages()` and `safePurge()` since the `ReentrantLock` already provides mutual exclusion.
|
|
||||||
|
|
||||||
### ✓ Step 4: Harmonize MusicManager synchronization
|
|
||||||
MusicManager has a consistent and documented threading model.
|
|
||||||
|
|
||||||
- Add `synchronized` to `isCurrentlyPlaying()`, `getCurrentlyPlaying()`, `stopTrack()`, and `resume()` to match the existing pattern on `connect()`/`disconnect()`/`play()`.
|
|
||||||
- Alternatively, if JDA's `AudioPlayer` is already thread-safe, remove `synchronized` from all methods and add a class-level Javadoc explaining the threading model.
|
|
||||||
- Investigate which approach is correct by checking Lavaplayer's `AudioPlayer` thread-safety guarantees, then apply consistently.
|
|
||||||
+21
-21
@@ -139,24 +139,24 @@ block the others. Effectively this means:
|
|||||||
|
|
||||||
## Package map
|
## Package map
|
||||||
|
|
||||||
| Package | Contents |
|
| Package | Contents |
|
||||||
|---|---|
|
|---------------------------------------------------|---------------------------------------------------------------------------------------------------------|
|
||||||
| `me.bobert.netneutrality` | Entry point (`App`) |
|
| `me.bobert.netneutrality` | Entry point (`App`) |
|
||||||
| `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` |
|
| `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` |
|
||||||
| `…core.command` | Command engine (registry, processor, manager, options, impl bases) |
|
| `…core.command` | Command engine (registry, processor, manager, options, impl bases) |
|
||||||
| `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) |
|
| `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) |
|
||||||
| `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) |
|
| `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) |
|
||||||
| `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) |
|
| `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) |
|
||||||
| `…core` | `LogHandler`, `NetNeutralityBot` |
|
| `…core` | `LogHandler` |
|
||||||
| `…command.*` | Concrete command implementations (`admin`, `basic`, `bot`, `crypto`, `custom`, `git`, `music`, `react`) |
|
| `…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.command` / `…context.tasks` | Concrete context implementations (slash, message, button, modal, emoji, etc.) |
|
||||||
| `…context` | `MusicChannelContext` |
|
| `…context` | `MusicChannelContext` |
|
||||||
| `…tasks.*` | Concrete task implementations (`git`, `help`, `music`, `reaction`) |
|
| `…tasks.*` | Concrete task implementations (`git`, `help`, `music`, `reaction`) |
|
||||||
| `…db` | Session/migration, `Repository`, `JpaUtils`, `GSettings` |
|
| `…db` | Session/migration, `Repository`, `JpaUtils`, `GSettings` |
|
||||||
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
|
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
|
||||||
| `…hooks` | `EventsHandler` |
|
| `…hooks` | `EventsHandler` |
|
||||||
| `…http` | Internal web API and HTTP client module |
|
| `…http` | Internal web API and HTTP client module |
|
||||||
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
|
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
|
||||||
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
|
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
|
||||||
| `…shared.util` | Cross-cutting helpers |
|
| `…shared.util` | Cross-cutting helpers |
|
||||||
| `…exceptions` | Custom exception hierarchy |
|
| `…exceptions` | Custom exception hierarchy |
|
||||||
+10
-5
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
|
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
|
||||||
`me.bobert.netneutrality.db` (entities, repositories, services).
|
`me.bobert.netneutrality.db` (entities, repositories, services).
|
||||||
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`.
|
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`,
|
||||||
|
`db/migration/V2__task_type_id.sql`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ of a star-shaped model; most other tables reference `guild_id` with `ON DELETE C
|
|||||||
| `OLogging` | `logging` | `OLoggingId` (composite) | Composite key `guild_id` + `channel_id`; `@MapsId` to `OGuild`; debug/error log channel config. |
|
| `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. |
|
| `OLoggingId` | — (embeddable) | — | `@Embeddable`/`@IdClass` for the composite key. |
|
||||||
| `OLogMessage` | `log_message` | `id` (auto int) | Many-to-one `OGuild`; message logging history. |
|
| `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`. |
|
| `OTask` | `tasks` | `id` (auto int) | [Persistable task](tasks.md#31-persistable-tasks) state; unique `task_id`, `task_class` holds the logical `typeId`, JSON `payload`. |
|
||||||
| `OWebUser` | `web_users` | `id` (String) | Web dashboard user (tokens/expiry). *Present in schema & code but not listed in `hibernate.cfg.xml`.* |
|
| `OWebUser` | `web_users` | `id` (String) | Web dashboard user (tokens/expiry). *Present in schema & code but not listed in `hibernate.cfg.xml`.* |
|
||||||
|
|
||||||
### ER overview
|
### ER overview
|
||||||
@@ -102,8 +103,9 @@ Services wrap repositories with business logic and **caching**.
|
|||||||
values in `settingsCache` and reloads on change; used by the
|
values in `settingsCache` and reloads on change; used by the
|
||||||
[web API settings endpoints](web-api.md).
|
[web API settings endpoints](web-api.md).
|
||||||
- **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON
|
- **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON
|
||||||
for the `tasks` table and reconstructs them at startup via
|
for the `tasks` table, keyed by the task's stable `typeId()` in `task_class`, and reconstructs them
|
||||||
[`TaskRegistry`/`TaskFactory`](tasks.md#31-persistable-tasks).
|
at startup via [`PersistableTaskRegistry`](tasks.md#persistabletaskregistry--discovery-and-restore).
|
||||||
|
Rows with an unknown `task_class` are logged and skipped.
|
||||||
|
|
||||||
## GSettings
|
## GSettings
|
||||||
|
|
||||||
@@ -125,4 +127,7 @@ role/channel and how `ConfigureCommand` persists changes.
|
|||||||
The initial migration creates the tables backing the entities above (`guilds`, `guild_settings`,
|
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
|
`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
|
`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.
|
added as new `V3__…`, `V4__…` Flyway scripts in the same directory.
|
||||||
|
|
||||||
|
`V2__task_type_id.sql` rewrites `tasks.task_class` from the old fully-qualified class names to the
|
||||||
|
stable logical `typeId` (e.g. `emoji-reaction-role`), so previously persisted rows remain loadable.
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Udio source — follow-up reference
|
|
||||||
|
|
||||||
These files are **not part of the build**. They are the original client-side Lavaplayer
|
|
||||||
implementation of the udio.com source, kept verbatim as the reference for a future port.
|
|
||||||
|
|
||||||
| File | Was |
|
|
||||||
|---|---|
|
|
||||||
| `UdioAudioSourceManager.java` | `me.bobert.netneutrality.music.source.UdioAudioSourceManager` |
|
|
||||||
| `UdioAudioTrack.java` | `me.bobert.netneutrality.music.source.UdioAudioTrack` |
|
|
||||||
| `UdioAudioSourceManagerTest.java` | its unit test |
|
|
||||||
|
|
||||||
## Why they were moved
|
|
||||||
|
|
||||||
The bot no longer decodes audio itself: after the [migration to Lavalink](../../music.md), source
|
|
||||||
resolution happens on the node. A Lavaplayer `AudioSourceManager` living in the bot process can
|
|
||||||
never be used by Lavalink, and these classes cannot even compile now that the `dev.arbjerg:lavaplayer`
|
|
||||||
dependency has been dropped from `pom.xml`.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
**Udio playback is disabled.** `SourceFinder` still classifies Udio URLs, but
|
|
||||||
`SourceFinder.getIdentifier` returns no identifier for them and `MusicManager.play` replies that
|
|
||||||
Udio is temporarily unsupported.
|
|
||||||
|
|
||||||
## Follow-up
|
|
||||||
|
|
||||||
Port `UdioAudioSourceManager` / `UdioAudioTrack` into a standalone **Lavalink server plugin**
|
|
||||||
(its own build and published artifact), then enable it by adding the dependency to
|
|
||||||
`lavalink/application.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
lavalink:
|
|
||||||
plugins:
|
|
||||||
- dependency: "<group>:udio-plugin:<version>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the node resolves `https://www.udio.com/songs/...` identifiers, the only bot-side change is to
|
|
||||||
restore `Udio` in the `SourceFinder.getIdentifier` switch and drop the "temporarily unsupported"
|
|
||||||
reply in `MusicManager.play`.
|
|
||||||
|
|
||||||
The scraping logic itself still applies: the manager extracts metadata and a direct playback URL
|
|
||||||
from the Next.js payload (`self.__next_f.push(...)`) of the track page, and the URL must be
|
|
||||||
re-fetched right before playback because it expires.
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package me.bobert.netneutrality.music.source;
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
|
||||||
import org.apache.commons.io.IOUtils;
|
|
||||||
import org.apache.http.HttpStatus;
|
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
|
||||||
import org.apache.http.client.methods.HttpGet;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
|
||||||
import org.apache.logging.log4j.Logger;
|
|
||||||
|
|
||||||
import java.io.DataInput;
|
|
||||||
import java.io.DataOutput;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
|
|
||||||
|
|
||||||
public class UdioAudioSourceManager implements AudioSourceManager {
|
|
||||||
private static final Logger log = LogManager.getLogger(UdioAudioSourceManager.class);
|
|
||||||
private static final String TRACK_URL_REGEX = "^https://www.udio.com/songs/[0-9A-Za-z]+$";
|
|
||||||
private static final Pattern trackUrlPattern = Pattern.compile(TRACK_URL_REGEX);
|
|
||||||
private static final Pattern NEXT_PUSH_PATTERN = Pattern.compile(
|
|
||||||
"self\\.__next_f\\.push\\(\\[(\\d+),\"(.*?)\"]\\)",
|
|
||||||
Pattern.DOTALL
|
|
||||||
);
|
|
||||||
private final HttpInterfaceManager httpInterfaceManager;
|
|
||||||
|
|
||||||
public UdioAudioSourceManager() {
|
|
||||||
httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getSourceName() {
|
|
||||||
return "udio";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference) {
|
|
||||||
if (!trackUrlPattern.matcher(reference.identifier).matches()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) {
|
|
||||||
return loadFromTrackPage(httpInterface, reference.identifier);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new FriendlyException("Loading Udio track information failed.", SUSPICIOUS, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isTrackEncodable(AudioTrack track) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void encodeTrack(AudioTrack track, DataOutput output) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) {
|
|
||||||
return new UdioAudioTrack(trackInfo, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void shutdown() {
|
|
||||||
ExceptionTools.closeWithWarnings(httpInterfaceManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpInterface getHttpInterface() {
|
|
||||||
return httpInterfaceManager.getInterface();
|
|
||||||
}
|
|
||||||
|
|
||||||
private AudioItem loadFromTrackPage(HttpInterface httpInterface, String trackUrl) throws IOException {
|
|
||||||
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackUrl))) {
|
|
||||||
int statusCode = response.getStatusLine().getStatusCode();
|
|
||||||
|
|
||||||
if (statusCode == HttpStatus.SC_NOT_FOUND) {
|
|
||||||
return AudioReference.NO_TRACK;
|
|
||||||
} else if (!HttpClientTools.isSuccessWithContent(statusCode)) {
|
|
||||||
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
|
|
||||||
new IllegalStateException("Response code is " + statusCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadTrackFromPageContent(trackUrl, IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private AudioTrack loadTrackFromPageContent(String trackUrl, String content) throws IOException {
|
|
||||||
JsonBrowser trackInfo = loadTrackInfoFromPageContent(content);
|
|
||||||
|
|
||||||
if (trackInfo == null) {
|
|
||||||
log.debug("Udio HTML: {}", content);
|
|
||||||
throw new FriendlyException("Udio: Failed to extract track information from web page.", SUSPICIOUS, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
String title = trackInfo.get("title").text();
|
|
||||||
String artist = trackInfo.get("artist").text();
|
|
||||||
long duration = (long) (trackInfo.get("duration").as(Double.class) * 1000.0);
|
|
||||||
String playbackUrl = trackInfo.get("song_path").text();
|
|
||||||
String artworkUrl = getArtworkUrlFromImagePath(trackInfo);
|
|
||||||
|
|
||||||
AudioTrackInfo audioTrackInfo = new AudioTrackInfo(title, artist, duration, trackUrl, false, playbackUrl, artworkUrl, null);
|
|
||||||
return new UdioAudioTrack(audioTrackInfo, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected JsonBrowser loadTrackInfoFromPageContent(String content) throws IOException {
|
|
||||||
Matcher matcher = NEXT_PUSH_PATTERN.matcher(content);
|
|
||||||
while (matcher.find()) {
|
|
||||||
String payload = matcher.group(2);
|
|
||||||
JsonBrowser trackInfo = tryExtractTrackFromNextChunk(payload);
|
|
||||||
|
|
||||||
if (trackInfo != null) {
|
|
||||||
return trackInfo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonBrowser tryExtractTrackFromNextChunk(String payload) {
|
|
||||||
try {
|
|
||||||
int trackKeyIndex = payload.indexOf("\\\"track\\\":{");
|
|
||||||
if (trackKeyIndex < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
int objectStart = payload.indexOf('{', trackKeyIndex);
|
|
||||||
if (objectStart < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String escapedTrackJson = extractJsonObjectFromString(payload, objectStart);
|
|
||||||
if (escapedTrackJson == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String trackJson = unescapeJsonStringFragment(escapedTrackJson);
|
|
||||||
JsonBrowser track = JsonBrowser.parse(trackJson);
|
|
||||||
|
|
||||||
if (isValidTrack(track)) {
|
|
||||||
return track;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("Skipping unparsable track payload fragment", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts a JSON object from a given string starting at the specified index. This method parses the string
|
|
||||||
* to locate and retrieve the JSON object, ensuring proper handling of nested structures and escaped characters.
|
|
||||||
*
|
|
||||||
* @param text The input string containing the JSON object to be extracted.
|
|
||||||
* @param startIndex The index at which to start searching for the JSON object within the input string.
|
|
||||||
* @return The extracted JSON object as a string, or {@code null} if no valid JSON object is found.
|
|
||||||
*/
|
|
||||||
private String extractJsonObjectFromString(String text, int startIndex) {
|
|
||||||
boolean escaped = false;
|
|
||||||
boolean inString = false;
|
|
||||||
int depth = 0;
|
|
||||||
|
|
||||||
for (int i = startIndex; i < text.length(); i++) {
|
|
||||||
char c = text.charAt(i);
|
|
||||||
|
|
||||||
if (escaped) {
|
|
||||||
escaped = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '\\') {
|
|
||||||
escaped = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '"') {
|
|
||||||
inString = !inString;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inString) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '{') {
|
|
||||||
depth++;
|
|
||||||
} else if (c == '}') {
|
|
||||||
depth--;
|
|
||||||
if (depth == 0) {
|
|
||||||
return text.substring(startIndex, i + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String unescapeJsonStringFragment(String value) {
|
|
||||||
return value.replace("\\\"", "\"").replace("\\\\", "\\");
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isValidTrack(JsonBrowser track) {
|
|
||||||
return track != null
|
|
||||||
&& !track.isNull()
|
|
||||||
&& !track.get("title").isNull()
|
|
||||||
&& !track.get("artist").isNull()
|
|
||||||
&& !track.get("duration").isNull()
|
|
||||||
&& !track.get("song_path").isNull()
|
|
||||||
&& !track.get("image_path").isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getArtworkUrlFromImagePath(JsonBrowser trackInfo) {
|
|
||||||
String imagePath = trackInfo.get("image_path").text();
|
|
||||||
String artworkOptions = "width=3840,quality=75";
|
|
||||||
|
|
||||||
return imagePath.replace("public", artworkOptions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package me.bobert.netneutrality.music.source;
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
|
|
||||||
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.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
|
|
||||||
public class UdioAudioSourceManagerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void loadItem() {
|
|
||||||
AudioReference reference = new AudioReference("https://www.udio.com/songs/eNmZxqpMt9MRrjFUgYDgyi", null);
|
|
||||||
|
|
||||||
UdioAudioSourceManager udioAudioSourceManager = new UdioAudioSourceManager();
|
|
||||||
AudioItem loaded = udioAudioSourceManager.loadItem(null, reference);
|
|
||||||
|
|
||||||
assertNotNull(loaded);
|
|
||||||
|
|
||||||
UdioAudioTrack audioTrack = (UdioAudioTrack) loaded;
|
|
||||||
AudioTrackInfo info = audioTrack.getInfo();
|
|
||||||
|
|
||||||
assertNotNull(info);
|
|
||||||
|
|
||||||
assertNotNull(info.artworkUrl);
|
|
||||||
|
|
||||||
String artworkOptions = "width=3840,quality=75";
|
|
||||||
assertTrue(info.artworkUrl.contains(artworkOptions));
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package me.bobert.netneutrality.music.source;
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.container.mp3.Mp3AudioTrack;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor;
|
|
||||||
import org.apache.commons.io.IOUtils;
|
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
|
||||||
import org.apache.http.client.methods.HttpGet;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
|
||||||
import org.apache.logging.log4j.Logger;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
|
|
||||||
|
|
||||||
public class UdioAudioTrack extends DelegatedAudioTrack {
|
|
||||||
|
|
||||||
private static final Logger log = LogManager.getLogger(UdioAudioTrack.class);
|
|
||||||
|
|
||||||
private final UdioAudioSourceManager sourceManager;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param trackInfo Track info
|
|
||||||
* @param sourceManager Source manager which was used to find this track
|
|
||||||
*/
|
|
||||||
public UdioAudioTrack(AudioTrackInfo trackInfo, UdioAudioSourceManager sourceManager) {
|
|
||||||
super(trackInfo);
|
|
||||||
|
|
||||||
this.sourceManager = sourceManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void process(LocalAudioTrackExecutor localExecutor) throws Exception {
|
|
||||||
try (HttpInterface httpInterface = sourceManager.getHttpInterface()) {
|
|
||||||
String playbackUrl = loadPlaybackUrl(httpInterface);
|
|
||||||
|
|
||||||
log.debug("Starting Udio track from URL: {}", playbackUrl);
|
|
||||||
|
|
||||||
try (PersistentHttpStream stream = new PersistentHttpStream(httpInterface, new URI(playbackUrl), null)) {
|
|
||||||
processDelegate(new Mp3AudioTrack(trackInfo, stream), localExecutor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String loadPlaybackUrl(HttpInterface httpInterface) throws IOException {
|
|
||||||
JsonBrowser trackInfo = loadTrackInfo(httpInterface);
|
|
||||||
|
|
||||||
if (trackInfo == null) {
|
|
||||||
throw new FriendlyException("Udio: Failed to extract track information from web page.", SUSPICIOUS, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trackInfo.get("song_path").text();
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonBrowser loadTrackInfo(HttpInterface httpInterface) throws IOException {
|
|
||||||
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackInfo.identifier))) {
|
|
||||||
int statusCode = response.getStatusLine().getStatusCode();
|
|
||||||
|
|
||||||
if (!HttpClientTools.isSuccessWithContent(statusCode)) {
|
|
||||||
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
|
|
||||||
new IllegalStateException("Response code for player config is " + statusCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sourceManager.loadTrackInfoFromPageContent(IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+45
-69
@@ -111,94 +111,70 @@ under `tasks/generic`).
|
|||||||
## 3.1 Persistable tasks
|
## 3.1 Persistable tasks
|
||||||
|
|
||||||
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
|
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
|
||||||
`core.tasks.persistence.TaskRegistry`, `core.tasks.persistence.TaskFactory`;
|
`core.tasks.persistence.PersistableTaskRegistry`; entity
|
||||||
resource `src/main/resources/persistable-tasks.json`; entity
|
|
||||||
[`OTask`](persistence.md#entity-catalog).
|
[`OTask`](persistence.md#entity-catalog).
|
||||||
|
|
||||||
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup. The
|
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup.
|
||||||
rules for *how* a persistable task can be defined and reconstructed live in `TaskRegistry` and
|
There is **no mapping file** — task types are discovered on the classpath and each task knows how to
|
||||||
`TaskFactory`.
|
rebuild itself.
|
||||||
|
|
||||||
### The mapping file — `persistable-tasks.json`
|
### The contract
|
||||||
|
|
||||||
The file declares, for each persistable task class, the constructor parameters and their types:
|
```java
|
||||||
|
public interface PersistableTask extends Task {
|
||||||
```json
|
default String typeId() { return getName(); } // stable logical id stored in the DB
|
||||||
{
|
void restore(JSONObject payload); // rebuild state from a previous toPayload()
|
||||||
"tasks": [
|
void insert(CommandContext context);
|
||||||
{
|
void save();
|
||||||
"class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask",
|
void delete();
|
||||||
"params": ["guildId", "channelId", "messageId", "emojiMapping"],
|
String toPayload();
|
||||||
"types": ["string", "string", "string", "map"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema:
|
- **`typeId()`** is the value written to `tasks.task_class`. It defaults to `getName()`, so a package
|
||||||
|
move or class rename never breaks restoring existing rows.
|
||||||
|
- **`restore(JSONObject)`** is the counterpart of `toPayload()`. `AbstractPersistableTask` provides a
|
||||||
|
no-op default, so only tasks that carry extra state override it.
|
||||||
|
- **`toPayload()`** serializes the bean getters (`new JSONObject(this)`) and always includes `id` and
|
||||||
|
`lockId`, which `TaskService.validateTask` verifies against the stored row.
|
||||||
|
|
||||||
| Key | Meaning |
|
### `PersistableTaskRegistry` — discovery and restore
|
||||||
|----------|------------------------------------------------------------------|
|
|
||||||
| `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`.
|
`PersistableTaskRegistry.scan()` runs once during `TaskManagerImpl.init()` and uses the same
|
||||||
|
`ReflectionHelper.loadTasks(..., ApplicationContext, ...)` discovery used for
|
||||||
|
[built-in](#32-built-in-tasks) and [scheduled](#34-scheduled-tasks) tasks: every concrete
|
||||||
|
`PersistableTask` under `me.bobert.netneutrality` is instantiated once and indexed by its `typeId()`.
|
||||||
|
Two types declaring the same id fail fast at startup with both class names in the message.
|
||||||
|
|
||||||
### `TaskRegistry` — loading the mappings
|
`create(typeId, payload)` then produces a **fresh** instance per row:
|
||||||
|
|
||||||
`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
|
```java
|
||||||
taskCreators.put(className, payload -> {
|
PersistableTask task = ReflectionHelper.instantiate(type, context);
|
||||||
try {
|
task.restore(payload);
|
||||||
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
|
Because instantiation goes through the DI context, a restored task needs a constructor whose
|
||||||
creator via `TaskRegistry.getTaskCreator(className)`.
|
parameters are all resolvable from the `ApplicationContext` — in practice `(TaskManager)`. Fields
|
||||||
|
that only exist in the payload (ids, mappings) are assigned by `restore()` before the instance is
|
||||||
|
handed to the `TaskManager`.
|
||||||
|
|
||||||
### `TaskFactory` — reconstructing an instance
|
> **Adding a persistable task:** write **one class** extending `AbstractPersistableTask` (or
|
||||||
|
> `EmojiTask`), give it a `(TaskManager)` constructor for the registry plus whatever constructors the
|
||||||
`TaskFactory.createTaskInstance(className, params, types, payload)`:
|
> feature code needs, and override `restore()` if it holds state beyond the bean getters. Nothing
|
||||||
|
> else has to be registered — the scan picks it up and `getName()` becomes its `typeId()`.
|
||||||
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
|
### Persistence round-trip
|
||||||
|
|
||||||
```
|
```
|
||||||
insert(task) ──▶ TaskService serializes payload ──▶ OTask row (task_id, class, payload JSON)
|
insert(task) ──▶ TaskService.persistTask ──▶ OTask row (task_id, task_class = typeId, payload JSON)
|
||||||
startup ──▶ TaskService reads OTask rows ──▶ TaskRegistry.getTaskCreator(class)
|
startup ──▶ PersistableTaskRegistry.scan() (typeId ──▶ class)
|
||||||
.fromPayload(payload) ──▶ TaskFactory ──▶ instance
|
──▶ TaskService reads OTask rows ──▶ registry.create(task_class, payload)
|
||||||
|
──▶ DI instantiate ──▶ task.restore(payload)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Rows whose `task_class` is not a registered `typeId` are logged and skipped; the remaining rows still
|
||||||
|
load.
|
||||||
|
|
||||||
## 3.2 Built-in tasks
|
## 3.2 Built-in tasks
|
||||||
|
|
||||||
**Classes:** `BuiltInTask`, `AbstractBuiltInTask`.
|
**Classes:** `BuiltInTask`, `AbstractBuiltInTask`.
|
||||||
@@ -296,8 +272,8 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in
|
|||||||
`EmojiReactionRoleTask`.
|
`EmojiReactionRoleTask`.
|
||||||
4. That `EmojiReactionRoleTask` is `insert`ed into `TaskManager` — because it is a
|
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
|
[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)
|
on the next startup via the
|
||||||
mapping.
|
[`PersistableTaskRegistry`](#persistabletaskregistry--discovery-and-restore).
|
||||||
|
|
||||||
### Worked example — GitLab bug report (modal)
|
### Worked example — GitLab bug report (modal)
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ error tracking, the shared utility helpers, and the custom exception hierarchy.
|
|||||||
|
|
||||||
**Package:** `me.bobert.netneutrality` / `me.bobert.netneutrality.bootstrap`
|
**Package:** `me.bobert.netneutrality` / `me.bobert.netneutrality.bootstrap`
|
||||||
|
|
||||||
| Class | Role |
|
| 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). |
|
| `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.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()`. |
|
| `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. |
|
||||||
| `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
|
The full startup and shutdown sequences are documented in
|
||||||
[architecture.md](architecture.md#startup-lifecycle).
|
[architecture.md](architecture.md#startup-lifecycle).
|
||||||
@@ -36,15 +35,15 @@ resolve via `context.get(type)` is preferred; otherwise the no-arg constructor i
|
|||||||
|
|
||||||
**Interfaces used for injection** (implemented by the concrete managers registered above):
|
**Interfaces used for injection** (implemented by the concrete managers registered above):
|
||||||
|
|
||||||
| Interface | Typical implementor |
|
| Interface | Typical implementor |
|
||||||
|---|---|
|
|-----------------------------------------|---------------------------------------|
|
||||||
| `platform.config.SettingsProvider` | `utils.Settings` |
|
| `platform.config.SettingsProvider` | `utils.Settings` |
|
||||||
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
|
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
|
||||||
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
|
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
|
||||||
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
|
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
|
||||||
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
|
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
|
||||||
| `feature.music.MusicService` | `music.MusicServiceImpl` |
|
| `feature.music.MusicService` | `music.MusicServiceImpl` |
|
||||||
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
|
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
|
||||||
|
|
||||||
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
|
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
|
||||||
static access points left are:
|
static access points left are:
|
||||||
@@ -75,19 +74,19 @@ file and overlays **environment variables** on top (environment variables win).
|
|||||||
|
|
||||||
An example configuration file is `bot.properties.example`. Key configuration values:
|
An example configuration file is `bot.properties.example`. Key configuration values:
|
||||||
|
|
||||||
| Key | Purpose |
|
| Key | Purpose |
|
||||||
|---|---|
|
|------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
|
||||||
| `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. |
|
| `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. |
|
||||||
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). |
|
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). |
|
||||||
| `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). |
|
| `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). |
|
||||||
| `NETNEUTRALITY_BOT_OWNER` | Bot owner user id (used for owner-only commands / permissions). |
|
| `NETNEUTRALITY_BOT_OWNER` | Bot owner user id (used for owner-only commands / permissions). |
|
||||||
| `NETNEUTRALITY_BOT_INVITE` | Invite link surfaced by help/info. |
|
| `NETNEUTRALITY_BOT_INVITE` | Invite link surfaced by help/info. |
|
||||||
| `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). |
|
| `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). |
|
||||||
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
|
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
|
||||||
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
|
| `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)). |
|
| `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_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`. |
|
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. |
|
||||||
|
|
||||||
## Utility helpers
|
## Utility helpers
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -33,7 +33,8 @@ a path prefix.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
|
| `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). |
|
| `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. |
|
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown. |
|
||||||
|
| `SettingsHandler` | `/settings` | `/settings/catalog` — every known setting with its default value and description; `/settings/current/{guildId}` — current guild settings; `/settings/reload/{guildId}` — invalidate & reload the settings cache. |
|
||||||
|
|
||||||
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the
|
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
|
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<maven.compiler.source>25</maven.compiler.source>
|
<maven.compiler.source>25</maven.compiler.source>
|
||||||
<maven.compiler.target>25</maven.compiler.target>
|
<maven.compiler.target>25</maven.compiler.target>
|
||||||
<mockito.version>5.23.0</mockito.version>
|
<mockito.version>5.23.0</mockito.version>
|
||||||
|
<testcontainers.version>2.0.5</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -29,6 +30,27 @@
|
|||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.14.1</version>
|
||||||
|
<configuration>
|
||||||
|
<release>${java.version}</release>
|
||||||
|
<proc>full</proc>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-processor</artifactId>
|
||||||
|
<version>7.1.9.Final</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>io.javalin.community.openapi</groupId>
|
||||||
|
<artifactId>openapi-annotation-processor</artifactId>
|
||||||
|
<version>7.2.3</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
@@ -44,8 +66,11 @@
|
|||||||
<artifactId>flyway-maven-plugin</artifactId>
|
<artifactId>flyway-maven-plugin</artifactId>
|
||||||
<version>11.20.3</version>
|
<version>11.20.3</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
|
<!--suppress UnresolvedMavenProperty -->
|
||||||
<url>${env.FLYWAY_URL}</url>
|
<url>${env.FLYWAY_URL}</url>
|
||||||
|
<!--suppress UnresolvedMavenProperty -->
|
||||||
<user>${env.FLYWAY_USER}</user>
|
<user>${env.FLYWAY_USER}</user>
|
||||||
|
<!--suppress UnresolvedMavenProperty -->
|
||||||
<password>${env.FLYWAY_PASSWORD}</password>
|
<password>${env.FLYWAY_PASSWORD}</password>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
@@ -82,6 +107,30 @@
|
|||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<finalName>netneutrality</finalName>
|
<finalName>netneutrality</finalName>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>*:*</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>module-info.class</exclude>
|
||||||
|
<exclude>META-INF/versions/*/module-info.class</exclude>
|
||||||
|
<exclude>META-INF/MANIFEST.MF</exclude>
|
||||||
|
<exclude>META-INF/LICENSE*</exclude>
|
||||||
|
<exclude>META-INF/NOTICE*</exclude>
|
||||||
|
<exclude>META-INF/DEPENDENCIES</exclude>
|
||||||
|
<exclude>META-INF/jandex.idx</exclude>
|
||||||
|
<exclude>META-INF/versions/9/OSGI-INF/MANIFEST.MF</exclude>
|
||||||
|
<exclude>META-INF/*.SF</exclude>
|
||||||
|
<exclude>META-INF/*.DSA</exclude>
|
||||||
|
<exclude>META-INF/*.RSA</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
<filter>
|
||||||
|
<artifact>com.squareup.okhttp3:okhttp</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>**</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
<transformers>
|
<transformers>
|
||||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
<mainClass>${main.class}</mainClass>
|
<mainClass>${main.class}</mainClass>
|
||||||
@@ -162,7 +211,28 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.javalin</groupId>
|
<groupId>io.javalin</groupId>
|
||||||
<artifactId>javalin</artifactId>
|
<artifactId>javalin</artifactId>
|
||||||
<version>6.7.0</version>
|
<version>7.2.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.javalin.community.openapi</groupId>
|
||||||
|
<artifactId>openapi-specification</artifactId>
|
||||||
|
<version>7.2.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.javalin.community.openapi</groupId>
|
||||||
|
<artifactId>javalin-openapi-plugin</artifactId>
|
||||||
|
<version>7.2.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.javalin.community.openapi</groupId>
|
||||||
|
<artifactId>openapi-generator</artifactId>
|
||||||
|
<version>7.2.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.javalin.community.openapi</groupId>
|
||||||
|
<artifactId>javalin-swagger-plugin</artifactId>
|
||||||
|
<version>7.2.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -175,6 +245,11 @@
|
|||||||
<artifactId>hibernate-hikaricp</artifactId>
|
<artifactId>hibernate-hikaricp</artifactId>
|
||||||
<version>7.1.9.Final</version>
|
<version>7.1.9.Final</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.data</groupId>
|
||||||
|
<artifactId>jakarta.data-api</artifactId>
|
||||||
|
<version>1.0.1</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.mysql</groupId>
|
<groupId>com.mysql</groupId>
|
||||||
<artifactId>mysql-connector-j</artifactId>
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
@@ -202,7 +277,7 @@
|
|||||||
<artifactId>gitlab4j-api</artifactId>
|
<artifactId>gitlab4j-api</artifactId>
|
||||||
<version>6.2.0</version>
|
<version>6.2.0</version>
|
||||||
<exclusions>
|
<exclusions>
|
||||||
<!-- Clashes with the Servlet 5 API used by Javalin's Jetty 11 in the shaded jar. -->
|
<!-- Clashes with the Servlet API used by Javalin's Jetty 12 in the shaded jar. -->
|
||||||
<exclusion>
|
<exclusion>
|
||||||
<groupId>jakarta.servlet</groupId>
|
<groupId>jakarta.servlet</groupId>
|
||||||
<artifactId>jakarta.servlet-api</artifactId>
|
<artifactId>jakarta.servlet-api</artifactId>
|
||||||
@@ -217,14 +292,9 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.json</groupId>
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
<artifactId>json</artifactId>
|
<artifactId>jackson-databind</artifactId>
|
||||||
<version>20250517</version>
|
<version>2.21.2</version>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.google.code.gson</groupId>
|
|
||||||
<artifactId>gson</artifactId>
|
|
||||||
<version>2.14.0</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -278,6 +348,18 @@
|
|||||||
<version>${mockito.version}</version>
|
<version>${mockito.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||||
|
<version>${testcontainers.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-mysql</artifactId>
|
||||||
|
<version>${testcontainers.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@@ -28,7 +28,12 @@ import me.bobert.netneutrality.core.thread.ExecutorProvider;
|
|||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
import me.bobert.netneutrality.utils.ChannelMessagePurger;
|
import me.bobert.netneutrality.utils.ChannelMessagePurger;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
|
import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
|
||||||
|
import me.bobert.netneutrality.db.repository.BlacklistRepository;
|
||||||
|
import me.bobert.netneutrality.db.repository.GuildRepository;
|
||||||
|
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
|
||||||
|
import me.bobert.netneutrality.db.repository.TaskRepository;
|
||||||
import me.bobert.netneutrality.utils.Settings;
|
import me.bobert.netneutrality.utils.Settings;
|
||||||
import me.bobert.netneutrality.http.WebModule;
|
import me.bobert.netneutrality.http.WebModule;
|
||||||
import net.dv8tion.jda.api.JDA;
|
import net.dv8tion.jda.api.JDA;
|
||||||
@@ -94,7 +99,9 @@ public class App {
|
|||||||
context.start();
|
context.start();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LogHandler.log(e);
|
LogHandler.log(e);
|
||||||
System.exit(1);
|
// Roll back the components that did start so no non-daemon thread keeps the JVM alive.
|
||||||
|
context.stop();
|
||||||
|
throw new IllegalStateException("Startup failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
|
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
|
||||||
@@ -112,9 +119,11 @@ public class App {
|
|||||||
// Allow ApplicationContext itself to be injected into components that need it.
|
// Allow ApplicationContext itself to be injected into components that need it.
|
||||||
context.register(context);
|
context.register(context);
|
||||||
|
|
||||||
// Persistence: build the SessionFactory + run migrations; released last on shutdown.
|
// Persistence: run migrations + build the SessionFactory; released last on shutdown.
|
||||||
// SessionManager binds itself into JpaUtils for repository access.
|
SessionManager sessions = context.register(new SessionManager(settings));
|
||||||
context.register(new SessionManager(settings));
|
GuildRepository guildRepository = new GuildRepository(sessions);
|
||||||
|
BlacklistRepository blacklistRepository = new BlacklistRepository(sessions);
|
||||||
|
GuildSettingRepository guildSettingRepository = new GuildSettingRepository(sessions);
|
||||||
|
|
||||||
// Executors: no explicit start today; shut down after music players are disconnected.
|
// Executors: no explicit start today; shut down after music players are disconnected.
|
||||||
ExecutorProvider executors = context.register(new ThreadManager());
|
ExecutorProvider executors = context.register(new ThreadManager());
|
||||||
@@ -128,12 +137,14 @@ public class App {
|
|||||||
CommandRegistry commands = context.register(new CommandManager(context));
|
CommandRegistry commands = context.register(new CommandManager(context));
|
||||||
|
|
||||||
// Task registry (priority 30).
|
// Task registry (priority 30).
|
||||||
TaskManager tasks = context.register(new TaskManagerImpl(context));
|
TaskManager tasks = context.register(new TaskManagerImpl(context, new TaskRepository(sessions)));
|
||||||
|
|
||||||
// Per-guild service cache (not a Lifecycle component).
|
// Per-guild service cache (not a Lifecycle component).
|
||||||
GuildServiceProvider guildServices = context.register(new GuildServiceProviderImpl());
|
GuildServiceProvider guildServices = context.register(
|
||||||
|
new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository));
|
||||||
LogHandler.bind(guildServices);
|
LogHandler.bind(guildServices);
|
||||||
JpaUtils.bindGuildServiceLookup(guildServices::get);
|
UserFunction.bind(guildServices);
|
||||||
|
DisabledCommandPrecondition.bind(guildServices);
|
||||||
|
|
||||||
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
|
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
|
||||||
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
|
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
|
||||||
@@ -150,7 +161,7 @@ public class App {
|
|||||||
}, () -> { }));
|
}, () -> { }));
|
||||||
|
|
||||||
// Web API (priority 70) — Lifecycle with graceful stop.
|
// Web API (priority 70) — Lifecycle with graceful stop.
|
||||||
context.register(new WebApi(settings, guildServices, this::getJDA, this::shutdown));
|
context.register(new WebApi(settings, guildServices, music, this::getJDA, this::shutdown));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
|
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
|
||||||
@@ -206,8 +217,11 @@ public class App {
|
|||||||
.build();
|
.build();
|
||||||
jda.awaitReady();
|
jda.awaitReady();
|
||||||
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
|
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
|
||||||
|
if (e instanceof InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
LogHandler.log(e);
|
LogHandler.log(e);
|
||||||
System.exit(1);
|
throw new IllegalStateException("Unable to connect to Discord", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ package me.bobert.netneutrality.bootstrap;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
import me.bobert.netneutrality.db.service.GuildService;
|
import me.bobert.netneutrality.db.service.GuildService;
|
||||||
import me.bobert.netneutrality.music.MusicService;
|
import me.bobert.netneutrality.music.MusicService;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.music.TrackScheduler;
|
import me.bobert.netneutrality.music.TrackScheduler;
|
||||||
import org.json.JSONArray;
|
import me.bobert.netneutrality.utils.Json;
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileWriter;
|
import java.io.FileWriter;
|
||||||
@@ -41,10 +42,10 @@ public class BotState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, GuildService> services = guildServices.getServices();
|
Map<String, GuildService> services = guildServices.snapshot();
|
||||||
|
|
||||||
JSONObject main = new JSONObject();
|
ObjectNode main = Json.object();
|
||||||
JSONArray guildsArray = new JSONArray();
|
ArrayNode guildsArray = Json.array();
|
||||||
|
|
||||||
for (MusicManager manager : musicService.getManagers()) {
|
for (MusicManager manager : musicService.getManagers()) {
|
||||||
TrackScheduler trackScheduler = manager.getScheduler();
|
TrackScheduler trackScheduler = manager.getScheduler();
|
||||||
@@ -53,9 +54,9 @@ public class BotState {
|
|||||||
for (GuildService service : services.values()) {
|
for (GuildService service : services.values()) {
|
||||||
|
|
||||||
|
|
||||||
JSONObject guild = new JSONObject();
|
ObjectNode guild = Json.object();
|
||||||
JSONObject music = new JSONObject();
|
ObjectNode music = Json.object();
|
||||||
guild.put("id", service.getEntity().getGuildId());
|
guild.put("id", service.getGuildId());
|
||||||
|
|
||||||
// MessageChannel currentChannel = trackScheduler.getTextChannel();
|
// MessageChannel currentChannel = trackScheduler.getTextChannel();
|
||||||
// if (currentChannel == null) {
|
// if (currentChannel == null) {
|
||||||
@@ -64,12 +65,12 @@ public class BotState {
|
|||||||
// music.put("current_channel", currentChannel.getId());
|
// music.put("current_channel", currentChannel.getId());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
guild.put("music", music);
|
guild.set("music", music);
|
||||||
|
|
||||||
guildsArray.put(guild);
|
guildsArray.add(guild);
|
||||||
}
|
}
|
||||||
|
|
||||||
main.put("guilds", guildsArray);
|
main.set("guilds", guildsArray);
|
||||||
|
|
||||||
File state = new File("state.json");
|
File state = new File("state.json");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,16 +10,20 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
|||||||
import me.bobert.netneutrality.core.command.impl.AdminCommand;
|
import me.bobert.netneutrality.core.command.impl.AdminCommand;
|
||||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.db.entities.OBlacklist;
|
import me.bobert.netneutrality.db.service.BlacklistEntry;
|
||||||
|
import me.bobert.netneutrality.db.service.BlacklistResult;
|
||||||
import me.bobert.netneutrality.db.service.BlacklistService;
|
import me.bobert.netneutrality.db.service.BlacklistService;
|
||||||
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
|
||||||
|
|
||||||
public class BlacklistCommand extends AdminCommand {
|
public class BlacklistCommand extends AdminCommand {
|
||||||
|
private static final Logger logger = LogManager.getLogger(BlacklistCommand.class);
|
||||||
|
|
||||||
private final GuildServiceProvider guildServices;
|
private final GuildServiceProvider guildServices;
|
||||||
|
|
||||||
@@ -64,16 +68,16 @@ public class BlacklistCommand extends AdminCommand {
|
|||||||
public CommandExecutionResult execute(CommandContext context) {
|
public CommandExecutionResult execute(CommandContext context) {
|
||||||
String sub = context.getSubcommand();
|
String sub = context.getSubcommand();
|
||||||
String word = context.getOption("word", "", CommandOption::getAsString);
|
String word = context.getOption("word", "", CommandOption::getAsString);
|
||||||
BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService();
|
BlacklistService service = guildServices.get(context.getGuild().getId()).getBlacklistService();
|
||||||
|
|
||||||
switch (sub) {
|
switch (sub) {
|
||||||
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
|
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
|
||||||
List<OBlacklist> blacklists = service.getBlacklistForGuild();
|
List<BlacklistEntry> blacklists = service.list();
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("List of blacklisted words for this server: \n");
|
sb.append("List of blacklisted words for this server: \n");
|
||||||
for (OBlacklist bl : blacklists) {
|
for (BlacklistEntry bl : blacklists) {
|
||||||
sb.append(bl.getWord()).append(" -> ").append(bl.getEnabled() ? "enabled" : "disabled").append("\n");
|
sb.append(bl.word()).append(" -> ").append(bl.enabled() ? "enabled" : "disabled").append("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blacklists.isEmpty()) {
|
if (blacklists.isEmpty()) {
|
||||||
@@ -83,13 +87,32 @@ public class BlacklistCommand extends AdminCommand {
|
|||||||
asyncMessage.edit(sb.toString());
|
asyncMessage.edit(sb.toString());
|
||||||
});
|
});
|
||||||
case "add" -> {
|
case "add" -> {
|
||||||
return service.add(context.getGuildUser(), word);
|
try {
|
||||||
|
return toReply(service.add(context.getGuildUser().getId(), word), word);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Failed to add word to blacklist", e);
|
||||||
|
return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "remove" -> {
|
case "remove" -> {
|
||||||
return service.delete(word);
|
try {
|
||||||
|
return toReply(service.remove(word), word);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Failed to remove word from blacklist", e);
|
||||||
|
return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return CommandExecutionResult.none();
|
return CommandExecutionResult.none();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static CommandExecutionResult toReply(BlacklistResult result, String word) {
|
||||||
|
return switch (result) {
|
||||||
|
case ADDED, REENABLED -> CommandExecutionResult.success("Added the word [%s] to the blacklist.", word);
|
||||||
|
case ALREADY_EXISTS -> CommandExecutionResult.warn(String.format("The word [%s] is already in the blacklist.", word));
|
||||||
|
case REMOVED -> CommandExecutionResult.success("Removed the word [%s] from the blacklist.", word);
|
||||||
|
case NOT_FOUND -> CommandExecutionResult.warn(String.format("The word [%s] is not in the blacklist.", word));
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ import me.bobert.netneutrality.core.tasks.TaskManager;
|
|||||||
import me.bobert.netneutrality.tasks.help.BotInfo;
|
import me.bobert.netneutrality.tasks.help.BotInfo;
|
||||||
import me.bobert.netneutrality.tasks.help.CommandHelp;
|
import me.bobert.netneutrality.tasks.help.CommandHelp;
|
||||||
import me.bobert.netneutrality.tasks.help.ReportBug;
|
import me.bobert.netneutrality.tasks.help.ReportBug;
|
||||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
import me.bobert.netneutrality.utils.MessageFunction;
|
||||||
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
||||||
import net.dv8tion.jda.api.components.buttons.Button;
|
import net.dv8tion.jda.api.components.buttons.Button;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
|
|||||||
@@ -12,19 +12,27 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
|
|||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.db.GSettings;
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
import me.bobert.netneutrality.db.service.GuildService;
|
import me.bobert.netneutrality.db.service.GuildService;
|
||||||
|
import me.bobert.netneutrality.db.service.GuildSettingService;
|
||||||
import net.dv8tion.jda.api.entities.Role;
|
import net.dv8tion.jda.api.entities.Role;
|
||||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||||
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
|
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
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.db.service.GuildServiceProvider;
|
||||||
import me.bobert.netneutrality.music.MusicService;
|
import me.bobert.netneutrality.music.MusicService;
|
||||||
|
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public class ConfigureCommand extends AdminCommand {
|
public class ConfigureCommand extends AdminCommand {
|
||||||
|
private static final Logger logger = LogManager.getLogger(ConfigureCommand.class);
|
||||||
|
|
||||||
private final GuildServiceProvider guildServices;
|
private final GuildServiceProvider guildServices;
|
||||||
private final MusicService musicService;
|
private final MusicService musicService;
|
||||||
@@ -70,85 +78,201 @@ public class ConfigureCommand extends AdminCommand {
|
|||||||
new SubcommandData("music-role", "Role needed to use music commands")
|
new SubcommandData("music-role", "Role needed to use music commands")
|
||||||
.addOption(OptionType.ROLE, "role", "Name of role", true),
|
.addOption(OptionType.ROLE, "role", "Name of role", true),
|
||||||
new SubcommandData("music-channel", "Only allow music commands on this channel")
|
new SubcommandData("music-channel", "Only allow music commands on this channel")
|
||||||
.addOption(OptionType.CHANNEL, "channel", "Leave empty to allow everywhere")
|
.addOption(OptionType.CHANNEL, "channel", "Leave empty to allow everywhere"),
|
||||||
|
new SubcommandData("music-max-playlist-size", "Playlist size above which confirmation is asked before queueing")
|
||||||
|
.addOptions(new OptionData(OptionType.INTEGER, "size", "Number of tracks", true)
|
||||||
|
.setRequiredRange(1, 1000)),
|
||||||
|
new SubcommandData("music-dj-role", "Role that may control music playback without being the current DJ")
|
||||||
|
.addOption(OptionType.ROLE, "role", "Leave empty to clear"),
|
||||||
|
new SubcommandData("music-vote-skip-threshold", "Percentage of listeners required to approve a vote-skip")
|
||||||
|
.addOptions(new OptionData(OptionType.INTEGER, "percent", "Percentage (1-100)", true)
|
||||||
|
.setRequiredRange(1, 100)),
|
||||||
|
new SubcommandData("music-idle-timeout", "Minutes of inactivity before the bot auto-disconnects")
|
||||||
|
.addOptions(new OptionData(OptionType.INTEGER, "minutes", "Number of minutes", true)
|
||||||
|
.setRequiredRange(1, 1440)),
|
||||||
|
new SubcommandData("command-prefix", "Override the bot's message command prefix on this server")
|
||||||
|
.addOption(OptionType.STRING, "prefix", "Leave empty to use the bot-wide default"),
|
||||||
|
new SubcommandData("admin-role", "Role granted admin permissions, in addition to Discord permissions")
|
||||||
|
.addOption(OptionType.ROLE, "role", "Leave empty to clear"),
|
||||||
|
new SubcommandData("disabled-commands", "Comma-separated list of command names disabled on this server")
|
||||||
|
.addOption(OptionType.STRING, "commands", "Leave empty to re-enable everything"),
|
||||||
|
new SubcommandData("disable-command", "Disable a single command on this server")
|
||||||
|
.addOption(OptionType.STRING, "name", "Name of the command to disable", true),
|
||||||
|
new SubcommandData("enable-command", "Re-enable a single command on this server")
|
||||||
|
.addOption(OptionType.STRING, "name", "Name of the command to re-enable", true),
|
||||||
|
new SubcommandData("log-level", "Minimum severity of events sent to the log channel")
|
||||||
|
.addOptions(new OptionData(OptionType.STRING, "level", "Severity level", true)
|
||||||
|
.addChoice("ERROR", "ERROR").addChoice("WARN", "WARN").addChoice("INFO", "INFO")),
|
||||||
|
new SubcommandData("locale", "Preferred locale for the bot's replies on this server")
|
||||||
|
.addOption(OptionType.STRING, "locale", "Locale code, e.g. en, fr", true),
|
||||||
|
new SubcommandData("timezone", "Timezone used to display timestamps on this server")
|
||||||
|
.addOption(OptionType.STRING, "timezone", "Timezone id, e.g. UTC, Europe/Paris", true),
|
||||||
|
new SubcommandData("music-search-engine", "Default search engine used when a play query is not a direct url")
|
||||||
|
.addOptions(new OptionData(OptionType.STRING, "engine", "Search engine", true)
|
||||||
|
.addChoice("YouTube", "YOUTUBE").addChoice("YouTube Music", "YTMUSIC").addChoice("SoundCloud", "SOUNDCLOUD"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandExecutionResult execute(CommandContext context) {
|
public CommandExecutionResult execute(CommandContext context) {
|
||||||
|
try {
|
||||||
|
return configure(context);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Failed to update configuration", e);
|
||||||
|
return CommandExecutionResult.warn("Failed to update configuration: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configure(CommandContext context) {
|
||||||
String sub = context.getSubcommand();
|
String sub = context.getSubcommand();
|
||||||
GuildService guildService = guildServices.get(context.getGuild().getId());
|
GuildService guildService = guildServices.get(context.getGuild().getId());
|
||||||
|
GuildSettingService settingService = guildService.getGuildSettingService();
|
||||||
context.ephemeral();
|
context.ephemeral();
|
||||||
|
|
||||||
if ("music-role".equals(sub)) {
|
return switch (sub) {
|
||||||
Role role = context.getOption("role", CommandOption::getAsRole);
|
case "music-role" -> configureMusicRole(context, settingService);
|
||||||
assert role != null;
|
case "music-channel" -> configureMusicChannel(context, settingService);
|
||||||
|
case "music-volume" -> configureMusicVolume(context, settingService);
|
||||||
|
case "error-logging" -> configureErrorLogging(context, guildService);
|
||||||
|
case "music-max-playlist-size" -> configureIntSetting(context, settingService, GSettings.MUSIC_MAXPLAYLISTSIZE, "size", "Maximum playlist size before confirmation is now %d");
|
||||||
|
case "music-dj-role" -> configureRoleSetting(context, settingService, GSettings.MUSIC_DJ_ROLE, "No dedicated DJ role is set anymore", "Successfully set %s as the DJ role");
|
||||||
|
case "music-vote-skip-threshold" -> configureIntSetting(context, settingService, GSettings.MUSIC_VOTE_SKIP_THRESHOLD, "percent", "Vote-skip threshold is now %d%%");
|
||||||
|
case "music-idle-timeout" -> configureIntSetting(context, settingService, GSettings.MUSIC_IDLE_TIMEOUT, "minutes", "Idle disconnect timeout is now %d minutes");
|
||||||
|
case "command-prefix" -> configureStringSetting(context, settingService, GSettings.COMMAND_PREFIX, "prefix", "Command prefix is now reset to the bot-wide default", "Command prefix is now set to %s");
|
||||||
|
case "admin-role" -> configureRoleSetting(context, settingService, GSettings.ADMIN_ROLE, "No dedicated admin role is set anymore", "Successfully set %s as the admin role");
|
||||||
|
case "disabled-commands" -> configureStringSetting(context, settingService, GSettings.DISABLED_COMMANDS, "commands", "All commands are now re-enabled", "Disabled commands are now: %s");
|
||||||
|
case "disable-command" -> toggleDisabledCommand(context, settingService, true);
|
||||||
|
case "enable-command" -> toggleDisabledCommand(context, settingService, false);
|
||||||
|
case "log-level" -> configureStringSetting(context, settingService, GSettings.LOG_LEVEL, "level", "Log level reset to default", "Log level is now %s");
|
||||||
|
case "locale" -> configureStringSetting(context, settingService, GSettings.LOCALE, "locale", "Locale reset to default", "Locale is now %s");
|
||||||
|
case "timezone" -> configureStringSetting(context, settingService, GSettings.TIMEZONE, "timezone", "Timezone reset to default", "Timezone is now %s");
|
||||||
|
case "music-search-engine" -> configureStringSetting(context, settingService, GSettings.MUSIC_SEARCH_ENGINE, "engine", "Default search engine reset to YouTube", "Default search engine is now %s");
|
||||||
|
case "show-all" -> showAll(context, settingService);
|
||||||
|
default -> CommandExecutionResult.error("Unknown subcommand: " + sub);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (role.getPosition() == -1) {
|
private CommandExecutionResult configureIntSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String successFormat) {
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
|
int value = context.getOption(optionName, Integer.parseInt(setting.getDefaultValue()), CommandOption::getAsInt);
|
||||||
|
settingService.set(setting, String.valueOf(value));
|
||||||
|
|
||||||
return CommandExecutionResult.success("Everyone can now use music commands");
|
return CommandExecutionResult.success(successFormat, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role.getGuild().getId().equals(context.getGuild().getId())) {
|
private CommandExecutionResult configureStringSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String clearedMessage, String successFormat) {
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, role.getId());
|
String value = context.getOption(optionName, "", CommandOption::getAsString);
|
||||||
|
|
||||||
return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
|
if (value.isEmpty()) {
|
||||||
}
|
settingService.set(setting, null);
|
||||||
|
return CommandExecutionResult.success(clearedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("music-channel".equals(sub)) {
|
settingService.set(setting, value);
|
||||||
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
|
return CommandExecutionResult.success(successFormat, value);
|
||||||
|
}
|
||||||
|
|
||||||
if (channel == null) {
|
private CommandExecutionResult toggleDisabledCommand(CommandContext context, GuildSettingService settingService, boolean disable) {
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, null);
|
String name = context.getOption("name", "", CommandOption::getAsString).trim();
|
||||||
|
|
||||||
return CommandExecutionResult.success("No channel restrictions for music commands");
|
Set<String> disabledCommands = Arrays.stream(settingService.getOrDefault(GSettings.DISABLED_COMMANDS).split(","))
|
||||||
}
|
.map(String::trim)
|
||||||
|
.filter(value -> !value.isEmpty())
|
||||||
if (channel instanceof TextChannel) {
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
if (channel.getGuild().getId().equals(context.getGuild().getId())) {
|
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, channel.getId());
|
if (disable) {
|
||||||
|
if (disabledCommands.stream().noneMatch(value -> value.equalsIgnoreCase(name))) {
|
||||||
return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention()));
|
disabledCommands.add(name);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
disabledCommands.removeIf(value -> value.equalsIgnoreCase(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
settingService.set(GSettings.DISABLED_COMMANDS, disabledCommands.isEmpty() ? null : String.join(",", disabledCommands));
|
||||||
|
|
||||||
|
return CommandExecutionResult.success(disable ? "Command %s is now disabled on this server" : "Command %s is now re-enabled on this server", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configureRoleSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String clearedMessage, String successFormat) {
|
||||||
|
Role role = context.getOption("role", CommandOption::getAsRole);
|
||||||
|
|
||||||
|
if (role == null) {
|
||||||
|
settingService.set(setting, null);
|
||||||
|
return CommandExecutionResult.success(clearedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
settingService.set(setting, role.getId());
|
||||||
|
return CommandExecutionResult.success(successFormat, role.getAsMention());
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configureMusicRole(CommandContext context, GuildSettingService settingService) {
|
||||||
|
Role role = context.getOption("role", CommandOption::getAsRole);
|
||||||
|
assert role != null;
|
||||||
|
|
||||||
|
if (role.getPosition() == -1) {
|
||||||
|
settingService.set(GSettings.MUSIC_ROLE, null);
|
||||||
|
|
||||||
|
return CommandExecutionResult.success("Everyone can now use music commands");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.getGuild().getId().equals(context.getGuild().getId())) {
|
||||||
|
settingService.set(GSettings.MUSIC_ROLE, role.getId());
|
||||||
|
|
||||||
|
return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommandExecutionResult.error("Unknown subcommand: music-role");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configureMusicChannel(CommandContext context, GuildSettingService settingService) {
|
||||||
|
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
|
||||||
|
|
||||||
|
if (channel == null) {
|
||||||
|
settingService.set(GSettings.MUSIC_CHANNEL, null);
|
||||||
|
|
||||||
|
return CommandExecutionResult.success("No channel restrictions for music commands");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel instanceof TextChannel && channel.getGuild().getId().equals(context.getGuild().getId())) {
|
||||||
|
settingService.set(GSettings.MUSIC_CHANNEL, channel.getId());
|
||||||
|
|
||||||
|
return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommandExecutionResult.error("Unknown subcommand: music-channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configureMusicVolume(CommandContext context, GuildSettingService settingService) {
|
||||||
|
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
|
||||||
|
settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume));
|
||||||
|
musicService.get(context.getGuild()).setVolume(volume);
|
||||||
|
|
||||||
|
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configureErrorLogging(CommandContext context, GuildService guildService) {
|
||||||
|
boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
|
||||||
|
guildService.updateDebugMode(debug);
|
||||||
|
|
||||||
|
return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult showAll(CommandContext context, GuildSettingService settingService) {
|
||||||
|
Map<String, String> settings = settingService.snapshot();
|
||||||
|
MessageCreateBuilder builder = new MessageCreateBuilder();
|
||||||
|
|
||||||
|
for (GSettings o : GSettings.values()) {
|
||||||
|
String value = o.getDefaultValue();
|
||||||
|
|
||||||
|
if (settings.containsKey(o.getName())) {
|
||||||
|
value = settings.get(o.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder.addContent(o.getName()).addContent(" (").addContent(o.getDescription()).addContent("): ").addContent(value);
|
||||||
|
builder.addContent("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("music-volume".equals(sub)) {
|
context.replyEphemeral(builder.build());
|
||||||
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
|
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume));
|
|
||||||
musicService.get(context.getGuild()).setVolume(volume);
|
|
||||||
|
|
||||||
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
|
return CommandExecutionResult.success("Displayed current configuration");
|
||||||
}
|
|
||||||
|
|
||||||
if ("error-logging".equals(sub)) {
|
|
||||||
boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
|
|
||||||
guildService.updateDebugMode(debug);
|
|
||||||
|
|
||||||
return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("show-all".equals(sub)) {
|
|
||||||
Map<String, String> settings = guildService.getGuildSettingService().getSettings();
|
|
||||||
MessageCreateBuilder builder = new MessageCreateBuilder();
|
|
||||||
|
|
||||||
for (GSettings o : GSettings.values()) {
|
|
||||||
String value = o.getDefaultValue();
|
|
||||||
|
|
||||||
if (settings.containsKey(o.getName())) {
|
|
||||||
value = settings.get(o.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.addContent(o.getName()).addContent(": ").addContent(value);
|
|
||||||
builder.addContent("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
context.replyEphemeral(builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
return CommandExecutionResult.error("Unknown subcommand: " + sub);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ package me.bobert.netneutrality.command.crypto;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import me.bobert.netneutrality.core.LogHandler;
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.command.CommandCategory;
|
import me.bobert.netneutrality.core.command.CommandCategory;
|
||||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
@@ -15,7 +17,6 @@ import me.bobert.netneutrality.core.context.CommandContext;
|
|||||||
import me.bobert.netneutrality.http.WebModule;
|
import me.bobert.netneutrality.http.WebModule;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
@@ -69,7 +70,7 @@ public class CryptoPriceCommand extends AbstractCommand {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandExecutionResult execute(CommandContext context) {
|
public CommandExecutionResult execute(CommandContext context) {
|
||||||
Locale usa = new Locale("en", "US");
|
Locale usa = Locale.US;
|
||||||
NumberFormat dollarFormat = NumberFormat.getCurrencyInstance(usa);
|
NumberFormat dollarFormat = NumberFormat.getCurrencyInstance(usa);
|
||||||
dollarFormat.setMaximumFractionDigits(24);
|
dollarFormat.setMaximumFractionDigits(24);
|
||||||
|
|
||||||
@@ -91,14 +92,19 @@ public class CryptoPriceCommand extends AbstractCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WebModule http = new WebModule.Builder(String.format(baseURL, symbol, finalCurrency)).cache(30, TimeUnit.MINUTES).build().request();
|
WebModule http = new WebModule.Builder(String.format(baseURL, symbol, finalCurrency)).cache(30, TimeUnit.MINUTES).build().request();
|
||||||
JSONObject json = http.getAsObject();
|
ObjectNode json = http.getAsObject();
|
||||||
|
|
||||||
if (json.isEmpty() || (json.has("Response") && json.getString("Response").equalsIgnoreCase("Error"))) {
|
if (json.isEmpty() || json.path("Response").asText("").equalsIgnoreCase("Error")) {
|
||||||
asyncMessage.edit("Crypto doesn't exist.. yet");
|
asyncMessage.edit("Crypto doesn't exist.. yet");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
double value = json.getDouble(finalCurrency);
|
JsonNode priceNode = json.path(finalCurrency);
|
||||||
|
if (!priceNode.isNumber()) {
|
||||||
|
throw new IllegalStateException("Missing price for currency " + finalCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
double value = priceNode.asDouble();
|
||||||
String price = dollarFormat.format(value);
|
String price = dollarFormat.format(value);
|
||||||
|
|
||||||
asyncMessage.edit(String.format("The current price of %s in %s is %s", symbol, finalCurrency, price));
|
asyncMessage.edit(String.format("The current price of %s in %s is %s", symbol, finalCurrency, price));
|
||||||
@@ -115,12 +121,12 @@ public class CryptoPriceCommand extends AbstractCommand {
|
|||||||
private boolean validateSymbol(String symbol) {
|
private boolean validateSymbol(String symbol) {
|
||||||
try {
|
try {
|
||||||
WebModule http = new WebModule.Builder(exchangeApi).get().cache(1, TimeUnit.DAYS).build().request();
|
WebModule http = new WebModule.Builder(exchangeApi).get().cache(1, TimeUnit.DAYS).build().request();
|
||||||
JSONObject json = http.getAsObject();
|
ObjectNode json = http.getAsObject();
|
||||||
|
|
||||||
JSONObject pairs = json.getJSONObject("Data")
|
JsonNode pairs = json.path("Data")
|
||||||
.getJSONObject("exchanges")
|
.path("exchanges")
|
||||||
.getJSONObject(exchange)
|
.path(exchange)
|
||||||
.getJSONObject("pairs");
|
.path("pairs");
|
||||||
|
|
||||||
return pairs.has(symbol);
|
return pairs.has(symbol);
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ public class ClearQueueCommand extends MusicCommand {
|
|||||||
context.ack();
|
context.ack();
|
||||||
|
|
||||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||||
return manager.getQueueManager().clearQueue();
|
return CommandExecutionResult.from(manager.getQueueManager().clearQueue());
|
||||||
}
|
}
|
||||||
|
|
||||||
return CommandExecutionResult.none();
|
return CommandExecutionResult.none();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
|||||||
import me.bobert.netneutrality.music.MusicService;
|
import me.bobert.netneutrality.music.MusicService;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
import me.bobert.netneutrality.utils.MessageFunction;
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
|
|
||||||
public class CurrentlyPlayingCommand extends MusicCommand {
|
public class CurrentlyPlayingCommand extends MusicCommand {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import me.bobert.netneutrality.music.MusicService;
|
|||||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
import me.bobert.netneutrality.utils.MessageFunction;
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
@@ -83,18 +83,18 @@ public class QueueCommand extends MusicCommand {
|
|||||||
|
|
||||||
switch (sub) {
|
switch (sub) {
|
||||||
case "clear-all" -> {
|
case "clear-all" -> {
|
||||||
return manager.getQueueManager().clearQueue();
|
return CommandExecutionResult.from(manager.getQueueManager().clearQueue());
|
||||||
}
|
}
|
||||||
|
|
||||||
case "delete-track" -> {
|
case "delete-track" -> {
|
||||||
int position = context.getOption("position", 0, CommandOption::getAsInt);
|
int position = context.getOption("position", 0, CommandOption::getAsInt);
|
||||||
return manager.getQueueManager().removeFromQueue(position);
|
return CommandExecutionResult.from(manager.getQueueManager().removeFromQueue(position));
|
||||||
}
|
}
|
||||||
|
|
||||||
case "show-queue" -> context.reply(MessageFunction.buildViewQueueMessage(manager));
|
case "show-queue" -> context.reply(MessageFunction.buildViewQueueMessage(manager));
|
||||||
|
|
||||||
case "shuffle" -> {
|
case "shuffle" -> {
|
||||||
return manager.getQueueManager().shuffle();
|
return CommandExecutionResult.from(manager.getQueueManager().shuffle());
|
||||||
}
|
}
|
||||||
|
|
||||||
case "from-user" -> {
|
case "from-user" -> {
|
||||||
@@ -112,7 +112,7 @@ public class QueueCommand extends MusicCommand {
|
|||||||
return CommandExecutionResult.warn("The user isn't from this guild");
|
return CommandExecutionResult.warn("The user isn't from this guild");
|
||||||
}
|
}
|
||||||
|
|
||||||
return manager.getQueueManager().removeTracksFromUser(member);
|
return CommandExecutionResult.from(manager.getQueueManager().removeTracksFromUser(member));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public class ResumeCommand extends MusicCommand {
|
|||||||
MusicManager manager = musicService.get(context.getGuild());
|
MusicManager manager = musicService.get(context.getGuild());
|
||||||
|
|
||||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||||
return manager.resume();
|
return CommandExecutionResult.from(manager.resume());
|
||||||
}
|
}
|
||||||
|
|
||||||
return CommandExecutionResult.none();
|
return CommandExecutionResult.none();
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public class ShuffleCommand extends MusicCommand {
|
|||||||
context.ack();
|
context.ack();
|
||||||
|
|
||||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||||
return manager.getQueueManager().shuffle();
|
return CommandExecutionResult.from(manager.getQueueManager().shuffle());
|
||||||
}
|
}
|
||||||
|
|
||||||
return CommandExecutionResult.none();
|
return CommandExecutionResult.none();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
|
|||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
|
|
||||||
@@ -65,9 +65,9 @@ public class SkipCommand extends MusicCommand {
|
|||||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||||
int skips = context.getOption("skip", 1, CommandOption::getAsInt);
|
int skips = context.getOption("skip", 1, CommandOption::getAsInt);
|
||||||
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
|
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
|
||||||
return manager.getQueueManager().skip(skips);
|
return CommandExecutionResult.from(manager.getQueueManager().skip(skips));
|
||||||
} else {
|
} else {
|
||||||
return manager.voteSkip(context);
|
return CommandExecutionResult.from(manager.voteSkip(context.getGuildUser()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import me.bobert.netneutrality.music.MusicService;
|
|||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
|
|
||||||
public class StopCommand extends MusicCommand {
|
public class StopCommand extends MusicCommand {
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
|||||||
import me.bobert.netneutrality.music.MusicService;
|
import me.bobert.netneutrality.music.MusicService;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
import me.bobert.netneutrality.utils.MessageFunction;
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
|
|
||||||
public class ViewQueueCommand extends MusicCommand {
|
public class ViewQueueCommand extends MusicCommand {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
|
|||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public class VoteSkipCommand extends MusicCommand {
|
|||||||
MusicManager manager = musicService.get(context.getGuild());
|
MusicManager manager = musicService.get(context.getGuild());
|
||||||
|
|
||||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||||
return manager.voteSkip(context);
|
return CommandExecutionResult.from(manager.voteSkip(context.getGuildUser()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return CommandExecutionResult.none();
|
return CommandExecutionResult.none();
|
||||||
|
|||||||
+211
-190
@@ -10,6 +10,7 @@ import me.bobert.netneutrality.core.command.options.MessageCommandOption;
|
|||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.core.context.MessageContext;
|
import me.bobert.netneutrality.core.context.MessageContext;
|
||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
import net.dv8tion.jda.api.entities.Mentions;
|
import net.dv8tion.jda.api.entities.Mentions;
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
@@ -22,11 +23,20 @@ import net.dv8tion.jda.api.interactions.commands.OptionType;
|
|||||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.EnumSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.StringJoiner;
|
import java.util.StringJoiner;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class MessageCommandContext extends CommandContext implements MessageContext {
|
public class MessageCommandContext extends CommandContext implements MessageContext {
|
||||||
|
private static final Pattern INTEGER_PATTERN = Pattern.compile("[-+]?\\d+");
|
||||||
|
private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(\\.\\d+)?");
|
||||||
|
private static final Pattern MENTION_PATTERN = Pattern.compile("<(@[!&]?|#)\\d+>");
|
||||||
|
private static final Set<OptionType> MENTION_TYPES =
|
||||||
|
EnumSet.of(OptionType.USER, OptionType.ROLE, OptionType.MENTIONABLE, OptionType.CHANNEL);
|
||||||
|
private static final OptionData LEFTOVER_ARGUMENTS = new OptionData(OptionType.STRING, "arguments", "arguments");
|
||||||
|
|
||||||
private final MessageReceivedEvent event;
|
private final MessageReceivedEvent event;
|
||||||
private final Message message;
|
private final Message message;
|
||||||
|
|
||||||
@@ -61,7 +71,6 @@ public class MessageCommandContext extends CommandContext implements MessageCont
|
|||||||
@Override
|
@Override
|
||||||
public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
|
public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
|
||||||
List<CommandOption> options = new ArrayList<>();
|
List<CommandOption> options = new ArrayList<>();
|
||||||
int required = commandOptions.stream().filter(OptionData::isRequired).toList().size();
|
|
||||||
Mentions mentions = message.getMentions();
|
Mentions mentions = message.getMentions();
|
||||||
int roleMentionIndex = 0;
|
int roleMentionIndex = 0;
|
||||||
int userMentionIndex = 0;
|
int userMentionIndex = 0;
|
||||||
@@ -69,239 +78,251 @@ public class MessageCommandContext extends CommandContext implements MessageCont
|
|||||||
int channelMentionIndex = 0;
|
int channelMentionIndex = 0;
|
||||||
int index = 0;
|
int index = 0;
|
||||||
|
|
||||||
for (OptionData data : commandOptions) {
|
for (int i = 0; i < commandOptions.size(); i++) {
|
||||||
|
OptionData data = commandOptions.get(i);
|
||||||
OptionType type = data.getType();
|
OptionType type = data.getType();
|
||||||
Set<ChannelType> requiredChannelTypes = data.getChannelTypes();
|
Set<ChannelType> requiredChannelTypes = data.getChannelTypes();
|
||||||
List<Command.Choice> choices = data.getChoices();
|
List<Command.Choice> choices = data.getChoices();
|
||||||
|
int position = index + 1;
|
||||||
Object value = null;
|
Object value = null;
|
||||||
|
|
||||||
try {
|
if (index >= args.length) {
|
||||||
switch (type) {
|
if (data.isRequired()) {
|
||||||
case STRING -> {
|
invalidateContext(data, "missing required argument at index " + position);
|
||||||
int currentOptionIndex = commandOptions.indexOf(data);
|
}
|
||||||
boolean nextOptionIsString = false;
|
|
||||||
if (currentOptionIndex + 1 < commandOptions.size()) {
|
continue;
|
||||||
OptionData nextOption = commandOptions.get(currentOptionIndex + 1);
|
}
|
||||||
nextOptionIsString = nextOption.getType() == OptionType.STRING;
|
|
||||||
|
switch (type) {
|
||||||
|
case STRING -> {
|
||||||
|
String str;
|
||||||
|
if (i + 1 < commandOptions.size() && commandOptions.get(i + 1).getType() == OptionType.STRING) {
|
||||||
|
// The next option is also a string, so only the current argument belongs to this one
|
||||||
|
str = args[index++];
|
||||||
|
} else {
|
||||||
|
StringJoiner joiner = new StringJoiner(" ");
|
||||||
|
while (index < args.length && !stopsTextCapture(args[index], commandOptions, i)) {
|
||||||
|
joiner.add(args[index++]);
|
||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder strBuilder = new StringBuilder();
|
str = joiner.toString();
|
||||||
if (!nextOptionIsString) {
|
|
||||||
while (index < args.length) {
|
|
||||||
String nextArg = args[index];
|
|
||||||
|
|
||||||
if ((nextArg.contains("<@") || nextArg.contains("<#") || nextArg.contains("<@&")) && nextArg.contains(">")) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextArg.equalsIgnoreCase("true") || nextArg.equalsIgnoreCase("false")) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Long.parseLong(nextArg);
|
|
||||||
break;
|
|
||||||
} catch (NumberFormatException ignored) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Double.parseDouble(nextArg);
|
|
||||||
break;
|
|
||||||
} catch (NumberFormatException ignored) {}
|
|
||||||
|
|
||||||
if (!strBuilder.isEmpty()) {
|
|
||||||
strBuilder.append(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
strBuilder.append(nextArg);
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update index to skip the concatenated strings
|
|
||||||
index = index - 1;
|
|
||||||
} else {
|
|
||||||
// Next option is also a string, so just take the current argument
|
|
||||||
if (index < args.length) {
|
|
||||||
strBuilder.append(args[index]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strBuilder.isEmpty()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
String str = strBuilder.toString();
|
|
||||||
|
|
||||||
if (!choices.isEmpty()) {
|
|
||||||
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsString().equalsIgnoreCase(str)).toList();
|
|
||||||
if (matched.isEmpty()) {
|
|
||||||
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Integer min = data.getMinLength();
|
|
||||||
Integer max = data.getMaxLength();
|
|
||||||
|
|
||||||
if (min != null && str.length() < min) {
|
|
||||||
invalidateContext(data, "minimum length for argument is " + min);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (max != null && str.length() > max) {
|
|
||||||
invalidateContext(data, "maximum length for argument is " + max);
|
|
||||||
}
|
|
||||||
|
|
||||||
value = str;
|
|
||||||
}
|
}
|
||||||
case INTEGER -> {
|
|
||||||
long num = Long.parseLong(args[index]);
|
|
||||||
if (!choices.isEmpty()) {
|
|
||||||
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsLong() == num).toList();
|
|
||||||
if (matched.isEmpty()) {
|
|
||||||
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
value = num;
|
|
||||||
|
|
||||||
Number max = data.getMaxValue();
|
if (str.isEmpty()) {
|
||||||
Number min = data.getMinValue();
|
if (data.isRequired()) {
|
||||||
|
invalidateContext(data, "missing required argument at index " + position);
|
||||||
if (min != null && num < min.longValue()) {
|
|
||||||
value = min.longValue();
|
|
||||||
} else if (max != null && num > max.longValue()) {
|
|
||||||
value = max.longValue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case BOOLEAN -> {
|
|
||||||
String str = args[index];
|
|
||||||
if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("false")) {
|
|
||||||
value = Boolean.parseBoolean(str);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsString().equalsIgnoreCase(str))) {
|
||||||
|
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer min = data.getMinLength();
|
||||||
|
Integer max = data.getMaxLength();
|
||||||
|
|
||||||
|
if (min != null && str.length() < min) {
|
||||||
|
invalidateContext(data, "minimum length for argument is " + min);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max != null && str.length() > max) {
|
||||||
|
invalidateContext(data, "maximum length for argument is " + max);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = str;
|
||||||
|
}
|
||||||
|
case INTEGER -> {
|
||||||
|
String str = args[index++];
|
||||||
|
if (!INTEGER_PATTERN.matcher(str).matches()) {
|
||||||
|
invalidateContext(data, "the argument provided is not a number.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
long num = Long.parseLong(str);
|
||||||
|
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsLong() == num)) {
|
||||||
|
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Number min = data.getMinValue();
|
||||||
|
Number max = data.getMaxValue();
|
||||||
|
|
||||||
|
if (min != null && num < min.longValue()) {
|
||||||
|
invalidateContext(data, "minimum value for argument is " + min.longValue());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max != null && num > max.longValue()) {
|
||||||
|
invalidateContext(data, "maximum value for argument is " + max.longValue());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = num;
|
||||||
|
}
|
||||||
|
case BOOLEAN -> {
|
||||||
|
String str = args[index++];
|
||||||
|
if (!str.equalsIgnoreCase("true") && !str.equalsIgnoreCase("false")) {
|
||||||
invalidateContext(data, "expected true or false");
|
invalidateContext(data, "expected true or false");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case NUMBER -> {
|
|
||||||
double num = Double.parseDouble(args[index]);
|
|
||||||
if (!choices.isEmpty()) {
|
|
||||||
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsDouble() == num).toList();
|
|
||||||
if (matched.isEmpty()) {
|
|
||||||
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Number max = data.getMaxValue();
|
value = Boolean.parseBoolean(str);
|
||||||
Number min = data.getMinValue();
|
}
|
||||||
value = num;
|
case NUMBER -> {
|
||||||
|
String str = args[index++];
|
||||||
if (min != null && num < min.doubleValue()) {
|
if (!NUMBER_PATTERN.matcher(str).matches()) {
|
||||||
value = min.doubleValue();
|
invalidateContext(data, "the argument provided is not a number.");
|
||||||
} else if (max != null && num > max.doubleValue()) {
|
break;
|
||||||
value = max.doubleValue();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case ROLE -> {
|
|
||||||
if (mentions.getRoles().size() > roleMentionIndex) {
|
|
||||||
Role role = mentions.getRoles().get(roleMentionIndex++);
|
|
||||||
if (role.getGuild().getId().equals(getGuild().getId())) {
|
|
||||||
value = role;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
double num = Double.parseDouble(str);
|
||||||
}
|
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsDouble() == num)) {
|
||||||
|
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
||||||
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid role.");
|
break;
|
||||||
}
|
}
|
||||||
case USER -> {
|
|
||||||
if (mentions.getMembers().size() > memberMentionIndex) {
|
|
||||||
Member member = mentions.getMembers().get(memberMentionIndex++);
|
|
||||||
if (member.getGuild().getId().equals(getGuild().getId())) {
|
|
||||||
value = member;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mentions.getUsers().size() > userMentionIndex) {
|
Number min = data.getMinValue();
|
||||||
value = mentions.getUsers().get(userMentionIndex++);
|
Number max = data.getMaxValue();
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user.");
|
if (min != null && num < min.doubleValue()) {
|
||||||
|
invalidateContext(data, "minimum value for argument is " + min.doubleValue());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case MENTIONABLE -> {
|
|
||||||
if (mentions.getRoles().size() > roleMentionIndex) {
|
|
||||||
Role role = mentions.getRoles().get(roleMentionIndex++);
|
|
||||||
if (role.getGuild().getId().equals(getGuild().getId())) {
|
|
||||||
value = role;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
if (max != null && num > max.doubleValue()) {
|
||||||
}
|
invalidateContext(data, "maximum value for argument is " + max.doubleValue());
|
||||||
|
break;
|
||||||
if (mentions.getMembers().size() > memberMentionIndex) {
|
|
||||||
Member member = mentions.getMembers().get(memberMentionIndex++);
|
|
||||||
if (member.getGuild().getId().equals(getGuild().getId())) {
|
|
||||||
value = member;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mentions.getUsers().size() > userMentionIndex) {
|
|
||||||
value = mentions.getUsers().get(userMentionIndex++);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user or role.");
|
|
||||||
}
|
}
|
||||||
case CHANNEL -> {
|
|
||||||
if (mentions.getChannels().size() > channelMentionIndex) {
|
|
||||||
GuildChannel channel = mentions.getChannels().get(channelMentionIndex++);
|
|
||||||
|
|
||||||
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) {
|
value = num;
|
||||||
invalidateContext(data, "not a valid channel type at index " + (index + 1) +
|
}
|
||||||
" , valid types are: " + validTypes(requiredChannelTypes));
|
case ROLE -> {
|
||||||
break;
|
index++;
|
||||||
}
|
Role role = mentions.getRoles().size() > roleMentionIndex ? mentions.getRoles().get(roleMentionIndex++) : null;
|
||||||
|
if (role == null || !isFromCurrentGuild(role.getGuild())) {
|
||||||
|
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (channel.getGuild().getId().equals(getGuild().getId())) {
|
value = role;
|
||||||
value = channel;
|
}
|
||||||
break;
|
case USER -> {
|
||||||
}
|
index++;
|
||||||
}
|
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
|
||||||
|
if (value instanceof Member) {
|
||||||
|
memberMentionIndex++;
|
||||||
|
} else if (value != null) {
|
||||||
|
userMentionIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid channel.");
|
if (value == null) {
|
||||||
|
invalidateContext(data, "provided argument at position " + position + " is not a valid user.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IndexOutOfBoundsException e) {
|
case MENTIONABLE -> {
|
||||||
if (options.size() < required) {
|
index++;
|
||||||
invalidateContext(data, "missing required argument at index " + (index + 1));
|
if (mentions.getRoles().size() > roleMentionIndex) {
|
||||||
}
|
Role role = mentions.getRoles().get(roleMentionIndex++);
|
||||||
|
if (!isFromCurrentGuild(role.getGuild())) {
|
||||||
|
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.isRequired() && index < required) {
|
value = role;
|
||||||
// This should never happen because required options are validated before calling parseOptions()
|
break;
|
||||||
throw new IllegalArgumentException("Required option " + data.getName() + " was not found at index " + index);
|
}
|
||||||
|
|
||||||
|
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
|
||||||
|
if (value instanceof Member) {
|
||||||
|
memberMentionIndex++;
|
||||||
|
} else if (value != null) {
|
||||||
|
userMentionIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
invalidateContext(data, "provided argument at position " + position + " is not a valid user or role.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (NumberFormatException ignored) {
|
case CHANNEL -> {
|
||||||
invalidateContext(data, "the argument provided is not a number.");
|
index++;
|
||||||
|
GuildChannel channel = mentions.getChannels().size() > channelMentionIndex ? mentions.getChannels().get(channelMentionIndex++) : null;
|
||||||
|
if (channel == null || !isFromCurrentGuild(channel.getGuild())) {
|
||||||
|
invalidateContext(data, "provided argument at position " + position + " is not a valid channel.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) {
|
||||||
|
invalidateContext(data, "not a valid channel type at index " + position +
|
||||||
|
" , valid types are: " + validTypes(requiredChannelTypes));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = channel;
|
||||||
|
}
|
||||||
|
default -> index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
options.add(new MessageCommandOption(data, value));
|
options.add(new MessageCommandOption(data, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
index++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are leftover arguments that weren't consumed
|
// Check if there are leftover arguments that weren't consumed
|
||||||
if (index < args.length) {
|
if (index < args.length) {
|
||||||
if (options.size() >= required) {
|
OptionData lastOption = commandOptions.isEmpty() ? LEFTOVER_ARGUMENTS : commandOptions.getLast();
|
||||||
invalidateContext(commandOptions.getLast(), "too many arguments");
|
invalidateContext(lastOption, "too many arguments");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A string option greedily consumes the remaining arguments, it has to stop as soon as an argument
|
||||||
|
* belongs to one of the following options.
|
||||||
|
*/
|
||||||
|
private boolean stopsTextCapture(String arg, List<OptionData> commandOptions, int currentOptionIndex) {
|
||||||
|
if (NUMBER_PATTERN.matcher(arg).matches()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<OptionData> remaining = commandOptions.subList(currentOptionIndex + 1, commandOptions.size());
|
||||||
|
|
||||||
|
if (arg.equalsIgnoreCase("true") || arg.equalsIgnoreCase("false")) {
|
||||||
|
return remaining.stream().anyMatch(option -> option.getType() == OptionType.BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MENTION_PATTERN.matcher(arg).matches()) {
|
||||||
|
return remaining.stream().anyMatch(option -> MENTION_TYPES.contains(option.getType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object resolveUser(Mentions mentions, int memberMentionIndex, int userMentionIndex) {
|
||||||
|
if (mentions.getMembers().size() > memberMentionIndex) {
|
||||||
|
Member member = mentions.getMembers().get(memberMentionIndex);
|
||||||
|
if (isFromCurrentGuild(member.getGuild())) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentions.getUsers().size() > userMentionIndex) {
|
||||||
|
return mentions.getUsers().get(userMentionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFromCurrentGuild(Guild guild) {
|
||||||
|
return getGuild() != null && guild != null && guild.getId().equals(getGuild().getId());
|
||||||
|
}
|
||||||
|
|
||||||
private String validChoices(List<Command.Choice> choices) {
|
private String validChoices(List<Command.Choice> choices) {
|
||||||
StringJoiner joiner = new StringJoiner(", ");
|
StringJoiner joiner = new StringJoiner(", ");
|
||||||
for (Command.Choice choice : choices) {
|
for (Command.Choice choice : choices) {
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ public class SlashCommandContext extends CommandContext implements InteractionCo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean canSkipPermissionCheck() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SlashCommandInteractionEvent getEvent() {
|
public SlashCommandInteractionEvent getEvent() {
|
||||||
return event;
|
return event;
|
||||||
|
|||||||
@@ -5,32 +5,65 @@ package me.bobert.netneutrality.core;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import io.sentry.Sentry;
|
import me.bobert.netneutrality.core.log.DiscordDebugChannelLogSink;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.log.Log4jLogSink;
|
||||||
import me.bobert.netneutrality.core.context.Context;
|
import me.bobert.netneutrality.core.log.LogSink;
|
||||||
import me.bobert.netneutrality.core.context.TaskContext;
|
import me.bobert.netneutrality.core.log.LoggedException;
|
||||||
|
import me.bobert.netneutrality.core.log.SentryLogSink;
|
||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
import me.bobert.netneutrality.exceptions.ContextException;
|
import me.bobert.netneutrality.exceptions.ContextException;
|
||||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
import me.bobert.netneutrality.utils.MessageFunction;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
public class LogHandler {
|
public class LogHandler {
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(LogHandler.class);
|
private static final Logger logger = LogManager.getLogger(LogHandler.class);
|
||||||
private static volatile GuildServiceProvider guildServices;
|
private static final CopyOnWriteArrayList<LogSink> sinks = new CopyOnWriteArrayList<>();
|
||||||
|
private static volatile DiscordDebugChannelLogSink discordSink;
|
||||||
|
|
||||||
|
static {
|
||||||
|
sinks.add(new Log4jLogSink());
|
||||||
|
sinks.add(new SentryLogSink());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds the guild-service provider used for debug-channel logging. Called once during bootstrap.
|
* Binds the guild-service provider used for debug-channel logging. Called once during bootstrap.
|
||||||
|
* Internally registers (or replaces) the Discord debug-channel {@link LogSink}.
|
||||||
*
|
*
|
||||||
* @param provider the live guild service provider
|
* @param provider the live guild service provider
|
||||||
*/
|
*/
|
||||||
public static void bind(GuildServiceProvider provider) {
|
public static void bind(GuildServiceProvider provider) {
|
||||||
guildServices = provider;
|
if (discordSink != null) {
|
||||||
|
sinks.remove(discordSink);
|
||||||
|
}
|
||||||
|
|
||||||
|
discordSink = new DiscordDebugChannelLogSink(provider);
|
||||||
|
sinks.add(discordSink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers an additional log destination. Sinks are notified for every exception logged via
|
||||||
|
* {@link #log(Exception)}.
|
||||||
|
*
|
||||||
|
* @param sink the sink to register
|
||||||
|
*/
|
||||||
|
public static void addSink(LogSink sink) {
|
||||||
|
sinks.add(sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters a previously registered log destination.
|
||||||
|
*
|
||||||
|
* @param sink the sink to remove
|
||||||
|
*/
|
||||||
|
public static void removeSink(LogSink sink) {
|
||||||
|
sinks.remove(sink);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log an exception accordingly
|
* Log an exception accordingly
|
||||||
@@ -38,17 +71,15 @@ public class LogHandler {
|
|||||||
* @param e The exceptions to log
|
* @param e The exceptions to log
|
||||||
*/
|
*/
|
||||||
public static void log(Exception e) {
|
public static void log(Exception e) {
|
||||||
Sentry.captureException(e);
|
LoggedException event = LoggedException.of(e);
|
||||||
|
|
||||||
if (e instanceof ContextException exception) {
|
for (LogSink sink : sinks) {
|
||||||
logInternally(exception);
|
safelyHandle(sink, event);
|
||||||
tryLogToDebugChannel(exception);
|
|
||||||
tryGracefulContextError(exception);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(e.getMessage(), e);
|
if (e instanceof ContextException exception) {
|
||||||
|
tryGracefulContextError(exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void log(String s, Exception e) {
|
public static void log(String s, Exception e) {
|
||||||
@@ -69,37 +100,11 @@ public class LogHandler {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void logInternally(ContextException exception) {
|
private static void safelyHandle(LogSink sink, LoggedException event) {
|
||||||
Context context = exception.getContext();
|
try {
|
||||||
String guildId = context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId();
|
sink.handle(event);
|
||||||
|
} catch (Throwable t) {
|
||||||
logger.error("Exception thrown from context (guild: {}, user: {}): {}", guildId,
|
logger.error("Log sink {} failed to handle exception", sink.getClass().getSimpleName(), t);
|
||||||
context.getDiscordUser().getId(),
|
|
||||||
exception.getMessage()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context instanceof CommandContext commandContext) {
|
|
||||||
logger.error("Caused by command: {}", commandContext.getCommand().getCommandName(), exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context instanceof TaskContext taskContext) {
|
|
||||||
logger.error("Caused by task with ID: {}", taskContext.getId(), exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void tryLogToDebugChannel(ContextException exception) {
|
|
||||||
Context context = exception.getContext();
|
|
||||||
|
|
||||||
if (context.getGuild() != null) {
|
|
||||||
GuildServiceProvider provider = guildServices;
|
|
||||||
if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) {
|
|
||||||
context.getGuild()
|
|
||||||
.getTextChannels()
|
|
||||||
.stream()
|
|
||||||
.filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log"))
|
|
||||||
.filter(TextChannel::canTalk)
|
|
||||||
.forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception)).queue());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package me.bobert.netneutrality.core;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity ordering used to gate what gets sent to a guild's log channel, from least to most verbose:
|
||||||
|
* {@code ERROR} < {@code WARN} < {@code INFO}.
|
||||||
|
*/
|
||||||
|
public enum LogSeverity {
|
||||||
|
ERROR,
|
||||||
|
WARN,
|
||||||
|
INFO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a severity name, falling back to {@link #ERROR} (the most restrictive level) when the
|
||||||
|
* value is missing or unrecognized.
|
||||||
|
*
|
||||||
|
* @param value the raw severity name (case-insensitive)
|
||||||
|
* @return the matching severity, or {@link #ERROR} as a safe default
|
||||||
|
*/
|
||||||
|
public static LogSeverity parse(String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return LogSeverity.valueOf(value.toUpperCase());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param configured the guild's configured minimum severity
|
||||||
|
* @return {@code true} when this severity should be sent given the configured minimum
|
||||||
|
*/
|
||||||
|
public boolean meetsThreshold(LogSeverity configured) {
|
||||||
|
return this.ordinal() <= configured.ordinal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package me.bobert.netneutrality.core;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
|
||||||
|
|
||||||
public class NetNeutralityBot implements Lifecycle {
|
|
||||||
@Override
|
|
||||||
public void start() throws Exception {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int priority() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.command;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.core.tasks.Task;
|
import me.bobert.netneutrality.core.tasks.Task;
|
||||||
|
import me.bobert.netneutrality.music.QueueActionResult;
|
||||||
|
|
||||||
public class CommandExecutionResult {
|
public class CommandExecutionResult {
|
||||||
|
|
||||||
@@ -80,4 +81,18 @@ public class CommandExecutionResult {
|
|||||||
public static CommandExecutionResult task(Task task) {
|
public static CommandExecutionResult task(Task task) {
|
||||||
return new CommandExecutionResult(Result.TASK, null, task, null);
|
return new CommandExecutionResult(Result.TASK, null, task, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts a {@link QueueActionResult} returned by {@link me.bobert.netneutrality.music.QueueManager}
|
||||||
|
* into a {@link CommandExecutionResult} so it can be surfaced as a Discord reply or HTTP response.
|
||||||
|
*/
|
||||||
|
public static CommandExecutionResult from(QueueActionResult result) {
|
||||||
|
return switch (result.getStatus()) {
|
||||||
|
case SUCCESS -> success(result.getMessage());
|
||||||
|
case INFO -> info(result.getMessage());
|
||||||
|
case WARN -> warn(result.getMessage());
|
||||||
|
case ERROR -> error(result.getMessage());
|
||||||
|
case NOACTION -> none();
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ package me.bobert.netneutrality.core.command;
|
|||||||
import io.sentry.Sentry;
|
import io.sentry.Sentry;
|
||||||
import me.bobert.netneutrality.core.LogHandler;
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.command.precondition.CommandPrecondition;
|
import me.bobert.netneutrality.core.command.precondition.CommandPrecondition;
|
||||||
|
import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
|
||||||
import me.bobert.netneutrality.core.command.precondition.GuildOnlyPrecondition;
|
import me.bobert.netneutrality.core.command.precondition.GuildOnlyPrecondition;
|
||||||
import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition;
|
import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition;
|
||||||
import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition;
|
import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition;
|
||||||
@@ -15,7 +16,7 @@ import me.bobert.netneutrality.core.context.CommandContext;
|
|||||||
import me.bobert.netneutrality.exceptions.CommandExecutionException;
|
import me.bobert.netneutrality.exceptions.CommandExecutionException;
|
||||||
import me.bobert.netneutrality.exceptions.GracefulContextError;
|
import me.bobert.netneutrality.exceptions.GracefulContextError;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
@@ -52,6 +53,7 @@ public class CommandProcessor {
|
|||||||
private static List<CommandPrecondition> defaultPreconditions() {
|
private static List<CommandPrecondition> defaultPreconditions() {
|
||||||
return List.of(
|
return List.of(
|
||||||
new GuildOnlyPrecondition(),
|
new GuildOnlyPrecondition(),
|
||||||
|
new DisabledCommandPrecondition(),
|
||||||
new SlashCommandOnlyPrecondition(),
|
new SlashCommandOnlyPrecondition(),
|
||||||
new PermissionPrecondition()
|
new PermissionPrecondition()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import me.bobert.netneutrality.core.command.CommandCategory;
|
|||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
import me.bobert.netneutrality.db.GSettings;
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
import me.bobert.netneutrality.db.service.GuildService;
|
import me.bobert.netneutrality.db.service.GuildService;
|
||||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
import net.dv8tion.jda.api.entities.Role;
|
import net.dv8tion.jda.api.entities.Role;
|
||||||
@@ -43,7 +43,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
|||||||
* @return the guild service
|
* @return the guild service
|
||||||
*/
|
*/
|
||||||
protected final GuildService guildService(Guild guild) {
|
protected final GuildService guildService(Guild guild) {
|
||||||
return guildServices.get(guild);
|
return guildServices.get(guild.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +79,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
|||||||
|
|
||||||
Guild guild = member.getGuild();
|
Guild guild = member.getGuild();
|
||||||
|
|
||||||
String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
|
String roleDb = guildService(guild.getId()).getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
|
||||||
|
|
||||||
if (roleDb != null && !roleDb.isEmpty()) {
|
if (roleDb != null && !roleDb.isEmpty()) {
|
||||||
if (UserFunction.isAdmin(member)) {
|
if (UserFunction.isAdmin(member)) {
|
||||||
@@ -89,7 +89,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
|||||||
Role neededRole = guild.getRoleById(roleDb);
|
Role neededRole = guild.getRoleById(roleDb);
|
||||||
if (neededRole == null) {
|
if (neededRole == null) {
|
||||||
// Role doesn't exist on guild anymore, remove from db
|
// Role doesn't exist on guild anymore, remove from db
|
||||||
guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
|
guildService(guild.getId()).getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL);
|
String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getOrDefault(GSettings.MUSIC_CHANNEL);
|
||||||
if (channelDb != null && !channelDb.isEmpty()) {
|
if (channelDb != null && !channelDb.isEmpty()) {
|
||||||
return channelDb.equals(channel.getId());
|
return channelDb.equals(channel.getId());
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
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.db.GSettings;
|
||||||
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class DisabledCommandPrecondition implements CommandPrecondition {
|
||||||
|
|
||||||
|
private static volatile GuildServiceProvider guildServices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the guild-service provider used to resolve the guild's configured {@code DISABLED_COMMANDS}.
|
||||||
|
* Called once during bootstrap.
|
||||||
|
*
|
||||||
|
* @param provider the live guild service provider
|
||||||
|
*/
|
||||||
|
public static void bind(GuildServiceProvider provider) {
|
||||||
|
guildServices = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean check(CommandContext context, Command command) {
|
||||||
|
GuildServiceProvider provider = guildServices;
|
||||||
|
if (provider == null || context.getGuild() == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String disabledCommands = provider.get(context.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.DISABLED_COMMANDS);
|
||||||
|
if (disabledCommands.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean disabled = Arrays.stream(disabledCommands.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.anyMatch(name -> name.equalsIgnoreCase(command.getCommandName()));
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
context.replyEphemeral("This command is disabled on this server");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-9
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.core.command.precondition;
|
|||||||
|
|
||||||
import me.bobert.netneutrality.core.command.Command;
|
import me.bobert.netneutrality.core.command.Command;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.shared.util.UserFunction;
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
|
|
||||||
public class PermissionPrecondition implements CommandPrecondition {
|
public class PermissionPrecondition implements CommandPrecondition {
|
||||||
|
|
||||||
@@ -21,19 +21,21 @@ public class PermissionPrecondition implements CommandPrecondition {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.canBeExecutedFrom(context.getChannel())) {
|
if (!command.canBeExecutedFrom(context.getChannel())) {
|
||||||
return true;
|
context.replyEphemeral("This command cannot be executed in the current channel");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.canBeExecutedBy(context.getGuildUser())) {
|
if (command.needAdmin() && !UserFunction.isAdmin(context.getGuildUser())) {
|
||||||
return true;
|
context.replyEphemeral("You are not powerful enough, scrub");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.needAdmin() && UserFunction.isAdmin(context.getGuildUser())) {
|
if (!command.canBeExecutedBy(context.getGuildUser())) {
|
||||||
return true;
|
context.replyEphemeral("You are not powerful enough, scrub");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.reply("You are not powerful enough, scrub");
|
return true;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.core.context;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.shared.util.ContextUtils;
|
import me.bobert.netneutrality.utils.ContextUtils;
|
||||||
import net.dv8tion.jda.api.JDA;
|
import net.dv8tion.jda.api.JDA;
|
||||||
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package me.bobert.netneutrality.core.log;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import me.bobert.netneutrality.core.LogSeverity;
|
||||||
|
import me.bobert.netneutrality.core.context.Context;
|
||||||
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
|
import me.bobert.netneutrality.db.service.GuildSettingService;
|
||||||
|
import me.bobert.netneutrality.exceptions.ContextException;
|
||||||
|
import me.bobert.netneutrality.utils.MessageFunction;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link LogSink} that posts {@link ContextException}s to a guild's debug/log channel, subject to
|
||||||
|
* {@link GuildServiceProvider#isDebugEnabled} and the guild's configured {@link LogSeverity} threshold.
|
||||||
|
*/
|
||||||
|
public class DiscordDebugChannelLogSink implements LogSink {
|
||||||
|
|
||||||
|
private final GuildServiceProvider guildServices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param guildServices the guild service provider used to look up debug settings per guild
|
||||||
|
*/
|
||||||
|
public DiscordDebugChannelLogSink(GuildServiceProvider guildServices) {
|
||||||
|
this.guildServices = guildServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(LoggedException event) {
|
||||||
|
if (!(event.exception() instanceof ContextException exception)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context context = exception.getContext();
|
||||||
|
|
||||||
|
if (context.getGuild() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guildServices == null || !guildServices.get(context.getGuild().getId()).isDebugEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GuildSettingService settingService = guildServices.get(context.getGuild().getId()).getGuildSettingService();
|
||||||
|
LogSeverity configured = LogSeverity.parse(settingService.getOrDefault(GSettings.LOG_LEVEL));
|
||||||
|
|
||||||
|
if (!LogSeverity.ERROR.meetsThreshold(configured)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String timezone = settingService.getOrDefault(GSettings.TIMEZONE);
|
||||||
|
context.getGuild()
|
||||||
|
.getTextChannels()
|
||||||
|
.stream()
|
||||||
|
.filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log"))
|
||||||
|
.filter(TextChannel::canTalk)
|
||||||
|
.forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception, timezone)).queue());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package me.bobert.netneutrality.core.log;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
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.exceptions.ContextException;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link LogSink} that logs every exception via Log4j, including contextual details
|
||||||
|
* (guild, user, command/task) when the exception is a {@link ContextException}.
|
||||||
|
*/
|
||||||
|
public class Log4jLogSink implements LogSink {
|
||||||
|
|
||||||
|
private static final Logger logger = LogManager.getLogger(Log4jLogSink.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(LoggedException event) {
|
||||||
|
Exception exception = event.exception();
|
||||||
|
|
||||||
|
if (exception instanceof ContextException contextException) {
|
||||||
|
logInternally(contextException);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(exception.getMessage(), exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logInternally(ContextException exception) {
|
||||||
|
Context context = exception.getContext();
|
||||||
|
String guildId = context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId();
|
||||||
|
|
||||||
|
logger.error("Exception thrown from context (guild: {}, user: {}): {}", guildId,
|
||||||
|
context.getDiscordUser().getId(),
|
||||||
|
exception.getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context instanceof CommandContext commandContext) {
|
||||||
|
logger.error("Caused by command: {}", commandContext.getCommand().getCommandName(), exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context instanceof TaskContext taskContext) {
|
||||||
|
logger.error("Caused by task with ID: {}", taskContext.getId(), exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package me.bobert.netneutrality.core.log;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pluggable log destination. Implementations decide internally whether and how to act on a
|
||||||
|
* {@link LoggedException}. Failures thrown by a sink are caught and isolated by the dispatcher
|
||||||
|
* (e.g. {@code LogHandler}) so one broken sink never blocks the others.
|
||||||
|
*/
|
||||||
|
public interface LogSink {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a logged exception event. Implementations should not throw checked exceptions that
|
||||||
|
* would prevent other sinks from running; any thrown exception is caught by the dispatcher.
|
||||||
|
*
|
||||||
|
* @param event the logged exception event
|
||||||
|
*/
|
||||||
|
void handle(LoggedException event);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package me.bobert.netneutrality.core.log;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import me.bobert.netneutrality.core.context.Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable event payload dispatched to every registered {@link LogSink}.
|
||||||
|
*
|
||||||
|
* @param exception the exception being logged
|
||||||
|
* @param context the context the exception occurred in, or {@code null} if none
|
||||||
|
* @param message an optional additional message, or {@code null} if none
|
||||||
|
*/
|
||||||
|
public record LoggedException(Exception exception, Context context, String message) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param e the exception to log
|
||||||
|
* @return a {@link LoggedException} with no context or message
|
||||||
|
*/
|
||||||
|
public static LoggedException of(Exception e) {
|
||||||
|
return new LoggedException(e, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param message the message to attach to the exception
|
||||||
|
* @param e the exception to log
|
||||||
|
* @return a {@link LoggedException} with no context
|
||||||
|
*/
|
||||||
|
public static LoggedException of(String message, Exception e) {
|
||||||
|
return new LoggedException(e, null, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package me.bobert.netneutrality.core.log;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import io.sentry.Sentry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link LogSink} that reports every logged exception to Sentry.
|
||||||
|
*/
|
||||||
|
public class SentryLogSink implements LogSink {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(LoggedException event) {
|
||||||
|
Sentry.captureException(event.exception());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ public class ScheduledTaskManager implements Lifecycle {
|
|||||||
ScheduledTask.TimeFrame timeFrame = task.getTimeFrame();
|
ScheduledTask.TimeFrame timeFrame = task.getTimeFrame();
|
||||||
|
|
||||||
Runnable runnable = () -> {
|
Runnable runnable = () -> {
|
||||||
logger.info("Executing scheduled task: {}", taskId);
|
logger.debug("Executing scheduled task: {}", taskId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
task.run();
|
task.run();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package me.bobert.netneutrality.core.tasks;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,23 @@ import me.bobert.netneutrality.core.context.CommandContext;
|
|||||||
* so that it can be stored in a database.
|
* so that it can be stored in a database.
|
||||||
*/
|
*/
|
||||||
public interface PersistableTask extends Task {
|
public interface PersistableTask extends Task {
|
||||||
|
/**
|
||||||
|
* Stable logical type id stored in {@code tasks.task_class}.
|
||||||
|
* Defaults to {@link Task#getName()} so package moves and class renames are not breaking changes.
|
||||||
|
*
|
||||||
|
* @return the stable type id of this task type
|
||||||
|
*/
|
||||||
|
default String typeId() {
|
||||||
|
return getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds this instance's state from a payload previously produced by {@link #toPayload()}.
|
||||||
|
*
|
||||||
|
* @param payload the persisted payload, must not be {@code null}
|
||||||
|
*/
|
||||||
|
void restore(ObjectNode payload);
|
||||||
|
|
||||||
void insert(CommandContext context);
|
void insert(CommandContext context);
|
||||||
|
|
||||||
void save();
|
void save();
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
|
|||||||
import me.bobert.netneutrality.core.tasks.trait.WithButton;
|
import me.bobert.netneutrality.core.tasks.trait.WithButton;
|
||||||
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
|
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
|
||||||
import me.bobert.netneutrality.core.tasks.trait.WithParent;
|
import me.bobert.netneutrality.core.tasks.trait.WithParent;
|
||||||
|
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
|
||||||
|
import me.bobert.netneutrality.db.repository.TaskRepository;
|
||||||
import me.bobert.netneutrality.db.service.TaskService;
|
import me.bobert.netneutrality.db.service.TaskService;
|
||||||
import me.bobert.netneutrality.exceptions.GracefulContextError;
|
import me.bobert.netneutrality.exceptions.GracefulContextError;
|
||||||
import me.bobert.netneutrality.exceptions.TaskExecutionException;
|
import me.bobert.netneutrality.exceptions.TaskExecutionException;
|
||||||
@@ -45,21 +47,24 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
private final Map<String, CommandContext> deletableTasksContext;
|
private final Map<String, CommandContext> deletableTasksContext;
|
||||||
private final Map<String, ReentrantLock> tasksLock;
|
private final Map<String, ReentrantLock> tasksLock;
|
||||||
private final ApplicationContext context;
|
private final ApplicationContext context;
|
||||||
|
private final PersistableTaskRegistry persistableTaskRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a task manager that can inject context-registered dependencies into reflectively
|
* Creates a task manager that can inject context-registered dependencies into reflectively
|
||||||
* loaded tasks.
|
* loaded tasks.
|
||||||
*
|
*
|
||||||
* @param context the application context; must not be {@code null}
|
* @param context the application context; must not be {@code null}
|
||||||
|
* @param taskRepository the repository persisting {@link PersistableTask}s
|
||||||
*/
|
*/
|
||||||
public TaskManagerImpl(ApplicationContext context) {
|
public TaskManagerImpl(ApplicationContext context, TaskRepository taskRepository) {
|
||||||
Objects.requireNonNull(context, "ApplicationContext must not be null");
|
Objects.requireNonNull(context, "ApplicationContext must not be null");
|
||||||
this.tasks = new ConcurrentHashMap<>();
|
this.tasks = new ConcurrentHashMap<>();
|
||||||
this.pendingTasks = new ConcurrentHashMap<>();
|
this.pendingTasks = new ConcurrentHashMap<>();
|
||||||
this.deletableTasksContext = new ConcurrentHashMap<>();
|
this.deletableTasksContext = new ConcurrentHashMap<>();
|
||||||
this.tasksLock = new ConcurrentHashMap<>();
|
this.tasksLock = new ConcurrentHashMap<>();
|
||||||
this.taskService = new TaskService(this);
|
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
this.persistableTaskRegistry = new PersistableTaskRegistry(context);
|
||||||
|
this.taskService = new TaskService(this, persistableTaskRegistry, taskRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -76,6 +81,7 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
logger.info("Initializing task manager...");
|
logger.info("Initializing task manager...");
|
||||||
|
|
||||||
loadBuiltInTasks();
|
loadBuiltInTasks();
|
||||||
|
persistableTaskRegistry.scan();
|
||||||
taskService.loadTasks();
|
taskService.loadTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ package me.bobert.netneutrality.core.tasks.impl;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.core.tasks.PersistableTask;
|
import me.bobert.netneutrality.core.tasks.PersistableTask;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
import org.json.JSONObject;
|
import me.bobert.netneutrality.utils.Json;
|
||||||
|
|
||||||
public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask {
|
public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask {
|
||||||
|
|
||||||
@@ -60,9 +61,33 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe
|
|||||||
taskManager().removeTask(this);
|
taskManager().removeTask(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op default: only tasks carrying extra state need to override this.
|
||||||
|
*
|
||||||
|
* @param payload the persisted payload
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void restore(ObjectNode payload) {
|
||||||
|
// Nothing to restore by default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op default: only tasks carrying extra state need to override this.
|
||||||
|
*
|
||||||
|
* @param payload the payload being written
|
||||||
|
*/
|
||||||
|
protected void writePayload(ObjectNode payload) {
|
||||||
|
// Nothing extra to write by default
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toPayload() {
|
public String toPayload() {
|
||||||
JSONObject payload = new JSONObject(this);
|
ObjectNode payload = Json.object();
|
||||||
|
payload.put("id", getId());
|
||||||
|
payload.put("lockId", getLockId());
|
||||||
|
payload.put("name", getName());
|
||||||
|
|
||||||
|
writePayload(payload);
|
||||||
|
|
||||||
return payload.toString();
|
return payload.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package me.bobert.netneutrality.core.tasks.impl;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
|
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
|
|
||||||
@@ -13,9 +15,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
|
|
||||||
public abstract class EmojiTask extends AbstractPersistableTask {
|
public abstract class EmojiTask extends AbstractPersistableTask {
|
||||||
private final Map<String, String> emojiMapping = new ConcurrentHashMap<>();
|
private final Map<String, String> emojiMapping = new ConcurrentHashMap<>();
|
||||||
private final String guildId;
|
private String guildId;
|
||||||
private final String channelId;
|
private String channelId;
|
||||||
private final String messageId;
|
private String messageId;
|
||||||
|
|
||||||
public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) {
|
public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) {
|
||||||
super(taskManager);
|
super(taskManager);
|
||||||
@@ -24,6 +26,45 @@ public abstract class EmojiTask extends AbstractPersistableTask {
|
|||||||
this.messageId = messageId;
|
this.messageId = messageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor used by the persistable task registry: the message coordinates and the emoji
|
||||||
|
* mapping are hydrated afterwards by {@link #restore(ObjectNode)}.
|
||||||
|
*
|
||||||
|
* @param taskManager the application task manager, must not be {@code null}
|
||||||
|
*/
|
||||||
|
public EmojiTask(TaskManager taskManager) {
|
||||||
|
super(taskManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void restore(ObjectNode payload) {
|
||||||
|
this.guildId = payload.path("guildId").asText(null);
|
||||||
|
this.channelId = payload.path("channelId").asText(null);
|
||||||
|
this.messageId = payload.path("messageId").asText(null);
|
||||||
|
|
||||||
|
emojiMapping.clear();
|
||||||
|
JsonNode mapping = payload.path("emojiMapping");
|
||||||
|
if (mapping.isObject()) {
|
||||||
|
mapping.properties().forEach(entry -> emojiMapping.put(entry.getKey(), entry.getValue().asText()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writePayload(ObjectNode payload) {
|
||||||
|
putIfPresent(payload, "guildId", guildId);
|
||||||
|
putIfPresent(payload, "channelId", channelId);
|
||||||
|
putIfPresent(payload, "messageId", messageId);
|
||||||
|
|
||||||
|
ObjectNode mapping = payload.putObject("emojiMapping");
|
||||||
|
emojiMapping.forEach(mapping::put);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putIfPresent(ObjectNode payload, String key, String value) {
|
||||||
|
if (value != null) {
|
||||||
|
payload.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public String getGuildId() {
|
public String getGuildId() {
|
||||||
return guildId;
|
return guildId;
|
||||||
}
|
}
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package me.bobert.netneutrality.core.tasks.persistence;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
||||||
|
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
|
||||||
|
import me.bobert.netneutrality.core.tasks.PersistableTask;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of the persistable task types available on the classpath.
|
||||||
|
*
|
||||||
|
* <p>Types are discovered by scanning for concrete {@link PersistableTask} implementations and are
|
||||||
|
* keyed by their {@link PersistableTask#typeId()}, which is the value stored in
|
||||||
|
* {@code tasks.task_class}. Restoring a row creates a fresh instance through the regular DI path
|
||||||
|
* and lets the task hydrate itself from its payload.
|
||||||
|
*/
|
||||||
|
public final class PersistableTaskRegistry {
|
||||||
|
private static final Logger logger = LogManager.getLogger(PersistableTaskRegistry.class);
|
||||||
|
|
||||||
|
private final Map<String, Class<? extends PersistableTask>> typesById = new ConcurrentHashMap<>();
|
||||||
|
private final ApplicationContext context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param context the DI context used to instantiate discovered task types, must not be {@code null}
|
||||||
|
*/
|
||||||
|
public PersistableTaskRegistry(ApplicationContext context) {
|
||||||
|
this.context = Objects.requireNonNull(context, "ApplicationContext must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers every concrete {@link PersistableTask} implementation and registers it by its type id.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException if two task types declare the same type id
|
||||||
|
*/
|
||||||
|
public void scan() {
|
||||||
|
typesById.clear();
|
||||||
|
|
||||||
|
ReflectionHelper.loadTasks(PersistableTask.class, context, this::register);
|
||||||
|
|
||||||
|
logger.info("Registered {} persistable task type(s): {}", typesById.size(), typesById.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
void register(PersistableTask prototype) {
|
||||||
|
String typeId = prototype.typeId();
|
||||||
|
Class<? extends PersistableTask> type = prototype.getClass();
|
||||||
|
|
||||||
|
Class<? extends PersistableTask> previous = typesById.putIfAbsent(typeId, type);
|
||||||
|
if (previous != null && !previous.equals(type)) {
|
||||||
|
throw new IllegalStateException(String.format(
|
||||||
|
"Duplicate persistable task type id '%s' declared by %s and %s",
|
||||||
|
typeId, previous.getName(), type.getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param typeId the type id to look up
|
||||||
|
* @return {@code true} if a task type is registered under the given id
|
||||||
|
*/
|
||||||
|
public boolean isRegistered(String typeId) {
|
||||||
|
return typeId != null && typesById.containsKey(typeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the registered type ids
|
||||||
|
*/
|
||||||
|
public Set<String> getTypeIds() {
|
||||||
|
return Collections.unmodifiableSet(typesById.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fresh task instance for the given type id and hydrates it from the payload.
|
||||||
|
*
|
||||||
|
* @param typeId the registered type id
|
||||||
|
* @param payload the payload previously produced by {@link PersistableTask#toPayload()}
|
||||||
|
* @return the restored task
|
||||||
|
* @throws IllegalArgumentException if no task type is registered under {@code typeId}
|
||||||
|
* @throws ReflectiveOperationException if the task type cannot be instantiated
|
||||||
|
*/
|
||||||
|
public PersistableTask create(String typeId, ObjectNode payload) throws ReflectiveOperationException {
|
||||||
|
Class<? extends PersistableTask> type = typesById.get(typeId);
|
||||||
|
if (type == null) {
|
||||||
|
throw new IllegalArgumentException("No persistable task type registered for id: " + typeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistableTask task = ReflectionHelper.instantiate(type, context);
|
||||||
|
task.restore(payload);
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package me.bobert.netneutrality.core.tasks.persistence;
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import me.bobert.netneutrality.core.tasks.PersistableTask;
|
|
||||||
import org.json.JSONArray;
|
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
import java.lang.reflect.InvocationTargetException;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class TaskFactory {
|
|
||||||
public enum ParamType {
|
|
||||||
STRING(String.class),
|
|
||||||
INT(Integer.class),
|
|
||||||
MAP(Map.class);
|
|
||||||
|
|
||||||
private final Class<?> typeClass;
|
|
||||||
|
|
||||||
ParamType(Class<?> typeClass) {
|
|
||||||
this.typeClass = typeClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Class<?> getTypeClass() {
|
|
||||||
return typeClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ParamType fromString(String type) {
|
|
||||||
return switch (type.toLowerCase()) {
|
|
||||||
case "string" -> STRING;
|
|
||||||
case "int" -> INT;
|
|
||||||
case "map" -> MAP;
|
|
||||||
default -> throw new IllegalArgumentException("Unsupported type: " + type);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PersistableTask createTaskInstance(String className, JSONArray params, JSONArray types, JSONObject payload) throws Exception {
|
|
||||||
if (params.length() != types.length()) {
|
|
||||||
throw new RuntimeException("Wrong number of parameters and types found in task mappings");
|
|
||||||
}
|
|
||||||
|
|
||||||
Class<?> clazz = Class.forName(className);
|
|
||||||
if (!PersistableTask.class.isAssignableFrom(clazz)) {
|
|
||||||
throw new IllegalArgumentException("Class does not implement PersistableTask.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Class<?>[] classTypes = new Class<?>[types.length()];
|
|
||||||
Object[] classParams = new Object[params.length()];
|
|
||||||
|
|
||||||
for (int i = 0; i < params.length(); i++) {
|
|
||||||
ParamType paramType = ParamType.fromString(types.getString(i));
|
|
||||||
String paramName = params.getString(i);
|
|
||||||
|
|
||||||
classTypes[i] = paramType.getTypeClass();
|
|
||||||
classParams[i] = extractValue(paramType, paramName, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
|
|
||||||
} catch (NoSuchMethodException e) {
|
|
||||||
throw new NoSuchMethodException("No matching constructor found with parameters: " + Arrays.toString(classTypes));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
throw new IllegalArgumentException("Invalid arguments for constructor. Expected: " + Arrays.toString(classParams), e);
|
|
||||||
} catch (InvocationTargetException e) {
|
|
||||||
throw new RuntimeException("Constructor threw an exception", e.getCause());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Object extractValue(ParamType type, String paramName, JSONObject payload) {
|
|
||||||
return switch (type) {
|
|
||||||
case STRING -> payload.getString(paramName);
|
|
||||||
case INT -> payload.getInt(paramName);
|
|
||||||
case MAP -> payload.getJSONObject(paramName).toMap();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package me.bobert.netneutrality.core.tasks.persistence;
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import me.bobert.netneutrality.core.tasks.PersistableTask;
|
|
||||||
import org.apache.commons.io.IOUtils;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
|
||||||
import org.apache.logging.log4j.Logger;
|
|
||||||
import org.json.JSONArray;
|
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class TaskRegistry {
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface TaskCreator {
|
|
||||||
PersistableTask fromPayload(JSONObject payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(TaskRegistry.class);
|
|
||||||
private static final Map<String, TaskCreator> taskCreators = new HashMap<>();
|
|
||||||
private static final String FILE_NAME = "persistable-tasks.json";
|
|
||||||
|
|
||||||
// JSON keys as constants
|
|
||||||
private static final String KEY_TASKS = "tasks";
|
|
||||||
private static final String KEY_CLASS = "class";
|
|
||||||
private static final String KEY_PARAMS = "params";
|
|
||||||
private static final String KEY_TYPES = "types";
|
|
||||||
|
|
||||||
public static TaskCreator getTaskCreator(String className) {
|
|
||||||
return taskCreators.get(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void loadTaskConfig() {
|
|
||||||
logger.info("Reading task mappings from {}..", FILE_NAME);
|
|
||||||
|
|
||||||
try (InputStream is = TaskFactory.class.getClassLoader().getResourceAsStream(FILE_NAME)) {
|
|
||||||
if (is == null) {
|
|
||||||
throw new FileNotFoundException("Could not load: " + FILE_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
String jsonString = IOUtils.toString(is, StandardCharsets.UTF_8);
|
|
||||||
JSONObject json = new JSONObject(jsonString);
|
|
||||||
JSONArray tasksArray = json.optJSONArray(KEY_TASKS);
|
|
||||||
|
|
||||||
if (tasksArray == null) {
|
|
||||||
logger.warn("No tasks found in JSON: {}", FILE_NAME);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < tasksArray.length(); i++) {
|
|
||||||
JSONObject taskJson = tasksArray.getJSONObject(i);
|
|
||||||
|
|
||||||
String className = taskJson.optString(KEY_CLASS, "").trim();
|
|
||||||
if (className.isEmpty()) {
|
|
||||||
logger.warn("Missing JSON key '{}' at index {}", KEY_CLASS, i);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
JSONArray params = taskJson.optJSONArray(KEY_PARAMS);
|
|
||||||
if (params == null) {
|
|
||||||
logger.warn("Missing JSON key '{}' for class: {}", KEY_PARAMS, className);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
JSONArray types = taskJson.optJSONArray(KEY_TYPES);
|
|
||||||
if (types == null) {
|
|
||||||
logger.warn("Missing JSON key '{}' for class: {}", KEY_TYPES, className);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("{} mappings registered", taskCreators.size());
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package me.bobert.netneutrality.db;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JDBC connection settings shared by Flyway ({@link DatabaseMigration}) and Hibernate
|
||||||
|
* ({@link SessionManager}), so both always connect to the same database.
|
||||||
|
*
|
||||||
|
* @param url the JDBC url
|
||||||
|
* @param user the database user
|
||||||
|
* @param password the database password (never logged, see {@link #toString()})
|
||||||
|
*/
|
||||||
|
public record DatabaseConfig(String url, String user, String password) {
|
||||||
|
|
||||||
|
public DatabaseConfig {
|
||||||
|
Objects.requireNonNull(url, "url");
|
||||||
|
Objects.requireNonNull(user, "user");
|
||||||
|
password = password == null ? "" : password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the config from the {@code MYSQL_*} settings.
|
||||||
|
*
|
||||||
|
* @param settings the bot settings
|
||||||
|
* @return the database config
|
||||||
|
*/
|
||||||
|
public static DatabaseConfig from(SettingsProvider settings) {
|
||||||
|
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
|
||||||
|
String dbUser = settings.get("MYSQL_USER", "root");
|
||||||
|
String dbPass = settings.get("MYSQL_PASSWORD", "");
|
||||||
|
String dbHost = settings.get("MYSQL_HOST", "localhost");
|
||||||
|
String dbPort = settings.get("MYSQL_PORT", "3306");
|
||||||
|
return new DatabaseConfig(String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName), dbUser, dbPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "DatabaseConfig[url=" + url + ", user=" + user + ", password=***]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,39 @@
|
|||||||
package me.bobert.netneutrality.db;
|
package me.bobert.netneutrality.db;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
*
|
*
|
||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
import org.flywaydb.core.api.output.MigrateResult;
|
import org.flywaydb.core.api.output.MigrateResult;
|
||||||
|
|
||||||
public class DatabaseMigration {
|
public final class DatabaseMigration {
|
||||||
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class);
|
private static final Logger logger = LogManager.getLogger(DatabaseMigration.class);
|
||||||
|
|
||||||
public static void migrate(SettingsProvider settings) {
|
private DatabaseMigration() {
|
||||||
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
|
}
|
||||||
String dbUser = settings.get("MYSQL_USER", "root");
|
|
||||||
String dbPass = settings.get("MYSQL_PASSWORD", "");
|
|
||||||
String dbHost = settings.get("MYSQL_HOST", "localhost");
|
|
||||||
String dbPort = settings.get("MYSQL_PORT", "3306");
|
|
||||||
String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies all pending Flyway migrations to the configured database.
|
||||||
|
*
|
||||||
|
* @param config the database connection settings
|
||||||
|
* @throws IllegalStateException if the migration fails
|
||||||
|
*/
|
||||||
|
public static void migrate(DatabaseConfig config) {
|
||||||
try {
|
try {
|
||||||
logger.info("Running database migrations..");
|
logger.info("Running database migrations..");
|
||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(dbUrl, dbUser, dbPass)
|
.dataSource(config.url(), config.user(), config.password())
|
||||||
.load();
|
.load();
|
||||||
|
|
||||||
MigrateResult result = flyway.migrate();
|
MigrateResult result = flyway.migrate();
|
||||||
logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted);
|
logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error(e);
|
throw new IllegalStateException("Database migration failed", e);
|
||||||
System.exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,64 @@
|
|||||||
package me.bobert.netneutrality.db;
|
package me.bobert.netneutrality.db;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
*
|
*
|
||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-guild settings stored in the {@code guild_settings} table.
|
||||||
|
*
|
||||||
|
* <p>Only values that differ from the default are stored; setting a value back to its default (or
|
||||||
|
* {@code null}) deletes the row. {@link #getName()} is the key persisted in the {@code name}
|
||||||
|
* column and must never change for an existing setting.
|
||||||
|
*/
|
||||||
public enum GSettings {
|
public enum GSettings {
|
||||||
//TODO: description
|
/** The role required to use music commands; empty means everyone may use them. */
|
||||||
MUSIC_ROLE("MUSIC_ROLE", ""),
|
MUSIC_ROLE("MUSIC_ROLE", "", "The role required to use music commands; empty means everyone may use them", SettingType.ROLE),
|
||||||
MUSIC_CHANNEL("MUSIC_CHANNEL", ""),
|
/** Id of the only text channel music commands may be used in; empty means any channel. */
|
||||||
MUSIC_VOLUME("MUSIC_VOLUME", "30"),
|
MUSIC_CHANNEL("MUSIC_CHANNEL", "", "Id of the only text channel music commands may be used in; empty means any channel", SettingType.CHANNEL),
|
||||||
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20");
|
/** Default playback volume (0-100) applied when a music player is created. */
|
||||||
|
MUSIC_VOLUME("MUSIC_VOLUME", "30", "Default playback volume (0-100) applied when a music player is created", SettingType.INTEGER),
|
||||||
|
/** Playlist size above which the bot asks for confirmation before queueing every track. */
|
||||||
|
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20", "Playlist size above which the bot asks for confirmation before queueing every track", SettingType.INTEGER),
|
||||||
|
/** Role that may skip/stop/adjust volume without needing to be the current DJ; empty means the built-in DJ rule only. */
|
||||||
|
MUSIC_DJ_ROLE("MUSIC_DJ_ROLE", "", "Role that may control music playback without being the current DJ; empty means the built-in DJ rule only", SettingType.ROLE),
|
||||||
|
/** Percentage (0-100) of connected listeners required to approve a vote-skip. */
|
||||||
|
MUSIC_VOTE_SKIP_THRESHOLD("MUSIC_VOTE_SKIP_THRESHOLD", "50", "Percentage (0-100) of connected listeners required to approve a vote-skip", SettingType.PERCENTAGE),
|
||||||
|
/** Minutes of voice channel inactivity before the bot automatically disconnects. */
|
||||||
|
MUSIC_IDLE_TIMEOUT("MUSIC_IDLE_TIMEOUT", "10", "Minutes of voice channel inactivity before the bot automatically disconnects", SettingType.INTEGER),
|
||||||
|
/** Per-guild override of the bot's message command prefix; empty means the bot-wide default. */
|
||||||
|
COMMAND_PREFIX("COMMAND_PREFIX", "", "Per-guild override of the bot's message command prefix; empty means the bot-wide default", SettingType.STRING),
|
||||||
|
/** Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks. */
|
||||||
|
ADMIN_ROLE("ADMIN_ROLE", "", "Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks", SettingType.ROLE),
|
||||||
|
/** Comma-separated list of command names disabled on this guild. */
|
||||||
|
DISABLED_COMMANDS("DISABLED_COMMANDS", "", "Comma-separated list of command names disabled on this guild", SettingType.STRING),
|
||||||
|
/** Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO). */
|
||||||
|
LOG_LEVEL("LOG_LEVEL", "ERROR", "Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO)", SettingType.ENUM, "ERROR", "WARN", "INFO", "DEBUG"),
|
||||||
|
/** Preferred locale for the bot's replies on this guild. */
|
||||||
|
LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild", SettingType.STRING),
|
||||||
|
/** Timezone used to display timestamps for this guild. */
|
||||||
|
TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild", SettingType.STRING),
|
||||||
|
/** Default search engine used when a play query is not a direct url (YOUTUBE, YTMUSIC or SOUNDCLOUD). */
|
||||||
|
MUSIC_SEARCH_ENGINE("MUSIC_SEARCH_ENGINE", "YOUTUBE", "Default search engine used when a play query is not a direct url", SettingType.ENUM, "YOUTUBE", "YTMUSIC", "SOUNDCLOUD");
|
||||||
|
|
||||||
private final String name;
|
private final String name;
|
||||||
private final String defaultValue;
|
private final String defaultValue;
|
||||||
|
private final String description;
|
||||||
|
private final SettingType type;
|
||||||
|
private final String[] allowedValues;
|
||||||
|
|
||||||
GSettings(String name, String defaultValue) {
|
GSettings(String name, String defaultValue, String description, SettingType type) {
|
||||||
|
this(name, defaultValue, description, type, new String[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
GSettings(String name, String defaultValue, String description, SettingType type, String... allowedValues) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.defaultValue = defaultValue;
|
this.defaultValue = defaultValue;
|
||||||
|
this.description = description;
|
||||||
|
this.type = type;
|
||||||
|
this.allowedValues = allowedValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
@@ -27,4 +68,16 @@ public enum GSettings {
|
|||||||
public String getDefaultValue() {
|
public String getDefaultValue() {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String[] getAllowedValues() {
|
||||||
|
return allowedValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package me.bobert.netneutrality.db;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import me.bobert.netneutrality.db.entities.OGuild;
|
|
||||||
import me.bobert.netneutrality.db.service.GuildService;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
|
||||||
import org.apache.logging.log4j.Logger;
|
|
||||||
import org.hibernate.HibernateException;
|
|
||||||
import org.hibernate.ObjectNotFoundException;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
import org.hibernate.Transaction;
|
|
||||||
import org.hibernate.exception.ConstraintViolationException;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
import java.util.function.Function;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static Hibernate helpers used by repositories and domain services.
|
|
||||||
*
|
|
||||||
* <p>Because repositories are created deep in the object graph without constructor injection,
|
|
||||||
* this class holds a single {@link SessionProvider} bound once during bootstrap via
|
|
||||||
* {@link #bind(SessionProvider)}. That is the only global access point for sessions — there is no
|
|
||||||
* {@code SessionManager.instance()} shim.
|
|
||||||
*/
|
|
||||||
public class JpaUtils {
|
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(JpaUtils.class);
|
|
||||||
private static volatile SessionProvider sessions;
|
|
||||||
private static volatile Function<String, GuildService> guildServiceLookup;
|
|
||||||
|
|
||||||
private JpaUtils() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the application {@link SessionProvider}. Called once from {@code SessionManager}'s
|
|
||||||
* constructor during context registration.
|
|
||||||
*
|
|
||||||
* @param provider the live session provider
|
|
||||||
*/
|
|
||||||
public static void bind(SessionProvider provider) {
|
|
||||||
sessions = Objects.requireNonNull(provider, "SessionProvider");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional guild-service lookup used when recovering a missing {@link OGuild} entity. Bound from
|
|
||||||
* bootstrap once the {@code GuildServiceProvider} is available.
|
|
||||||
*
|
|
||||||
* @param lookup guild-id → service resolver
|
|
||||||
*/
|
|
||||||
public static void bindGuildServiceLookup(Function<String, GuildService> lookup) {
|
|
||||||
guildServiceLookup = Objects.requireNonNull(lookup, "guildServiceLookup");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SessionFactory factory() {
|
|
||||||
SessionProvider provider = sessions;
|
|
||||||
if (provider == null) {
|
|
||||||
throw new IllegalStateException("JpaUtils has not been bound to a SessionProvider");
|
|
||||||
}
|
|
||||||
return provider.getSessionFactory();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tries to read the persistent state into the object.
|
|
||||||
* <p>
|
|
||||||
* If the object is not found in the database and is of type {@link OGuild}, tries to recreate entity and throws
|
|
||||||
* an exception to block execution.
|
|
||||||
* <p>
|
|
||||||
*
|
|
||||||
* @param session The session in which to load the persistent object
|
|
||||||
* @param object The persistent object
|
|
||||||
* @param id Identifier
|
|
||||||
*/
|
|
||||||
public static void tryLoad(Session session, Object object, Serializable id) {
|
|
||||||
try {
|
|
||||||
session.load(object, id);
|
|
||||||
} catch (HibernateException exception) {
|
|
||||||
if (exception instanceof ObjectNotFoundException) {
|
|
||||||
if (object instanceof OGuild oGuild) {
|
|
||||||
String cachedId = oGuild.getGuildId();
|
|
||||||
logger.error("Trying to load Guild[{}] while entity doesn't exist in database, reloading..", cachedId);
|
|
||||||
|
|
||||||
Function<String, GuildService> lookup = guildServiceLookup;
|
|
||||||
if (lookup == null) {
|
|
||||||
session.close();
|
|
||||||
throw exception;
|
|
||||||
}
|
|
||||||
GuildService service = lookup.apply(cachedId);
|
|
||||||
service.reloadEntity();
|
|
||||||
service.getBlacklistService().removeGuildBlacklist();
|
|
||||||
logger.info("Guild[{}] reloaded", cachedId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session.close();
|
|
||||||
throw exception;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Session openSession() {
|
|
||||||
return factory().openSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void runWithSession(Consumer<Session> consumer) {
|
|
||||||
try (Session session = factory().openSession()) {
|
|
||||||
consumer.accept(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void runWithTransaction(Consumer<Session> consumer) {
|
|
||||||
try (Session session = factory().openSession()) {
|
|
||||||
session.beginTransaction();
|
|
||||||
consumer.accept(session);
|
|
||||||
session.getTransaction().commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void updateWithTransaction(Object persistantEntity, Runnable runnable) {
|
|
||||||
try (Session session = factory().openSession()) {
|
|
||||||
Transaction transaction = session.beginTransaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
runnable.run();
|
|
||||||
|
|
||||||
session.merge(persistantEntity);
|
|
||||||
transaction.commit();
|
|
||||||
} catch (ConstraintViolationException e) {
|
|
||||||
logger.error(e);
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error(e);
|
|
||||||
transaction.rollback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the entity and relations from the database and executes the given consumer.
|
|
||||||
* This will load all Lazy relationships defined in the entity.
|
|
||||||
*
|
|
||||||
* @param persistantEntity The entity to load
|
|
||||||
* @param id The identifier for the entity
|
|
||||||
* @param consumer The comsumer, which exposes the session
|
|
||||||
*/
|
|
||||||
public static void getWithSession(Object persistantEntity, Serializable id, Consumer<Session> consumer) {
|
|
||||||
try (Session session = factory().openSession()) {
|
|
||||||
tryLoad(session, persistantEntity, id);
|
|
||||||
consumer.accept(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package me.bobert.netneutrality.db;
|
|
||||||
/*
|
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
|
||||||
import org.apache.logging.log4j.Logger;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.Transaction;
|
|
||||||
import org.hibernate.exception.ConstraintViolationException;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface Repository<O> {
|
|
||||||
Logger logger = LogManager.getLogger(Repository.class);
|
|
||||||
|
|
||||||
List<O> findAll();
|
|
||||||
|
|
||||||
O findById(String id);
|
|
||||||
|
|
||||||
default void save(O entity) {
|
|
||||||
Transaction transaction = null;
|
|
||||||
|
|
||||||
try (Session session = JpaUtils.openSession()) {
|
|
||||||
transaction = session.beginTransaction();
|
|
||||||
session.merge(entity);
|
|
||||||
transaction.commit();
|
|
||||||
} catch (ConstraintViolationException e) {
|
|
||||||
logger.error(e);
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error(e);
|
|
||||||
|
|
||||||
if (transaction != null) {
|
|
||||||
transaction.rollback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void deleteBy(String id);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package me.bobert.netneutrality.db;
|
package me.bobert.netneutrality.db;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
*
|
*
|
||||||
@@ -10,7 +11,6 @@ import me.bobert.netneutrality.utils.SettingsProvider;
|
|||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.hibernate.SessionFactory;
|
import org.hibernate.SessionFactory;
|
||||||
import org.hibernate.boot.Metadata;
|
|
||||||
import org.hibernate.boot.MetadataSources;
|
import org.hibernate.boot.MetadataSources;
|
||||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||||
@@ -18,24 +18,34 @@ import org.hibernate.cfg.Environment;
|
|||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its
|
* Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its
|
||||||
* service registry.
|
* service registry.
|
||||||
*
|
*
|
||||||
* <p>On construction the instance is bound into {@link JpaUtils} so repositories and other static
|
* <p>{@link #start()} first applies the Flyway migrations and only then builds the session factory,
|
||||||
* persistence helpers can open sessions without a global {@code SessionManager} singleton.
|
* which validates the mapped entities against the migrated schema ({@code hbm2ddl.auto=validate}).
|
||||||
*/
|
*/
|
||||||
public class SessionManager implements SessionProvider {
|
public class SessionManager implements SessionProvider {
|
||||||
private static final Logger logger = LogManager.getLogger(SessionManager.class);
|
private static final Logger logger = LogManager.getLogger(SessionManager.class);
|
||||||
|
|
||||||
private final SettingsProvider settings;
|
private final DatabaseConfig config;
|
||||||
|
private final boolean reportErrors;
|
||||||
private StandardServiceRegistry registry;
|
private StandardServiceRegistry registry;
|
||||||
private SessionFactory sessionFactory;
|
private volatile SessionFactory sessionFactory;
|
||||||
|
|
||||||
public SessionManager(SettingsProvider settings) {
|
public SessionManager(SettingsProvider settings) {
|
||||||
this.settings = settings;
|
this(DatabaseConfig.from(settings), !"dev".equals(settings.get("NETNEUTRALITY_BOT_ENV", "dev")));
|
||||||
JpaUtils.bind(this);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param config the database connection settings
|
||||||
|
* @param reportErrors whether startup failures are reported to Sentry
|
||||||
|
*/
|
||||||
|
public SessionManager(DatabaseConfig config, boolean reportErrors) {
|
||||||
|
this.config = Objects.requireNonNull(config, "config");
|
||||||
|
this.reportErrors = reportErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -44,55 +54,58 @@ public class SessionManager implements SessionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start() {
|
public synchronized void start() {
|
||||||
getSessionFactory();
|
if (sessionFactory != null) {
|
||||||
DatabaseMigration.migrate(settings);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("Connecting to database '{}' with user '{}'", config.url(), config.user());
|
||||||
|
DatabaseMigration.migrate(config);
|
||||||
|
sessionFactory = buildSessionFactory();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
if (reportErrors) {
|
||||||
|
Sentry.captureException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop();
|
||||||
|
logger.error("Error connecting to database. Shutting down..", e);
|
||||||
|
throw new IllegalStateException("Unable to connect to the database", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionFactory buildSessionFactory() {
|
||||||
|
Map<String, Object> settings = new HashMap<>();
|
||||||
|
settings.put(Environment.JAKARTA_JDBC_URL, config.url());
|
||||||
|
settings.put(Environment.JAKARTA_JDBC_USER, config.user());
|
||||||
|
settings.put(Environment.JAKARTA_JDBC_PASSWORD, config.password());
|
||||||
|
|
||||||
|
registry = new StandardServiceRegistryBuilder()
|
||||||
|
.configure()
|
||||||
|
.applySettings(settings)
|
||||||
|
.build();
|
||||||
|
return new MetadataSources(registry).buildMetadata().buildSessionFactory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public synchronized void stop() {
|
||||||
|
SessionFactory factory = sessionFactory;
|
||||||
|
sessionFactory = null;
|
||||||
|
if (factory != null) {
|
||||||
|
factory.close();
|
||||||
|
}
|
||||||
if (registry != null) {
|
if (registry != null) {
|
||||||
StandardServiceRegistryBuilder.destroy(registry);
|
StandardServiceRegistryBuilder.destroy(registry);
|
||||||
registry = null;
|
registry = null;
|
||||||
sessionFactory = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SessionFactory getSessionFactory() {
|
public SessionFactory getSessionFactory() {
|
||||||
if (sessionFactory == null) {
|
SessionFactory factory = sessionFactory;
|
||||||
try {
|
if (factory == null) {
|
||||||
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure();
|
throw new IllegalStateException("SessionManager has not been started");
|
||||||
Map<String, Object> config = new HashMap<>();
|
|
||||||
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
|
|
||||||
String dbUser = settings.get("MYSQL_USER", "root");
|
|
||||||
String dbPass = settings.get("MYSQL_PASSWORD", "");
|
|
||||||
String dbHost = settings.get("MYSQL_HOST", "localhost");
|
|
||||||
String dbPort = settings.get("MYSQL_PORT", "3306");
|
|
||||||
logger.info("Connecting to database '{}' at {}:{} with user '{}'", dbName, dbHost, dbPort, dbUser);
|
|
||||||
|
|
||||||
config.put(Environment.JAKARTA_JDBC_URL, String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
|
|
||||||
config.put(Environment.JAKARTA_JDBC_USER, dbUser);
|
|
||||||
config.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
|
|
||||||
registryBuilder.applySettings(config);
|
|
||||||
|
|
||||||
registry = registryBuilder.build();
|
|
||||||
MetadataSources sources = new MetadataSources(registry);
|
|
||||||
Metadata metadata = sources.getMetadataBuilder().build();
|
|
||||||
sessionFactory = metadata.getSessionFactoryBuilder().build();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev");
|
|
||||||
if (!"dev".equals(env)) {
|
|
||||||
Sentry.captureException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
stop();
|
|
||||||
logger.error("Error connecting to database. Shutting down..");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return factory;
|
||||||
return sessionFactory;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package me.bobert.netneutrality.db;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classification of a {@link GSettings} value, used to validate incoming values and to let
|
||||||
|
* clients (e.g. a web app) render the appropriate input widget.
|
||||||
|
*/
|
||||||
|
public enum SettingType {
|
||||||
|
STRING,
|
||||||
|
INTEGER,
|
||||||
|
PERCENTAGE,
|
||||||
|
BOOLEAN,
|
||||||
|
ROLE,
|
||||||
|
CHANNEL,
|
||||||
|
ENUM
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package me.bobert.netneutrality.db;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import org.hibernate.StatelessSession;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs work on a Hibernate {@link StatelessSession}, the session type used by the generated
|
||||||
|
* Jakarta Data repositories.
|
||||||
|
*
|
||||||
|
* <p>{@link #inTx(Function)} commits on success and rolls back and rethrows on failure.
|
||||||
|
*/
|
||||||
|
public class TransactionRunner {
|
||||||
|
|
||||||
|
private final SessionProvider sessions;
|
||||||
|
|
||||||
|
public TransactionRunner(SessionProvider sessions) {
|
||||||
|
this.sessions = Objects.requireNonNull(sessions, "sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public <R> R read(Function<StatelessSession, R> work) {
|
||||||
|
return sessions.getSessionFactory().fromStatelessSession(work);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <R> R inTx(Function<StatelessSession, R> work) {
|
||||||
|
return sessions.getSessionFactory().fromStatelessTransaction(work);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package me.bobert.netneutrality.db.data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jakarta.data.repository.CrudRepository;
|
||||||
|
import jakarta.data.repository.Query;
|
||||||
|
import jakarta.data.repository.Repository;
|
||||||
|
import me.bobert.netneutrality.db.entities.OBlacklist;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface BlacklistQueries extends CrudRepository<OBlacklist, Integer> {
|
||||||
|
|
||||||
|
@Query("from OBlacklist where guild.guildId = :guildId order by id")
|
||||||
|
List<OBlacklist> byGuild(String guildId);
|
||||||
|
|
||||||
|
@Query("select word from OBlacklist where guild.guildId = :guildId and enabled = true")
|
||||||
|
List<String> enabledWords(String guildId);
|
||||||
|
|
||||||
|
@Query("from OBlacklist where upper(word) = upper(:word) and guild.guildId = :guildId")
|
||||||
|
List<OBlacklist> findWord(String guildId, String word);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package me.bobert.netneutrality.db.data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jakarta.data.repository.CrudRepository;
|
||||||
|
import jakarta.data.repository.Repository;
|
||||||
|
import me.bobert.netneutrality.db.entities.OGuild;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface GuildQueries extends CrudRepository<OGuild, String> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package me.bobert.netneutrality.db.data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jakarta.data.repository.CrudRepository;
|
||||||
|
import jakarta.data.repository.Query;
|
||||||
|
import jakarta.data.repository.Repository;
|
||||||
|
import me.bobert.netneutrality.db.entities.OGuildSetting;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface GuildSettingQueries extends CrudRepository<OGuildSetting, Integer> {
|
||||||
|
|
||||||
|
@Query("from OGuildSetting where guild.guildId = :guildId")
|
||||||
|
List<OGuildSetting> byGuild(String guildId);
|
||||||
|
|
||||||
|
@Query("from OGuildSetting where guild.guildId = :guildId and upper(name) = upper(:name)")
|
||||||
|
List<OGuildSetting> byName(String guildId, String name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package me.bobert.netneutrality.db.data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jakarta.data.Limit;
|
||||||
|
import jakarta.data.repository.CrudRepository;
|
||||||
|
import jakarta.data.repository.Find;
|
||||||
|
import jakarta.data.repository.Query;
|
||||||
|
import jakarta.data.repository.Repository;
|
||||||
|
import me.bobert.netneutrality.db.entities.OLogMessage;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface MessageQueries extends CrudRepository<OLogMessage, Integer> {
|
||||||
|
|
||||||
|
@Find
|
||||||
|
Optional<OLogMessage> byMessageId(String messageId);
|
||||||
|
|
||||||
|
@Query("delete from OLogMessage where messageId = :messageId")
|
||||||
|
int deleteByMessageId(String messageId);
|
||||||
|
|
||||||
|
@Query("from OLogMessage where guild.guildId = :guildId and userId = :userId order by dateSent desc")
|
||||||
|
List<OLogMessage> byUser(String userId, String guildId, Limit limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package me.bobert.netneutrality.db.data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jakarta.data.repository.CrudRepository;
|
||||||
|
import jakarta.data.repository.Find;
|
||||||
|
import jakarta.data.repository.Query;
|
||||||
|
import jakarta.data.repository.Repository;
|
||||||
|
import me.bobert.netneutrality.db.entities.OTask;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TaskQueries extends CrudRepository<OTask, Integer> {
|
||||||
|
|
||||||
|
@Find
|
||||||
|
Optional<OTask> byTaskId(String taskId);
|
||||||
|
|
||||||
|
@Query("delete from OTask where taskId = :taskId")
|
||||||
|
int deleteByTaskId(String taskId);
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "blacklist")
|
@Table(name = "blacklist")
|
||||||
@@ -76,4 +79,26 @@ public class OBlacklist {
|
|||||||
public void setId(Integer id) {
|
public void setId(Integer id) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity is the database id: two instances are equal when they have the same non-null id,
|
||||||
|
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OBlacklist other = (OBlacklist) o;
|
||||||
|
return getId() != null && Objects.equals(getId(), other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Constant per class so the hash never changes when the id is assigned on persist.
|
||||||
|
return Hibernate.getClass(this).hashCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,10 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.OneToMany;
|
import jakarta.persistence.OneToMany;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Optional;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -33,6 +34,12 @@ public class OGuild {
|
|||||||
@Column(name = "log_channel")
|
@Column(name = "log_channel")
|
||||||
private String logChannel;
|
private String logChannel;
|
||||||
|
|
||||||
|
@Column(name = "guild_name")
|
||||||
|
private String guildName;
|
||||||
|
|
||||||
|
@Column(name = "guild_icon_hash")
|
||||||
|
private String guildIconHash;
|
||||||
|
|
||||||
@OneToMany(mappedBy = "guild")
|
@OneToMany(mappedBy = "guild")
|
||||||
private Set<OGuildSetting> guildSettings = new LinkedHashSet<>();
|
private Set<OGuildSetting> guildSettings = new LinkedHashSet<>();
|
||||||
|
|
||||||
@@ -43,14 +50,14 @@ public class OGuild {
|
|||||||
private Set<OLogMessage> logMessages = new LinkedHashSet<>();
|
private Set<OLogMessage> logMessages = new LinkedHashSet<>();
|
||||||
|
|
||||||
@OneToMany(mappedBy = "guild")
|
@OneToMany(mappedBy = "guild")
|
||||||
private Set<OLogging> OLoggings = new LinkedHashSet<>();
|
private Set<OLogging> loggings = new LinkedHashSet<>();
|
||||||
|
|
||||||
public Set<OLogging> getLoggings() {
|
public Set<OLogging> getLoggings() {
|
||||||
return OLoggings;
|
return loggings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLoggings(Set<OLogging> OLoggings) {
|
public void setLoggings(Set<OLogging> loggings) {
|
||||||
this.OLoggings = OLoggings;
|
this.loggings = loggings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<OLogMessage> getLogMessages() {
|
public Set<OLogMessage> getLogMessages() {
|
||||||
@@ -110,13 +117,41 @@ public class OGuild {
|
|||||||
this.guildId = guildId;
|
this.guildId = guildId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public OGuildSetting getSettingByName(String name) {
|
public String getGuildName() {
|
||||||
Optional<OGuildSetting> setting = getGuildSettings()
|
return guildName;
|
||||||
.stream()
|
|
||||||
.filter(setting1 -> setting1.getName().equalsIgnoreCase(name))
|
|
||||||
.findFirst();
|
|
||||||
|
|
||||||
return setting.orElse(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setGuildName(String guildName) {
|
||||||
|
this.guildName = guildName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getGuildIconHash() {
|
||||||
|
return guildIconHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGuildIconHash(String guildIconHash) {
|
||||||
|
this.guildIconHash = guildIconHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity is the database id: two instances are equal when they have the same non-null id,
|
||||||
|
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OGuild other = (OGuild) o;
|
||||||
|
return getGuildId() != null && Objects.equals(getGuildId(), other.getGuildId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Constant per class so the hash never changes when the id is assigned on persist.
|
||||||
|
return Hibernate.getClass(this).hashCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,17 +13,14 @@ import jakarta.persistence.GeneratedValue;
|
|||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.NamedQueries;
|
|
||||||
import jakarta.persistence.NamedQuery;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "guild_settings")
|
@Table(name = "guild_settings")
|
||||||
@NamedQueries({
|
|
||||||
@NamedQuery(name = "settings_findByName", query = "select o from OGuildSetting o where o.guild.id = :id and upper(o.name) = upper(:name)")
|
|
||||||
})
|
|
||||||
public class OGuildSetting {
|
public class OGuildSetting {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@@ -33,8 +30,7 @@ public class OGuildSetting {
|
|||||||
@Column(name = "name", nullable = false)
|
@Column(name = "name", nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Lob
|
@Column(name = "value", nullable = false, columnDefinition = "text")
|
||||||
@Column(name = "value", nullable = false)
|
|
||||||
private String value;
|
private String value;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@@ -72,4 +68,26 @@ public class OGuildSetting {
|
|||||||
public void setId(Integer id) {
|
public void setId(Integer id) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity is the database id: two instances are equal when they have the same non-null id,
|
||||||
|
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OGuildSetting other = (OGuildSetting) o;
|
||||||
|
return getId() != null && Objects.equals(getId(), other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Constant per class so the hash never changes when the id is assigned on persist.
|
||||||
|
return Hibernate.getClass(this).hashCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,8 +16,10 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "log_message")
|
@Table(name = "log_message")
|
||||||
@@ -113,4 +115,26 @@ public class OLogMessage {
|
|||||||
public void setId(Integer id) {
|
public void setId(Integer id) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity is the database id: two instances are equal when they have the same non-null id,
|
||||||
|
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OLogMessage other = (OLogMessage) o;
|
||||||
|
return getId() != null && Objects.equals(getId(), other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Constant per class so the hash never changes when the id is assigned on persist.
|
||||||
|
return Hibernate.getClass(this).hashCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,9 @@ import jakarta.persistence.JoinColumn;
|
|||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.MapsId;
|
import jakarta.persistence.MapsId;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "logging")
|
@Table(name = "logging")
|
||||||
@@ -52,4 +55,26 @@ public class OLogging {
|
|||||||
public void setId(OLoggingId id) {
|
public void setId(OLoggingId id) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity is the database id: two instances are equal when they have the same non-null id,
|
||||||
|
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OLogging other = (OLogging) o;
|
||||||
|
return getId() != null && Objects.equals(getId(), other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Constant per class so the hash never changes when the id is assigned on persist.
|
||||||
|
return Hibernate.getClass(this).hashCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,9 +10,11 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "tasks", uniqueConstraints = {@UniqueConstraint(columnNames = {"task_id"})})
|
@Table(name = "tasks", uniqueConstraints = {@UniqueConstraint(columnNames = {"task_id"})})
|
||||||
@@ -25,11 +27,13 @@ public class OTask {
|
|||||||
@Column(name = "task_id", nullable = false)
|
@Column(name = "task_id", nullable = false)
|
||||||
private String taskId;
|
private String taskId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable logical task type id ({@code PersistableTask#typeId()}), not a class name.
|
||||||
|
*/
|
||||||
@Column(name = "task_class", nullable = false)
|
@Column(name = "task_class", nullable = false)
|
||||||
private String taskClass;
|
private String taskClass;
|
||||||
|
|
||||||
@Lob
|
@Column(name = "payload", columnDefinition = "text")
|
||||||
@Column(name = "payload")
|
|
||||||
private String payload;
|
private String payload;
|
||||||
|
|
||||||
public Integer getId() {
|
public Integer getId() {
|
||||||
@@ -64,4 +68,25 @@ public class OTask {
|
|||||||
this.payload = payload;
|
this.payload = payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity is the database id: two instances are equal when they have the same non-null id,
|
||||||
|
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OTask other = (OTask) o;
|
||||||
|
return getId() != null && Objects.equals(getId(), other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Constant per class so the hash never changes when the id is assigned on persist.
|
||||||
|
return Hibernate.getClass(this).hashCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,12 @@ package me.bobert.netneutrality.db.entities;
|
|||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Lob;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import org.hibernate.annotations.ColumnDefault;
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "web_users")
|
@Table(name = "web_users")
|
||||||
@@ -22,8 +23,7 @@ public class OWebUser {
|
|||||||
@Column(name = "id", nullable = false)
|
@Column(name = "id", nullable = false)
|
||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
@Lob
|
@Column(name = "email", nullable = false, columnDefinition = "mediumtext")
|
||||||
@Column(name = "email", nullable = false)
|
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
@Column(name = "token", nullable = false)
|
@Column(name = "token", nullable = false)
|
||||||
@@ -87,4 +87,25 @@ public class OWebUser {
|
|||||||
this.rememberToken = rememberToken;
|
this.rememberToken = rememberToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity is the database id: two instances are equal when they have the same non-null id,
|
||||||
|
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OWebUser other = (OWebUser) o;
|
||||||
|
return getId() != null && Objects.equals(getId(), other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Constant per class so the hash never changes when the id is assigned on persist.
|
||||||
|
return Hibernate.getClass(this).hashCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,104 +1,143 @@
|
|||||||
package me.bobert.netneutrality.db.repository;
|
package me.bobert.netneutrality.db.repository;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
*
|
*
|
||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.db.Repository;
|
import me.bobert.netneutrality.db.SessionProvider;
|
||||||
|
import me.bobert.netneutrality.db.TransactionRunner;
|
||||||
|
import me.bobert.netneutrality.db.data.BlacklistQueries_;
|
||||||
import me.bobert.netneutrality.db.entities.OBlacklist;
|
import me.bobert.netneutrality.db.entities.OBlacklist;
|
||||||
import me.bobert.netneutrality.db.entities.OGuild;
|
import me.bobert.netneutrality.db.entities.OGuild;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.query.Query;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public class BlacklistRepository implements Repository<OBlacklist> {
|
public class BlacklistRepository {
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public List<OBlacklist> findAll() {
|
* Outcome of {@link #enableWord(String, String, String)}.
|
||||||
List<OBlacklist> blacklists = new ArrayList<>();
|
*/
|
||||||
|
public enum EnableOutcome {
|
||||||
JpaUtils.runWithSession(session -> {
|
/** A new row was inserted. */
|
||||||
Query<OBlacklist> query = session.createQuery("FROM OBlacklist", OBlacklist.class);
|
CREATED,
|
||||||
|
/** An existing, disabled row was re-enabled. */
|
||||||
blacklists.addAll(query.list());
|
ENABLED,
|
||||||
});
|
/** The word was already enabled. */
|
||||||
|
UNCHANGED
|
||||||
return blacklists;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private final TransactionRunner tx;
|
||||||
public OBlacklist findById(String id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
public BlacklistRepository(SessionProvider sessions) {
|
||||||
public void deleteBy(String id) {
|
this.tx = new TransactionRunner(sessions);
|
||||||
JpaUtils.runWithTransaction(session -> {
|
|
||||||
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.id = :blacklistId", OBlacklist.class);
|
|
||||||
query.setParameter("blacklistId", id);
|
|
||||||
|
|
||||||
OBlacklist oTask = query.uniqueResult();
|
|
||||||
if (oTask != null) {
|
|
||||||
session.remove(oTask);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OBlacklist> findByGuildId(String guildId) {
|
public List<OBlacklist> findByGuildId(String guildId) {
|
||||||
List<OBlacklist> blacklists = new ArrayList<>();
|
return tx.read(session -> new BlacklistQueries_(session).byGuild(guildId));
|
||||||
|
|
||||||
JpaUtils.runWithSession(session -> {
|
|
||||||
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.guild.guildId = :guildId", OBlacklist.class);
|
|
||||||
query.setParameter("guildId", guildId);
|
|
||||||
|
|
||||||
blacklists.addAll(query.list());
|
|
||||||
});
|
|
||||||
|
|
||||||
return blacklists;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void add(OGuild oGuild, Member member, String word) {
|
public Set<String> findEnabledWords(String guildId) {
|
||||||
JpaUtils.runWithTransaction(session -> {
|
return tx.read(session -> Set.copyOf(new BlacklistQueries_(session).enabledWords(guildId)));
|
||||||
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word);
|
}
|
||||||
|
|
||||||
if (bl == null) {
|
/**
|
||||||
bl = new OBlacklist();
|
* Adds the word to the guild's blacklist, or re-enables it if it already exists. Lookup and
|
||||||
bl.setGuild(oGuild);
|
* write happen in the same transaction.
|
||||||
bl.setCreatorId(member.getId());
|
*
|
||||||
bl.setWord(word);
|
* @param guildId the guild id (the guild row must exist)
|
||||||
bl.setEnabled(true);
|
* @param creatorId the user adding the word
|
||||||
|
* @param word the word
|
||||||
session.persist(bl);
|
* @return what changed
|
||||||
} else {
|
*/
|
||||||
bl.setEnabled(true);
|
public EnableOutcome enableWord(String guildId, String creatorId, String word) {
|
||||||
session.merge(bl);
|
return tx.inTx(session -> {
|
||||||
|
BlacklistQueries_ queries = new BlacklistQueries_(session);
|
||||||
|
List<OBlacklist> existing = queries.findWord(guildId, word);
|
||||||
|
if (existing.isEmpty()) {
|
||||||
|
OGuild guild = new OGuild();
|
||||||
|
guild.setGuildId(guildId);
|
||||||
|
OBlacklist blacklist = new OBlacklist();
|
||||||
|
blacklist.setGuild(guild);
|
||||||
|
blacklist.setCreatorId(creatorId);
|
||||||
|
blacklist.setWord(word);
|
||||||
|
blacklist.setEnabled(true);
|
||||||
|
queries.insert(blacklist);
|
||||||
|
return EnableOutcome.CREATED;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void delete(OGuild oGuild, String word) {
|
List<OBlacklist> changed = new ArrayList<>();
|
||||||
JpaUtils.runWithTransaction(session -> {
|
for (OBlacklist blacklist : existing) {
|
||||||
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word);
|
if (!Boolean.TRUE.equals(blacklist.getEnabled())) {
|
||||||
if (bl != null) {
|
blacklist.setEnabled(true);
|
||||||
session.remove(bl);
|
changed.add(blacklist);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (changed.isEmpty()) {
|
||||||
|
return EnableOutcome.UNCHANGED;
|
||||||
|
}
|
||||||
|
queries.updateAll(changed);
|
||||||
|
return EnableOutcome.ENABLED;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private OBlacklist getWordFromDb(String guildId, String word) {
|
/**
|
||||||
try (Session session = JpaUtils.openSession()) {
|
* Deletes the word (case-insensitive) from the guild's blacklist.
|
||||||
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class);
|
*
|
||||||
query.setParameter("guildId", guildId);
|
* @param guildId the guild id
|
||||||
query.setParameter("word", word);
|
* @param word the word
|
||||||
OBlacklist blacklist = query.uniqueResult();
|
* @return {@code true} if at least one row was deleted
|
||||||
|
*/
|
||||||
|
public boolean deleteWord(String guildId, String word) {
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
BlacklistQueries_ queries = new BlacklistQueries_(session);
|
||||||
|
List<OBlacklist> existing = queries.findWord(guildId, word);
|
||||||
|
queries.deleteAll(existing);
|
||||||
|
return !existing.isEmpty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
session.close();
|
public List<OBlacklist> findAll() {
|
||||||
return blacklist;
|
return tx.read(session -> new BlacklistQueries_(session).findAll().toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<OBlacklist> findById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.read(session -> new BlacklistQueries_(session).findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates the entity.
|
||||||
|
*
|
||||||
|
* @param entity the entity to save
|
||||||
|
* @return the saved state, which callers should use from then on
|
||||||
|
*/
|
||||||
|
public OBlacklist save(OBlacklist entity) {
|
||||||
|
Objects.requireNonNull(entity, "entity");
|
||||||
|
return tx.inTx(session -> new BlacklistQueries_(session).save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the entity with the given id, if it exists.
|
||||||
|
*
|
||||||
|
* @param id the identifier
|
||||||
|
* @return {@code true} if a row was deleted
|
||||||
|
*/
|
||||||
|
public boolean deleteById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
BlacklistQueries_ queries = new BlacklistQueries_(session);
|
||||||
|
return queries.findById(id)
|
||||||
|
.map(entity -> {
|
||||||
|
queries.delete(entity);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.orElse(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
package me.bobert.netneutrality.db.repository;/*
|
package me.bobert.netneutrality.db.repository;
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
@@ -10,49 +6,76 @@ package me.bobert.netneutrality.db.repository;/*
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.db.Repository;
|
import me.bobert.netneutrality.db.SessionProvider;
|
||||||
|
import me.bobert.netneutrality.db.TransactionRunner;
|
||||||
|
import me.bobert.netneutrality.db.data.GuildQueries_;
|
||||||
import me.bobert.netneutrality.db.entities.OGuild;
|
import me.bobert.netneutrality.db.entities.OGuild;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.query.Query;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public class GuildRepository implements Repository<OGuild> {
|
public class GuildRepository {
|
||||||
@Override
|
|
||||||
public List<OGuild> findAll() {
|
|
||||||
List<OGuild> guilds = new ArrayList<>();
|
|
||||||
|
|
||||||
JpaUtils.runWithSession(session -> {
|
private final TransactionRunner tx;
|
||||||
Query<OGuild> query = session.createQuery("FROM OGuild", OGuild.class);
|
|
||||||
|
|
||||||
guilds.addAll(query.list());
|
public GuildRepository(SessionProvider sessions) {
|
||||||
|
this.tx = new TransactionRunner(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the guild row, inserting it first if it does not exist yet.
|
||||||
|
*
|
||||||
|
* @param guildId the Discord guild id
|
||||||
|
* @return the persistent guild
|
||||||
|
*/
|
||||||
|
public OGuild findOrCreate(String guildId) {
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
GuildQueries_ queries = new GuildQueries_(session);
|
||||||
|
return queries.findById(guildId).orElseGet(() -> {
|
||||||
|
OGuild guild = new OGuild();
|
||||||
|
guild.setGuildId(guildId);
|
||||||
|
return queries.insert(guild);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return guilds;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public List<OGuild> findAll() {
|
||||||
public OGuild findById(String id) {
|
return tx.read(session -> new GuildQueries_(session).findAll().toList());
|
||||||
try (Session session = JpaUtils.openSession()) {
|
|
||||||
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class);
|
|
||||||
query.setParameter("guildId", id);
|
|
||||||
|
|
||||||
return query.uniqueResult();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public Optional<OGuild> findById(String id) {
|
||||||
public void deleteBy(String id) {
|
Objects.requireNonNull(id, "id");
|
||||||
JpaUtils.runWithTransaction(session -> {
|
return tx.read(session -> new GuildQueries_(session).findById(id));
|
||||||
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class);
|
}
|
||||||
query.setParameter("guildId", id);
|
|
||||||
|
|
||||||
OGuild oGuild = query.uniqueResult();
|
/**
|
||||||
if (oGuild != null) {
|
* Inserts or updates the entity.
|
||||||
session.remove(oGuild);
|
*
|
||||||
}
|
* @param entity the entity to save
|
||||||
|
* @return the saved state, which callers should use from then on
|
||||||
|
*/
|
||||||
|
public OGuild save(OGuild entity) {
|
||||||
|
Objects.requireNonNull(entity, "entity");
|
||||||
|
return tx.inTx(session -> new GuildQueries_(session).save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the entity with the given id, if it exists.
|
||||||
|
*
|
||||||
|
* @param id the identifier
|
||||||
|
* @return {@code true} if a row was deleted
|
||||||
|
*/
|
||||||
|
public boolean deleteById(String id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
GuildQueries_ queries = new GuildQueries_(session);
|
||||||
|
return queries.findById(id)
|
||||||
|
.map(entity -> {
|
||||||
|
queries.delete(entity);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.orElse(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
package me.bobert.netneutrality.db.repository;/*
|
package me.bobert.netneutrality.db.repository;
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
@@ -11,88 +7,98 @@ package me.bobert.netneutrality.db.repository;/*
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.db.GSettings;
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
import me.bobert.netneutrality.db.Repository;
|
import me.bobert.netneutrality.db.SessionProvider;
|
||||||
|
import me.bobert.netneutrality.db.TransactionRunner;
|
||||||
|
import me.bobert.netneutrality.db.data.GuildSettingQueries_;
|
||||||
import me.bobert.netneutrality.db.entities.OGuild;
|
import me.bobert.netneutrality.db.entities.OGuild;
|
||||||
import me.bobert.netneutrality.db.entities.OGuildSetting;
|
import me.bobert.netneutrality.db.entities.OGuildSetting;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.query.Query;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public class GuildSettingRepository implements Repository<OGuildSetting> {
|
public class GuildSettingRepository {
|
||||||
@Override
|
|
||||||
public List<OGuildSetting> findAll() {
|
|
||||||
List<OGuildSetting> settings = new ArrayList<>();
|
|
||||||
|
|
||||||
JpaUtils.runWithSession(session -> {
|
private final TransactionRunner tx;
|
||||||
Query<OGuildSetting> query = session.createQuery("FROM OGuildSetting", OGuildSetting.class);
|
|
||||||
|
|
||||||
settings.addAll(query.list());
|
public GuildSettingRepository(SessionProvider sessions) {
|
||||||
});
|
this.tx = new TransactionRunner(sessions);
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public OGuildSetting findById(String id) {
|
|
||||||
try (Session session = JpaUtils.openSession()) {
|
|
||||||
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class);
|
|
||||||
query.setParameter("id", id);
|
|
||||||
|
|
||||||
return query.uniqueResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void deleteBy(String id) {
|
|
||||||
JpaUtils.runWithTransaction(session -> {
|
|
||||||
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class);
|
|
||||||
query.setParameter("id", id);
|
|
||||||
|
|
||||||
OGuildSetting setting = query.uniqueResult();
|
|
||||||
if (setting != null) {
|
|
||||||
session.remove(setting);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OGuildSetting> findByGuild(String guildId) {
|
public List<OGuildSetting> findByGuild(String guildId) {
|
||||||
List<OGuildSetting> settings = new ArrayList<>();
|
return tx.read(session -> new GuildSettingQueries_(session).byGuild(guildId));
|
||||||
|
|
||||||
JpaUtils.runWithSession(session -> {
|
|
||||||
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class);
|
|
||||||
query.setParameter("guildId", guildId);
|
|
||||||
|
|
||||||
settings.addAll(query.list());
|
|
||||||
});
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSettingValue(OGuild oGuild, GSettings setting, String value) {
|
/**
|
||||||
JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> {
|
* Stores a non-default value, or deletes the row when the value is {@code null} or equal to the
|
||||||
OGuildSetting oGuildSetting = oGuild.getSettingByName(setting.getName());
|
* setting's default. Runs in a single transaction.
|
||||||
|
*
|
||||||
|
* @param guildId the guild id (the guild row must exist)
|
||||||
|
* @param setting the setting
|
||||||
|
* @param value the new value, or {@code null} to reset
|
||||||
|
*/
|
||||||
|
public void upsertOrDelete(String guildId, GSettings setting, String value) {
|
||||||
|
tx.inTx(session -> {
|
||||||
|
GuildSettingQueries_ queries = new GuildSettingQueries_(session);
|
||||||
|
List<OGuildSetting> existing = queries.byName(guildId, setting.getName());
|
||||||
|
|
||||||
if (value == null || setting.getDefaultValue().equals(value)) {
|
if (value == null || setting.getDefaultValue().equals(value)) {
|
||||||
if (oGuildSetting != null) {
|
queries.deleteAll(existing);
|
||||||
session.remove(oGuildSetting);
|
return null;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (oGuildSetting == null) {
|
|
||||||
oGuildSetting = new OGuildSetting();
|
|
||||||
oGuildSetting.setGuild(oGuild);
|
|
||||||
oGuildSetting.setName(setting.getName());
|
|
||||||
oGuildSetting.setValue(value);
|
|
||||||
|
|
||||||
session.persist(oGuildSetting);
|
|
||||||
} else {
|
|
||||||
oGuildSetting.setValue(value);
|
|
||||||
|
|
||||||
session.merge(oGuildSetting);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.isEmpty()) {
|
||||||
|
OGuild guild = new OGuild();
|
||||||
|
guild.setGuildId(guildId);
|
||||||
|
OGuildSetting guildSetting = new OGuildSetting();
|
||||||
|
guildSetting.setGuild(guild);
|
||||||
|
guildSetting.setName(setting.getName());
|
||||||
|
guildSetting.setValue(value);
|
||||||
|
queries.insert(guildSetting);
|
||||||
|
} else {
|
||||||
|
existing.forEach(guildSetting -> guildSetting.setValue(value));
|
||||||
|
queries.updateAll(existing);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OGuildSetting> findAll() {
|
||||||
|
return tx.read(session -> new GuildSettingQueries_(session).findAll().toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<OGuildSetting> findById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.read(session -> new GuildSettingQueries_(session).findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates the entity.
|
||||||
|
*
|
||||||
|
* @param entity the entity to save
|
||||||
|
* @return the saved state, which callers should use from then on
|
||||||
|
*/
|
||||||
|
public OGuildSetting save(OGuildSetting entity) {
|
||||||
|
Objects.requireNonNull(entity, "entity");
|
||||||
|
return tx.inTx(session -> new GuildSettingQueries_(session).save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the entity with the given id, if it exists.
|
||||||
|
*
|
||||||
|
* @param id the identifier
|
||||||
|
* @return {@code true} if a row was deleted
|
||||||
|
*/
|
||||||
|
public boolean deleteById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
GuildSettingQueries_ queries = new GuildSettingQueries_(session);
|
||||||
|
return queries.findById(id)
|
||||||
|
.map(entity -> {
|
||||||
|
queries.delete(entity);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.orElse(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,105 +1,137 @@
|
|||||||
package me.bobert.netneutrality.db.repository;
|
package me.bobert.netneutrality.db.repository;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
*
|
*
|
||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.db.Repository;
|
import jakarta.data.Limit;
|
||||||
|
import me.bobert.netneutrality.db.SessionProvider;
|
||||||
|
import me.bobert.netneutrality.db.TransactionRunner;
|
||||||
|
import me.bobert.netneutrality.db.data.MessageQueries_;
|
||||||
import me.bobert.netneutrality.db.entities.OGuild;
|
import me.bobert.netneutrality.db.entities.OGuild;
|
||||||
import me.bobert.netneutrality.db.entities.OLogMessage;
|
import me.bobert.netneutrality.db.entities.OLogMessage;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
|
||||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.query.Query;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public class MessageRepository implements Repository<OLogMessage> {
|
public class MessageRepository {
|
||||||
@Override
|
|
||||||
public List<OLogMessage> findAll() {
|
|
||||||
List<OLogMessage> messages = new ArrayList<>();
|
|
||||||
|
|
||||||
JpaUtils.runWithSession(session -> {
|
private final TransactionRunner tx;
|
||||||
Query<OLogMessage> query = session.createQuery("FROM OLogMessage", OLogMessage.class);
|
|
||||||
|
|
||||||
messages.addAll(query.list());
|
public MessageRepository(SessionProvider sessions) {
|
||||||
});
|
this.tx = new TransactionRunner(sessions);
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public OLogMessage findById(String id) {
|
* Stores a logged message.
|
||||||
try (Session session = JpaUtils.openSession()) {
|
*
|
||||||
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class);
|
* @param guildId the guild id (the guild row must exist)
|
||||||
query.setParameter("messageId", id);
|
* @param channelId the channel id
|
||||||
|
* @param userId the author id
|
||||||
return query.uniqueResult();
|
* @param messageId the Discord message id
|
||||||
}
|
* @param content the raw message content
|
||||||
}
|
* @param sentAt when the message was sent
|
||||||
|
* @return the persistent log entry
|
||||||
@Override
|
*/
|
||||||
public void deleteBy(String id) {
|
public OLogMessage log(String guildId, String channelId, String userId, String messageId, String content, Instant sentAt) {
|
||||||
JpaUtils.runWithTransaction(session -> {
|
return tx.inTx(session -> {
|
||||||
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class);
|
OGuild guild = new OGuild();
|
||||||
query.setParameter("messageId", id);
|
guild.setGuildId(guildId);
|
||||||
|
|
||||||
OLogMessage oLogMessage = query.uniqueResult();
|
|
||||||
if (oLogMessage != null) {
|
|
||||||
session.remove(oLogMessage);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: move to a service
|
|
||||||
public void log(OGuild oGuild, MessageChannel channel, Member author, Message message) {
|
|
||||||
JpaUtils.runWithTransaction(session -> {
|
|
||||||
OLogMessage logMessage = new OLogMessage();
|
OLogMessage logMessage = new OLogMessage();
|
||||||
logMessage.setGuild(oGuild);
|
logMessage.setGuild(guild);
|
||||||
logMessage.setMessageId(message.getId());
|
logMessage.setChannelId(channelId);
|
||||||
logMessage.setMessage(message.getContentRaw().trim());
|
logMessage.setUserId(userId);
|
||||||
logMessage.setChannelId(channel.getId());
|
logMessage.setMessageId(messageId);
|
||||||
logMessage.setUserId(author.getId());
|
logMessage.setMessage(content == null ? null : content.trim());
|
||||||
logMessage.setDateSent(message.getTimeCreated().toInstant());
|
logMessage.setDateSent(sentAt);
|
||||||
|
return new MessageQueries_(session).insert(logMessage);
|
||||||
session.persist(logMessage);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void logEdit(String messageId, String newMessage) {
|
public Optional<OLogMessage> findByMessageId(String messageId) {
|
||||||
JpaUtils.runWithTransaction(session -> {
|
return tx.read(session -> new MessageQueries_(session).byMessageId(messageId));
|
||||||
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class);
|
}
|
||||||
query.setParameter("messageId", messageId);
|
|
||||||
|
|
||||||
OLogMessage message = query.uniqueResult();
|
public Optional<String> findMessageContent(String messageId) {
|
||||||
if (message != null) {
|
return findByMessageId(messageId).map(OLogMessage::getMessage);
|
||||||
message.setEditedMessage(newMessage);
|
}
|
||||||
|
|
||||||
session.merge(message);
|
/**
|
||||||
}
|
* Records the edited content of a logged message.
|
||||||
|
*
|
||||||
|
* @param messageId the Discord message id
|
||||||
|
* @param newMessage the edited content
|
||||||
|
* @return {@code true} if the message was found and updated
|
||||||
|
*/
|
||||||
|
public boolean updateEditedMessage(String messageId, String newMessage) {
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
MessageQueries_ queries = new MessageQueries_(session);
|
||||||
|
return queries.byMessageId(messageId)
|
||||||
|
.map(message -> {
|
||||||
|
message.setEditedMessage(newMessage);
|
||||||
|
queries.update(message);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.orElse(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OLogMessage> getMessagesFromUser(String userId, String guildId, int max) {
|
public boolean deleteByMessageId(String messageId) {
|
||||||
List<OLogMessage> messages = new ArrayList<>();
|
return tx.inTx(session -> new MessageQueries_(session).deleteByMessageId(messageId) > 0);
|
||||||
|
|
||||||
JpaUtils.runWithSession(session -> {
|
|
||||||
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.guild.guildId = :guildId AND o.userId = :userId", OLogMessage.class);
|
|
||||||
query.setParameter("guildId", guildId);
|
|
||||||
query.setParameter("userId", userId);
|
|
||||||
query.setMaxResults(max);
|
|
||||||
|
|
||||||
messages.addAll(query.list());
|
|
||||||
});
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getMessageString(String messageId) {
|
/**
|
||||||
return findById(messageId).getMessage();
|
* Returns the most recent messages of a user in a guild, newest first.
|
||||||
|
*
|
||||||
|
* @param userId the user id
|
||||||
|
* @param guildId the guild id
|
||||||
|
* @param max the maximum number of messages
|
||||||
|
* @return the messages
|
||||||
|
*/
|
||||||
|
public List<OLogMessage> findByUser(String userId, String guildId, int max) {
|
||||||
|
return tx.read(session -> new MessageQueries_(session).byUser(userId, guildId, Limit.of(max)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OLogMessage> findAll() {
|
||||||
|
return tx.read(session -> new MessageQueries_(session).findAll().toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<OLogMessage> findById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.read(session -> new MessageQueries_(session).findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates the entity.
|
||||||
|
*
|
||||||
|
* @param entity the entity to save
|
||||||
|
* @return the saved state, which callers should use from then on
|
||||||
|
*/
|
||||||
|
public OLogMessage save(OLogMessage entity) {
|
||||||
|
Objects.requireNonNull(entity, "entity");
|
||||||
|
return tx.inTx(session -> new MessageQueries_(session).save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the entity with the given id, if it exists.
|
||||||
|
*
|
||||||
|
* @param id the identifier
|
||||||
|
* @return {@code true} if a row was deleted
|
||||||
|
*/
|
||||||
|
public boolean deleteById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
MessageQueries_ queries = new MessageQueries_(session);
|
||||||
|
return queries.findById(id)
|
||||||
|
.map(entity -> {
|
||||||
|
queries.delete(entity);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.orElse(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,55 +1,97 @@
|
|||||||
package me.bobert.netneutrality.db.repository;
|
package me.bobert.netneutrality.db.repository;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
*
|
*
|
||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import me.bobert.netneutrality.db.SessionProvider;
|
||||||
import me.bobert.netneutrality.db.Repository;
|
import me.bobert.netneutrality.db.TransactionRunner;
|
||||||
|
import me.bobert.netneutrality.db.data.TaskQueries_;
|
||||||
import me.bobert.netneutrality.db.entities.OTask;
|
import me.bobert.netneutrality.db.entities.OTask;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.query.Query;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public class TaskRepository implements Repository<OTask> {
|
public class TaskRepository {
|
||||||
|
|
||||||
@Override
|
private final TransactionRunner tx;
|
||||||
public List<OTask> findAll() {
|
|
||||||
List<OTask> tasks = new ArrayList<>();
|
|
||||||
|
|
||||||
JpaUtils.runWithSession(session -> {
|
public TaskRepository(SessionProvider sessions) {
|
||||||
Query<OTask> query = session.createQuery("FROM OTask", OTask.class);
|
this.tx = new TransactionRunner(sessions);
|
||||||
|
|
||||||
tasks.addAll(query.list());
|
|
||||||
});
|
|
||||||
|
|
||||||
return tasks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public Optional<OTask> findByTaskId(String taskId) {
|
||||||
public OTask findById(String id) {
|
return tx.read(session -> new TaskQueries_(session).byTaskId(taskId));
|
||||||
try (Session session = JpaUtils.openSession()) {
|
|
||||||
Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class);
|
|
||||||
query.setParameter("taskId", id);
|
|
||||||
|
|
||||||
return query.uniqueResult();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public void deleteBy(String id) {
|
* Inserts the task or updates its payload, in a single transaction.
|
||||||
JpaUtils.runWithTransaction(session -> {
|
*
|
||||||
Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class);
|
* @param taskId the logical task id
|
||||||
query.setParameter("taskId", id);
|
* @param typeId the persistable task type id
|
||||||
|
* @param payload the serialized payload
|
||||||
OTask oTask = query.uniqueResult();
|
* @return the persistent task
|
||||||
if (oTask != null) {
|
*/
|
||||||
session.remove(oTask);
|
public OTask upsert(String taskId, String typeId, String payload) {
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
TaskQueries_ queries = new TaskQueries_(session);
|
||||||
|
Optional<OTask> existing = queries.byTaskId(taskId);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
OTask task = existing.get();
|
||||||
|
task.setPayload(payload);
|
||||||
|
return queries.update(task);
|
||||||
}
|
}
|
||||||
|
OTask task = new OTask();
|
||||||
|
task.setTaskId(taskId);
|
||||||
|
task.setTaskClass(typeId);
|
||||||
|
task.setPayload(payload);
|
||||||
|
return queries.insert(task);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean deleteByTaskId(String taskId) {
|
||||||
|
return tx.inTx(session -> new TaskQueries_(session).deleteByTaskId(taskId) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OTask> findAll() {
|
||||||
|
return tx.read(session -> new TaskQueries_(session).findAll().toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<OTask> findById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.read(session -> new TaskQueries_(session).findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates the entity.
|
||||||
|
*
|
||||||
|
* @param entity the entity to save
|
||||||
|
* @return the saved state, which callers should use from then on
|
||||||
|
*/
|
||||||
|
public OTask save(OTask entity) {
|
||||||
|
Objects.requireNonNull(entity, "entity");
|
||||||
|
return tx.inTx(session -> new TaskQueries_(session).save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the entity with the given id, if it exists.
|
||||||
|
*
|
||||||
|
* @param id the identifier
|
||||||
|
* @return {@code true} if a row was deleted
|
||||||
|
*/
|
||||||
|
public boolean deleteById(Integer id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return tx.inTx(session -> {
|
||||||
|
TaskQueries_ queries = new TaskQueries_(session);
|
||||||
|
return queries.findById(id)
|
||||||
|
.map(entity -> {
|
||||||
|
queries.delete(entity);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.orElse(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package me.bobert.netneutrality.db.service;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of a blacklisted word.
|
||||||
|
*
|
||||||
|
* @param word the word
|
||||||
|
* @param enabled whether the word is currently enforced
|
||||||
|
*/
|
||||||
|
public record BlacklistEntry(String word, boolean enabled) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package me.bobert.netneutrality.db.service;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a blacklist change made through {@link BlacklistService}.
|
||||||
|
*/
|
||||||
|
public enum BlacklistResult {
|
||||||
|
/** The word was added to the blacklist. */
|
||||||
|
ADDED,
|
||||||
|
/** The word existed but was disabled, and is now enabled again. */
|
||||||
|
REENABLED,
|
||||||
|
/** The word was already blacklisted; nothing changed. */
|
||||||
|
ALREADY_EXISTS,
|
||||||
|
/** The word was removed from the blacklist. */
|
||||||
|
REMOVED,
|
||||||
|
/** The word was not blacklisted; nothing changed. */
|
||||||
|
NOT_FOUND
|
||||||
|
}
|
||||||
@@ -6,93 +6,108 @@ package me.bobert.netneutrality.db.service;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
|
||||||
import me.bobert.netneutrality.db.entities.OBlacklist;
|
|
||||||
import me.bobert.netneutrality.db.entities.OGuild;
|
|
||||||
import me.bobert.netneutrality.db.repository.BlacklistRepository;
|
import me.bobert.netneutrality.db.repository.BlacklistRepository;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-guild blacklist, backed by the {@code blacklist} table and an in-memory cache of the enabled
|
||||||
|
* words. Persistence failures propagate to the caller.
|
||||||
|
*/
|
||||||
public class BlacklistService {
|
public class BlacklistService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(BlacklistService.class);
|
private static final Logger logger = LogManager.getLogger(BlacklistService.class);
|
||||||
private static final Map<String, Set<String>> blacklistCache = new ConcurrentHashMap<>();
|
private static final Pattern WORD_SEPARATOR = Pattern.compile("[^\\p{L}\\p{N}]+");
|
||||||
|
|
||||||
private final BlacklistRepository blacklistRepository;
|
private final BlacklistRepository blacklistRepository;
|
||||||
private final GuildService guildService;
|
private final GuildService guildService;
|
||||||
|
private volatile Set<String> enabledWords = Set.of();
|
||||||
|
|
||||||
public BlacklistService(GuildService guildService) {
|
public BlacklistService(GuildService guildService, BlacklistRepository blacklistRepository) {
|
||||||
this.blacklistRepository = new BlacklistRepository();
|
this.guildService = Objects.requireNonNull(guildService, "guildService");
|
||||||
this.guildService = guildService;
|
this.blacklistRepository = Objects.requireNonNull(blacklistRepository, "blacklistRepository");
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void init() {
|
|
||||||
reloadBlacklistCache();
|
reloadBlacklistCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void reloadBlacklistCache() {
|
public void reloadBlacklistCache() {
|
||||||
OGuild oGuild = guildService.getEntity();
|
logger.debug("Reloading blacklist cache for guild: {}", guildService.getGuildId());
|
||||||
logger.debug("Reloading blacklist cache for guild: {}", oGuild.getGuildId());
|
enabledWords = blacklistRepository.findEnabledWords(guildService.getGuildId()).stream()
|
||||||
|
.map(BlacklistService::normalize)
|
||||||
JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> {
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
Set<String> blacklistWords = oGuild.getBlacklists().stream()
|
|
||||||
.filter(OBlacklist::getEnabled)
|
|
||||||
.map(OBlacklist::getWord)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
blacklistCache.put(oGuild.getGuildId(), blacklistWords);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandExecutionResult add(Member member, String word) {
|
/**
|
||||||
try {
|
* Adds the word to the blacklist, or re-enables it.
|
||||||
blacklistRepository.add(guildService.getEntity(), member, word);
|
*
|
||||||
reloadBlacklistCache();
|
* @param creatorUserId the id of the user adding the word
|
||||||
|
* @param word the word
|
||||||
|
* @return {@link BlacklistResult#ADDED}, {@link BlacklistResult#REENABLED} or
|
||||||
|
* {@link BlacklistResult#ALREADY_EXISTS}
|
||||||
|
*/
|
||||||
|
public BlacklistResult add(String creatorUserId, String word) {
|
||||||
|
guildService.ensureEntity();
|
||||||
|
BlacklistRepository.EnableOutcome outcome =
|
||||||
|
blacklistRepository.enableWord(guildService.getGuildId(), creatorUserId, word);
|
||||||
|
reloadBlacklistCache();
|
||||||
|
|
||||||
return CommandExecutionResult.success("Added the word [%s] to the blacklist.", word);
|
return switch (outcome) {
|
||||||
} catch (Exception e) {
|
case CREATED -> BlacklistResult.ADDED;
|
||||||
return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage());
|
case ENABLED -> BlacklistResult.REENABLED;
|
||||||
}
|
case UNCHANGED -> BlacklistResult.ALREADY_EXISTS;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandExecutionResult delete(String word) {
|
/**
|
||||||
try {
|
* Removes the word from the blacklist.
|
||||||
blacklistRepository.delete(guildService.getEntity(), word);
|
*
|
||||||
reloadBlacklistCache();
|
* @param word the word
|
||||||
|
* @return {@link BlacklistResult#REMOVED} or {@link BlacklistResult#NOT_FOUND}
|
||||||
|
*/
|
||||||
|
public BlacklistResult remove(String word) {
|
||||||
|
boolean deleted = blacklistRepository.deleteWord(guildService.getGuildId(), word);
|
||||||
|
reloadBlacklistCache();
|
||||||
|
|
||||||
return CommandExecutionResult.success("Removed the word [%s] to the blacklist.", word);
|
return deleted ? BlacklistResult.REMOVED : BlacklistResult.NOT_FOUND;
|
||||||
} catch (Exception e) {
|
|
||||||
return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OBlacklist> getBlacklistForGuild() {
|
public List<BlacklistEntry> list() {
|
||||||
try {
|
return blacklistRepository.findByGuildId(guildService.getGuildId()).stream()
|
||||||
return blacklistRepository.findByGuildId(guildService.getEntity().getGuildId());
|
.map(blacklist -> new BlacklistEntry(blacklist.getWord(), Boolean.TRUE.equals(blacklist.getEnabled())))
|
||||||
} catch (Exception e) {
|
.toList();
|
||||||
return List.of();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether any word of the text is an enabled blacklisted word (case-insensitive).
|
||||||
|
*
|
||||||
|
* @param text the text to check
|
||||||
|
* @return {@code true} if the text contains a blacklisted word
|
||||||
|
*/
|
||||||
|
public boolean containsBlacklistedWord(String text) {
|
||||||
|
Set<String> words = enabledWords;
|
||||||
|
if (text == null || words.isEmpty()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (String token : WORD_SEPARATOR.split(text)) {
|
||||||
|
if (!token.isEmpty() && words.contains(normalize(token))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeGuildBlacklist() {
|
public void removeGuildBlacklist() {
|
||||||
logger.info("Clearing blacklist cache for guild: {}", guildService.getEntity().getGuildId());
|
logger.info("Clearing blacklist cache for guild: {}", guildService.getGuildId());
|
||||||
blacklistCache.remove(guildService.getEntity().getGuildId());
|
enabledWords = Set.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isWordBlacklisted(String guildId, String word) {
|
private static String normalize(String word) {
|
||||||
Set<String> blacklistWords = blacklistCache.get(guildId);
|
return word.toLowerCase(Locale.ROOT);
|
||||||
return blacklistWords != null && blacklistWords.contains(word);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Set<String> getBlacklist(String guildId) {
|
|
||||||
return blacklistCache.getOrDefault(guildId, Collections.emptySet());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,13 @@ package me.bobert.netneutrality.db.service;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.db.entities.OGuild;
|
import me.bobert.netneutrality.db.entities.OGuild;
|
||||||
|
import me.bobert.netneutrality.db.repository.BlacklistRepository;
|
||||||
import me.bobert.netneutrality.db.repository.GuildRepository;
|
import me.bobert.netneutrality.db.repository.GuildRepository;
|
||||||
import me.bobert.netneutrality.db.JpaUtils;
|
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
|
||||||
import org.slf4j.Logger;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-guild domain service (blacklist, settings, entity access).
|
* Per-guild domain service (blacklist, settings, entity access).
|
||||||
@@ -19,16 +22,24 @@ import org.slf4j.LoggerFactory;
|
|||||||
* {@link me.bobert.netneutrality.db.service.GuildServiceProvider}.
|
* {@link me.bobert.netneutrality.db.service.GuildServiceProvider}.
|
||||||
*/
|
*/
|
||||||
public class GuildService {
|
public class GuildService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(GuildService.class);
|
private static final Logger logger = LogManager.getLogger(GuildService.class);
|
||||||
private final GuildRepository guildRepository = new GuildRepository();
|
private final String guildId;
|
||||||
|
private final GuildRepository guildRepository;
|
||||||
private final BlacklistService blacklistService;
|
private final BlacklistService blacklistService;
|
||||||
private final GuildSettingService guildSettingService;
|
private final GuildSettingService guildSettingService;
|
||||||
private OGuild oGuild;
|
private volatile OGuild oGuild;
|
||||||
|
|
||||||
GuildService(String guildId) {
|
public GuildService(String guildId, GuildRepository guildRepository, BlacklistRepository blacklistRepository,
|
||||||
|
GuildSettingRepository guildSettingRepository) {
|
||||||
|
this.guildId = Objects.requireNonNull(guildId, "guildId");
|
||||||
|
this.guildRepository = Objects.requireNonNull(guildRepository, "guildRepository");
|
||||||
this.oGuild = findOrCreateGuild(guildId);
|
this.oGuild = findOrCreateGuild(guildId);
|
||||||
this.blacklistService = new BlacklistService(this);
|
this.blacklistService = new BlacklistService(this, blacklistRepository);
|
||||||
this.guildSettingService = new GuildSettingService(this);
|
this.guildSettingService = new GuildSettingService(this, guildSettingRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getGuildId() {
|
||||||
|
return guildId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public OGuild getEntity() {
|
public OGuild getEntity() {
|
||||||
@@ -43,17 +54,23 @@ public class GuildService {
|
|||||||
return guildSettingService;
|
return guildSettingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the guild row, creating it when it does not exist (for example after it was deleted
|
||||||
|
* while the service was cached).
|
||||||
|
*
|
||||||
|
* @param guildId the guild id
|
||||||
|
* @return the persistent guild
|
||||||
|
*/
|
||||||
public OGuild findOrCreateGuild(String guildId) {
|
public OGuild findOrCreateGuild(String guildId) {
|
||||||
OGuild guildEntity = guildRepository.findById(guildId);
|
logger.debug("Loading Guild: {}", guildId);
|
||||||
if (guildEntity == null) {
|
return guildRepository.findOrCreate(guildId);
|
||||||
logger.debug("Guild[{}] doesn't exist in DB, creating entity..", guildId);
|
}
|
||||||
|
|
||||||
guildEntity = new OGuild();
|
/**
|
||||||
guildEntity.setGuildId(guildId);
|
* Makes sure the guild row exists before writing rows that reference it.
|
||||||
guildRepository.save(guildEntity);
|
*/
|
||||||
}
|
void ensureEntity() {
|
||||||
|
this.oGuild = findOrCreateGuild(guildId);
|
||||||
return guildEntity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isDebugEnabled() {
|
public boolean isDebugEnabled() {
|
||||||
@@ -61,14 +78,61 @@ public class GuildService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void updateDebugMode(boolean debug) {
|
public void updateDebugMode(boolean debug) {
|
||||||
JpaUtils.updateWithTransaction(oGuild, () -> oGuild.setDebug(debug));
|
OGuild current = findOrCreateGuild(guildId);
|
||||||
|
current.setDebug(debug);
|
||||||
|
this.oGuild = guildRepository.save(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLoggingEnabled() {
|
||||||
|
return oGuild.getLogging();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateLoggingMode(boolean logging) {
|
||||||
|
OGuild current = findOrCreateGuild(guildId);
|
||||||
|
current.setLogging(logging);
|
||||||
|
this.oGuild = guildRepository.save(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLogChannel() {
|
||||||
|
return oGuild.getLogChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets (or clears, when {@code logChannel} is {@code null}/blank) the guild's log channel id.
|
||||||
|
*
|
||||||
|
* @param logChannel the Discord channel id to log to, or {@code null}/blank to clear it
|
||||||
|
*/
|
||||||
|
public void updateLogChannel(String logChannel) {
|
||||||
|
OGuild current = findOrCreateGuild(guildId);
|
||||||
|
current.setLogChannel(logChannel == null || logChannel.isBlank() ? null : logChannel);
|
||||||
|
this.oGuild = guildRepository.save(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the guild's name/icon into the database so they survive JDA cache eviction. Only
|
||||||
|
* writes when a value actually changed, to avoid needless updates on every event.
|
||||||
|
*
|
||||||
|
* @param name the current guild name
|
||||||
|
* @param iconHash the current guild icon hash, or {@code null} if the guild has no icon
|
||||||
|
*/
|
||||||
|
public void updateMetadata(String name, String iconHash) {
|
||||||
|
OGuild current = findOrCreateGuild(guildId);
|
||||||
|
|
||||||
|
if (Objects.equals(current.getGuildName(), name) && Objects.equals(current.getGuildIconHash(), iconHash)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.setGuildName(name);
|
||||||
|
current.setGuildIconHash(iconHash);
|
||||||
|
this.oGuild = guildRepository.save(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteGuild() {
|
public void deleteGuild() {
|
||||||
guildRepository.deleteBy(oGuild.getGuildId());
|
guildRepository.deleteById(guildId);
|
||||||
|
blacklistService.removeGuildBlacklist();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void reloadEntity() {
|
public void reloadEntity() {
|
||||||
this.oGuild = findOrCreateGuild(oGuild.getGuildId());
|
ensureEntity();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ package me.bobert.netneutrality.db.service;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,17 +22,16 @@ public interface GuildServiceProvider {
|
|||||||
GuildService get(String guildId);
|
GuildService get(String guildId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns (creating on first use) the service for the given guild.
|
* Drops the cached service for the guild; the next {@link #get(String)} builds a new one.
|
||||||
*
|
*
|
||||||
* @param guild the Discord guild
|
* @param guildId the Discord guild id
|
||||||
* @return the guild service
|
|
||||||
*/
|
*/
|
||||||
GuildService get(Guild guild);
|
void evict(String guildId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the live cache of guild services.
|
* Returns a read-only snapshot of the cached guild services.
|
||||||
*
|
*
|
||||||
* @return the services keyed by guild id
|
* @return the services keyed by guild id
|
||||||
*/
|
*/
|
||||||
Map<String, GuildService> getServices();
|
Map<String, GuildService> snapshot();
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,12 @@ package me.bobert.netneutrality.db.service;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import me.bobert.netneutrality.db.repository.BlacklistRepository;
|
||||||
|
import me.bobert.netneutrality.db.repository.GuildRepository;
|
||||||
|
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,22 +20,30 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
public class GuildServiceProviderImpl implements GuildServiceProvider {
|
public class GuildServiceProviderImpl implements GuildServiceProvider {
|
||||||
|
|
||||||
private final Map<String, GuildService> services = new ConcurrentHashMap<>();
|
private final Map<String, GuildService> services = new ConcurrentHashMap<>();
|
||||||
|
private final GuildRepository guildRepository;
|
||||||
|
private final BlacklistRepository blacklistRepository;
|
||||||
|
private final GuildSettingRepository guildSettingRepository;
|
||||||
|
|
||||||
public GuildServiceProviderImpl() {
|
public GuildServiceProviderImpl(GuildRepository guildRepository, BlacklistRepository blacklistRepository,
|
||||||
|
GuildSettingRepository guildSettingRepository) {
|
||||||
|
this.guildRepository = Objects.requireNonNull(guildRepository, "guildRepository");
|
||||||
|
this.blacklistRepository = Objects.requireNonNull(blacklistRepository, "blacklistRepository");
|
||||||
|
this.guildSettingRepository = Objects.requireNonNull(guildSettingRepository, "guildSettingRepository");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public GuildService get(String guildId) {
|
public GuildService get(String guildId) {
|
||||||
return services.computeIfAbsent(guildId, GuildService::new);
|
return services.computeIfAbsent(guildId,
|
||||||
|
id -> new GuildService(id, guildRepository, blacklistRepository, guildSettingRepository));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public GuildService get(Guild guild) {
|
public void evict(String guildId) {
|
||||||
return get(guild.getId());
|
services.remove(guildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, GuildService> getServices() {
|
public Map<String, GuildService> snapshot() {
|
||||||
return services;
|
return Map.copyOf(services);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,52 +9,73 @@ package me.bobert.netneutrality.db.service;
|
|||||||
import me.bobert.netneutrality.db.GSettings;
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
import me.bobert.netneutrality.db.entities.OGuildSetting;
|
import me.bobert.netneutrality.db.entities.OGuildSetting;
|
||||||
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
|
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
|
||||||
import org.slf4j.Logger;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-guild settings, backed by the {@code guild_settings} table. Only non-default values are
|
||||||
|
* stored; reads come from an immutable snapshot that is swapped atomically on reload.
|
||||||
|
*/
|
||||||
public class GuildSettingService {
|
public class GuildSettingService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(GuildSettingService.class);
|
private static final Logger logger = LogManager.getLogger(GuildSettingService.class);
|
||||||
private final GuildSettingRepository guildSettingRepository = new GuildSettingRepository();
|
private final GuildSettingRepository guildSettingRepository;
|
||||||
private final GuildService guildService;
|
private final GuildService guildService;
|
||||||
private final Map<String, String> settingsCache = new ConcurrentHashMap<>();
|
private volatile Map<String, String> settingsCache = Map.of();
|
||||||
|
|
||||||
public GuildSettingService(GuildService guildService) {
|
public GuildSettingService(GuildService guildService, GuildSettingRepository guildSettingRepository) {
|
||||||
this.guildService = guildService;
|
this.guildService = Objects.requireNonNull(guildService, "guildService");
|
||||||
|
this.guildSettingRepository = Objects.requireNonNull(guildSettingRepository, "guildSettingRepository");
|
||||||
reloadSettingsCache();
|
reloadSettingsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> getSettings() {
|
/**
|
||||||
|
* @return an unmodifiable snapshot of the stored (non-default) settings, keyed by setting name
|
||||||
|
*/
|
||||||
|
public Map<String, String> snapshot() {
|
||||||
return settingsCache;
|
return settingsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getSetting(GSettings setting) {
|
/**
|
||||||
String value = settingsCache.get(setting.getName());
|
* @param key the setting
|
||||||
|
* @return the stored value, or empty when the setting uses its default
|
||||||
if (value == null) {
|
*/
|
||||||
value = setting.getDefaultValue();
|
public Optional<String> get(GSettings key) {
|
||||||
}
|
return Optional.ofNullable(settingsCache.get(key.getName()));
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSettingValue(GSettings setting, String value) {
|
/**
|
||||||
guildSettingRepository.setSettingValue(guildService.getEntity(), setting, value);
|
* @param key the setting
|
||||||
|
* @return the stored value, or the setting's default
|
||||||
|
*/
|
||||||
|
public String getOrDefault(GSettings key) {
|
||||||
|
return get(key).orElse(key.getDefaultValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the value; {@code null} or the default value deletes the stored setting.
|
||||||
|
*
|
||||||
|
* @param key the setting
|
||||||
|
* @param value the new value, or {@code null} to reset it
|
||||||
|
*/
|
||||||
|
public void set(GSettings key, String value) {
|
||||||
|
guildService.ensureEntity();
|
||||||
|
guildSettingRepository.upsertOrDelete(guildService.getGuildId(), key, value);
|
||||||
|
|
||||||
reloadSettingsCache();
|
reloadSettingsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void reloadSettingsCache() {
|
public void reloadSettingsCache() {
|
||||||
logger.debug("Reloading settings cache for guild: {}", guildService.getEntity().getGuildId());
|
logger.debug("Reloading settings cache for guild: {}", guildService.getGuildId());
|
||||||
settingsCache.clear();
|
|
||||||
|
|
||||||
|
Map<String, String> fresh = new HashMap<>();
|
||||||
List<OGuildSetting> settings = guildSettingRepository.findByGuild(guildService.getEntity().getGuildId());
|
for (OGuildSetting setting : guildSettingRepository.findByGuild(guildService.getGuildId())) {
|
||||||
for (OGuildSetting setting : settings) {
|
fresh.put(setting.getName(), setting.getValue());
|
||||||
settingsCache.put(setting.getName(), setting.getValue());
|
|
||||||
}
|
}
|
||||||
|
settingsCache = Map.copyOf(fresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,14 +5,15 @@ package me.bobert.netneutrality.db.service;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import me.bobert.netneutrality.core.tasks.PersistableTask;
|
import me.bobert.netneutrality.core.tasks.PersistableTask;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
import me.bobert.netneutrality.core.tasks.persistence.TaskRegistry;
|
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
|
||||||
import me.bobert.netneutrality.db.entities.OTask;
|
import me.bobert.netneutrality.db.entities.OTask;
|
||||||
import me.bobert.netneutrality.db.repository.TaskRepository;
|
import me.bobert.netneutrality.db.repository.TaskRepository;
|
||||||
|
import me.bobert.netneutrality.utils.Json;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -21,38 +22,29 @@ public class TaskService {
|
|||||||
private final TaskManager taskManager;
|
private final TaskManager taskManager;
|
||||||
private static final Logger logger = LogManager.getLogger(TaskService.class);
|
private static final Logger logger = LogManager.getLogger(TaskService.class);
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
private final PersistableTaskRegistry registry;
|
||||||
|
|
||||||
public TaskService(TaskManager taskManager) {
|
public TaskService(TaskManager taskManager, PersistableTaskRegistry registry, TaskRepository taskRepository) {
|
||||||
this.taskManager = taskManager;
|
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
|
||||||
this.taskRepository = new TaskRepository();
|
this.registry = Objects.requireNonNull(registry, "registry");
|
||||||
|
this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void loadTasks() {
|
public void loadTasks() {
|
||||||
TaskRegistry.loadTaskConfig();
|
|
||||||
logger.info("Loading persistable tasks...");
|
logger.info("Loading persistable tasks...");
|
||||||
|
|
||||||
List<OTask> tasks = taskRepository.findAll();
|
List<OTask> tasks = taskRepository.findAll();
|
||||||
|
|
||||||
for (OTask oTask : tasks) {
|
for (OTask oTask : tasks) {
|
||||||
String taskId = oTask.getTaskId();
|
String taskId = oTask.getTaskId();
|
||||||
String taskClass = oTask.getTaskClass();
|
String typeId = oTask.getTaskClass();
|
||||||
logger.debug("Trying to load task with ID: {} and class: {}", taskId, taskClass);
|
logger.debug("Trying to load task with ID: {} and type: {}", taskId, typeId);
|
||||||
|
|
||||||
String payload = oTask.getPayload();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
TaskRegistry.TaskCreator creator = TaskRegistry.getTaskCreator(taskClass);
|
ObjectNode json = Json.readObject(oTask.getPayload());
|
||||||
if (creator == null) {
|
PersistableTask task = registry.create(typeId, json);
|
||||||
throw new IllegalArgumentException("No task creator registered for class: " + taskClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
JSONObject json = new JSONObject(payload);
|
validateTask(task, taskId, json);
|
||||||
PersistableTask task = creator.fromPayload(json);
|
|
||||||
if (task == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
validateTask(task, oTask);
|
|
||||||
taskManager.insertPersistableTask(task);
|
taskManager.insertPersistableTask(task);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Error loading task with ID {}: {}", taskId, e.getMessage());
|
logger.error("Error loading task with ID {}: {}", taskId, e.getMessage());
|
||||||
@@ -60,51 +52,35 @@ public class TaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateTask(PersistableTask task, OTask oTask) throws IllegalArgumentException {
|
private static void validateTask(PersistableTask task, String taskId, ObjectNode json) throws IllegalArgumentException {
|
||||||
String taskId = oTask.getTaskId();
|
|
||||||
String payload = oTask.getPayload();
|
|
||||||
|
|
||||||
if (!Objects.equals(taskId, task.getId())) {
|
if (!Objects.equals(taskId, task.getId())) {
|
||||||
throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId());
|
throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
JSONObject json = new JSONObject(payload);
|
String payloadId = json.path("id").asText(null);
|
||||||
String payloadId = json.getString("id");
|
|
||||||
if (!Objects.equals(payloadId, task.getId())) {
|
if (!Objects.equals(payloadId, task.getId())) {
|
||||||
throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId());
|
throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
String payloadLockId = json.getString("lockId");
|
String payloadLockId = json.path("lockId").asText(null);
|
||||||
if (!Objects.equals(payloadLockId, task.getLockId())) {
|
if (!Objects.equals(payloadLockId, task.getLockId())) {
|
||||||
throw new IllegalArgumentException("Task lock ID mismatch, expected: " + task.getLockId());
|
throw new IllegalArgumentException("Task lock ID mismatch, expected: " + task.getLockId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void persistTask(PersistableTask task) {
|
public void persistTask(PersistableTask task) {
|
||||||
String taskClass = task.getClass().getName();
|
String typeId = task.typeId();
|
||||||
if (TaskRegistry.getTaskCreator(taskClass) == null) {
|
if (!registry.isRegistered(typeId)) {
|
||||||
logger.error("No TaskCreator found for class: {}", taskClass);
|
logger.error("No persistable task type registered for id: {}", typeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
TaskRepository taskRepository = new TaskRepository();
|
logger.info("Persisting task: {}", task.getId());
|
||||||
OTask oTask = taskRepository.findById(task.getId());
|
taskRepository.upsert(task.getId(), typeId, task.toPayload());
|
||||||
if (oTask == null) {
|
|
||||||
oTask = new OTask();
|
|
||||||
oTask.setTaskId(task.getId());
|
|
||||||
oTask.setTaskClass(task.getClass().getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
oTask.setPayload(task.toPayload());
|
|
||||||
|
|
||||||
logger.info("Persisting task: {}", oTask.getTaskId());
|
|
||||||
taskRepository.save(oTask);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void unpersistTask(PersistableTask task) {
|
public void unpersistTask(PersistableTask task) {
|
||||||
logger.debug("Unpersisting task: {}", task.getId());
|
logger.debug("Unpersisting task: {}", task.getId());
|
||||||
TaskRepository taskRepository = new TaskRepository();
|
taskRepository.deleteByTaskId(task.getId());
|
||||||
|
|
||||||
taskRepository.deleteBy(task.getId());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,12 +23,15 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
|||||||
import me.bobert.netneutrality.db.service.GuildService;
|
import me.bobert.netneutrality.db.service.GuildService;
|
||||||
import me.bobert.netneutrality.core.command.CommandRegistry;
|
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
import net.dv8tion.jda.api.events.Event;
|
import net.dv8tion.jda.api.events.Event;
|
||||||
import net.dv8tion.jda.api.events.GenericEvent;
|
import net.dv8tion.jda.api.events.GenericEvent;
|
||||||
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
|
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
|
||||||
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
|
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
|
||||||
|
import net.dv8tion.jda.api.events.guild.update.GuildUpdateIconEvent;
|
||||||
|
import net.dv8tion.jda.api.events.guild.update.GuildUpdateNameEvent;
|
||||||
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
|
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
|
||||||
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
|
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
|
||||||
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
|
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
|
||||||
@@ -81,6 +84,12 @@ public class EventsHandler implements EventListener {
|
|||||||
Message message = event.getMessage();
|
Message message = event.getMessage();
|
||||||
String rawMessage = message.getContentDisplay().trim();
|
String rawMessage = message.getContentDisplay().trim();
|
||||||
String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
|
String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
|
||||||
|
if (event.isFromGuild()) {
|
||||||
|
String guildPrefix = guildServices.get(event.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.COMMAND_PREFIX);
|
||||||
|
if (!guildPrefix.isEmpty()) {
|
||||||
|
prefix = guildPrefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (rawMessage.startsWith(prefix)) {
|
if (rawMessage.startsWith(prefix)) {
|
||||||
return new MessageCommandContext(event, settings);
|
return new MessageCommandContext(event, settings);
|
||||||
}
|
}
|
||||||
@@ -95,13 +104,34 @@ public class EventsHandler implements EventListener {
|
|||||||
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
|
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
|
||||||
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
|
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
|
||||||
addGenericListener(GuildJoinEvent.class, event -> {
|
addGenericListener(GuildJoinEvent.class, event -> {
|
||||||
GuildService service = guildServices.get(event.getGuild());
|
GuildService service = guildServices.get(event.getGuild().getId());
|
||||||
|
service.updateMetadata(event.getGuild().getName(), event.getGuild().getIconId());
|
||||||
|
|
||||||
if (service.isDebugEnabled()) {
|
if (service.isDebugEnabled()) {
|
||||||
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
|
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
addGenericListener(GuildLeaveEvent.class, event -> guildServices.get(event.getGuild()).deleteGuild());
|
addGenericListener(GuildLeaveEvent.class, event -> onGuildLeave(event.getGuild().getId()));
|
||||||
|
addGenericListener(GuildUpdateNameEvent.class, event ->
|
||||||
|
guildServices.get(event.getGuild().getId()).updateMetadata(event.getNewName(), event.getGuild().getIconId()));
|
||||||
|
addGenericListener(GuildUpdateIconEvent.class, event ->
|
||||||
|
guildServices.get(event.getGuild().getId()).updateMetadata(event.getGuild().getName(), event.getNewIconId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the guild's data and drops its cached service. The service is evicted even when the
|
||||||
|
* delete fails, so a stale cache entry never outlives the guild membership.
|
||||||
|
*
|
||||||
|
* @param guildId the id of the guild the bot left
|
||||||
|
*/
|
||||||
|
void onGuildLeave(String guildId) {
|
||||||
|
try {
|
||||||
|
guildServices.get(guildId).deleteGuild();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Failed to delete data of guild {}", guildId, e);
|
||||||
|
} finally {
|
||||||
|
guildServices.evict(guildId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ package me.bobert.netneutrality.http;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import io.sentry.Breadcrumb;
|
import io.sentry.Breadcrumb;
|
||||||
import io.sentry.Sentry;
|
import io.sentry.Sentry;
|
||||||
import me.bobert.netneutrality.http.ResponseCache.CachedResponse;
|
import me.bobert.netneutrality.http.ResponseCache.CachedResponse;
|
||||||
|
import me.bobert.netneutrality.utils.Json;
|
||||||
import okhttp3.MediaType;
|
import okhttp3.MediaType;
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
import okhttp3.RequestBody;
|
import okhttp3.RequestBody;
|
||||||
import okhttp3.Response;
|
import okhttp3.Response;
|
||||||
import org.json.JSONArray;
|
|
||||||
import org.json.JSONObject;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -130,15 +130,15 @@ public class WebModule {
|
|||||||
return keyBuilder.toString();
|
return keyBuilder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public JSONObject getAsObject() {
|
public ObjectNode getAsObject() {
|
||||||
return cachedResponse.getBody()
|
return cachedResponse.getBody()
|
||||||
.map(JSONObject::new)
|
.map(Json::readObject)
|
||||||
.orElseThrow(() -> new IllegalStateException("No body available for this response"));
|
.orElseThrow(() -> new IllegalStateException("No body available for this response"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public JSONArray getAsArray() {
|
public ArrayNode getAsArray() {
|
||||||
return cachedResponse.getBody()
|
return cachedResponse.getBody()
|
||||||
.map(JSONArray::new)
|
.map(Json::readArray)
|
||||||
.orElseThrow(() -> new IllegalStateException("No body available for this response"));
|
.orElseThrow(() -> new IllegalStateException("No body available for this response"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ public class WebModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method == Method.POST && !params.isEmpty()) {
|
if (method == Method.POST && !params.isEmpty()) {
|
||||||
RequestBody body = RequestBody.create(new Gson().toJson(params), MediaType.parse("application/json; charset=utf-8"));
|
RequestBody body = RequestBody.create(Json.write(params), MediaType.parse("application/json; charset=utf-8"));
|
||||||
builder.post(body);
|
builder.post(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ package me.bobert.netneutrality.http.server;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import io.javalin.Javalin;
|
import io.javalin.config.RoutesConfig;
|
||||||
|
import io.javalin.http.Context;
|
||||||
import io.javalin.http.Handler;
|
import io.javalin.http.Handler;
|
||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
import io.javalin.openapi.HttpMethod;
|
||||||
|
import io.javalin.openapi.OpenApi;
|
||||||
|
import io.javalin.openapi.OpenApiContent;
|
||||||
|
import io.javalin.openapi.OpenApiResponse;
|
||||||
|
import me.bobert.netneutrality.http.server.dto.StatusResponse;
|
||||||
import net.dv8tion.jda.api.JDA;
|
import net.dv8tion.jda.api.JDA;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
@@ -18,7 +22,7 @@ import java.util.Map;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the {@code /actions/*} endpoints on a {@link Javalin} instance.
|
* Registers the {@code /actions/*} endpoints on a {@link RoutesConfig} instance.
|
||||||
*/
|
*/
|
||||||
public final class ActionRoutes {
|
public final class ActionRoutes {
|
||||||
|
|
||||||
@@ -29,40 +33,42 @@ public final class ActionRoutes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void register(
|
public static void register(
|
||||||
Javalin app,
|
RoutesConfig routes,
|
||||||
Supplier<JDA> jdaSupplier,
|
Supplier<JDA> jdaSupplier,
|
||||||
GuildServiceProvider guildServices,
|
|
||||||
Runnable shutdownHook
|
Runnable shutdownHook
|
||||||
) {
|
) {
|
||||||
app.get("/actions/status", ctx -> {
|
routes.get("/actions/status", ctx -> status(jdaSupplier, ctx));
|
||||||
JDA jda = GuildRoutes.jda(jdaSupplier);
|
|
||||||
ctx.json(Map.of(
|
|
||||||
"health", String.valueOf(jda.getStatus()),
|
|
||||||
"ping", String.valueOf(jda.getGatewayPing())
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
Handler shutdown = ctx -> {
|
Handler shutdown = ctx -> shutdown(ctx, shutdownHook);
|
||||||
logger.info("Request to shutdown received");
|
routes.post("/actions/shutdown", shutdown);
|
||||||
ctx.result("Shutting down..");
|
}
|
||||||
scheduleShutdown(shutdownHook);
|
|
||||||
};
|
|
||||||
app.post("/actions/shutdown", shutdown);
|
|
||||||
app.get("/actions/shutdown", shutdown);
|
|
||||||
|
|
||||||
app.get("/actions/settings/current/{guildId}", ctx -> {
|
@OpenApi(
|
||||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
path = "/actions/status",
|
||||||
Map<String, String> settings = guildServices.get(guild).getGuildSettingService().getSettings();
|
methods = HttpMethod.GET,
|
||||||
|
summary = "Report the current Discord gateway health",
|
||||||
|
tags = {"Actions"},
|
||||||
|
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = StatusResponse.class))
|
||||||
|
)
|
||||||
|
private static void status(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||||
|
JDA jda = GuildRoutes.jda(jdaSupplier);
|
||||||
|
ctx.json(Map.of(
|
||||||
|
"health", String.valueOf(jda.getStatus()),
|
||||||
|
"ping", String.valueOf(jda.getGatewayPing())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
ctx.json(Map.of("status", "ok", "settings", settings));
|
@OpenApi(
|
||||||
});
|
path = "/actions/shutdown",
|
||||||
|
methods = HttpMethod.POST,
|
||||||
app.post("/actions/settings/reload/{guildId}", ctx -> {
|
summary = "Gracefully shut the bot down",
|
||||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
tags = {"Actions"},
|
||||||
guildServices.get(guild).getGuildSettingService().reloadSettingsCache();
|
responses = @OpenApiResponse(status = "200")
|
||||||
|
)
|
||||||
ctx.json(Map.of("status", "true"));
|
private static void shutdown(Context ctx, Runnable shutdownHook) {
|
||||||
});
|
logger.info("Request to shutdown received");
|
||||||
|
ctx.result("Shutting down..");
|
||||||
|
scheduleShutdown(shutdownHook);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +82,6 @@ public final class ActionRoutes {
|
|||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
shutdownHook.run();
|
shutdownHook.run();
|
||||||
System.exit(0);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package me.bobert.netneutrality.http.server;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import io.javalin.http.Context;
|
||||||
|
import io.javalin.http.Handler;
|
||||||
|
import io.javalin.http.UnauthorizedResponse;
|
||||||
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the {@code X-API-Key} header against the configured {@code NETNEUTRALITY_API_KEY} value.
|
||||||
|
*
|
||||||
|
* <p>Registered as a {@code before} filter on every route; public paths (liveness check, Swagger UI,
|
||||||
|
* OpenAPI JSON, and the Swagger UI's static webjar assets) are skipped so they stay reachable without a key.
|
||||||
|
*/
|
||||||
|
public final class ApiKeyFilter implements Handler {
|
||||||
|
|
||||||
|
private static final String HEADER = "X-API-Key";
|
||||||
|
|
||||||
|
private static final List<String> PUBLIC_PREFIXES = List.of("/openapi", "/swagger", "/webjars");
|
||||||
|
private static final List<String> PUBLIC_PATHS = List.of("/");
|
||||||
|
|
||||||
|
private final SettingsProvider settings;
|
||||||
|
|
||||||
|
public ApiKeyFilter(SettingsProvider settings) {
|
||||||
|
this.settings = Objects.requireNonNull(settings, "settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(Context ctx) {
|
||||||
|
if (isPublic(ctx.path())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String expected = settings.get("NETNEUTRALITY_API_KEY");
|
||||||
|
String provided = ctx.header(HEADER);
|
||||||
|
|
||||||
|
if (expected == null || expected.isEmpty() || !expected.equals(provided)) {
|
||||||
|
throw new UnauthorizedResponse("Missing or invalid API key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPublic(String path) {
|
||||||
|
if (PUBLIC_PATHS.contains(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PUBLIC_PREFIXES.stream().anyMatch(path::startsWith);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package me.bobert.netneutrality.http.server;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import io.javalin.http.Context;
|
||||||
|
import net.dv8tion.jda.api.requests.RestAction;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a JDA entity from the in-memory cache when possible, falling back to a REST call
|
||||||
|
* (resolved asynchronously through Javalin's {@link Context#future}) otherwise.
|
||||||
|
*
|
||||||
|
* <p>JDA only keeps a short-lived, best-effort cache; this helper compensates for cache misses
|
||||||
|
* (evicted entries, not-yet-warmed guilds, etc.) without widening {@code MemberCachePolicy} or
|
||||||
|
* {@code CacheFlag}s.
|
||||||
|
*/
|
||||||
|
public final class CacheOrFetch {
|
||||||
|
|
||||||
|
private CacheOrFetch() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a value, returning the cached one synchronously if present, otherwise falling back
|
||||||
|
* to the given REST action and responding once it completes.
|
||||||
|
*
|
||||||
|
* @param ctx the request context
|
||||||
|
* @param cached supplies the cached value, or {@code null} on a cache miss
|
||||||
|
* @param restAction supplies the REST fallback when the cache misses
|
||||||
|
* @param onResolved called with the resolved value (from cache or REST) to build the response
|
||||||
|
* @param <T> the resolved entity type
|
||||||
|
*/
|
||||||
|
public static <T> void resolve(Context ctx, Supplier<T> cached, Supplier<RestAction<T>> restAction, Function<T, Object> onResolved) {
|
||||||
|
T value = cached.get();
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
ctx.json(onResolved.apply(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.future(() -> restAction.get().submit().thenApply(resolved -> {
|
||||||
|
ctx.json(onResolved.apply(resolved));
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user