Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
363e02f409 | ||
|
|
d43e032c37 | ||
|
|
d97c4c6da2 | ||
|
|
764adb6cbc | ||
|
|
e989bff9e3 | ||
|
|
0863e0723b | ||
|
|
142cc77ec8 | ||
|
|
d376e245b7 | ||
|
|
6e99fe5477 | ||
|
|
dba7a84819 | ||
|
|
87ba09b001 | ||
|
|
75b495b08c | ||
|
|
b0664e8835 | ||
|
|
7443cbfb4b | ||
|
|
f96c2677b6 | ||
|
|
8e4e77cd31 | ||
|
|
10462a08a4 | ||
|
|
389af563b9 | ||
|
|
d3211c6702 | ||
|
|
f6ba30639a | ||
|
|
780016b731 | ||
|
|
01bf94c60a | ||
|
|
dad2e0356e | ||
|
|
d0ea0535e2 | ||
|
|
78d6ade17a | ||
|
|
cb47a1622f | ||
|
|
545c09d502 | ||
|
|
6134c893ab | ||
|
|
91b6de708d | ||
|
|
bedc8b9f02 | ||
|
|
abdd2d1656 | ||
|
|
a23e3843ed | ||
|
|
1f126f29ad | ||
|
|
e0ec43813d | ||
|
|
861b3f90e7 | ||
|
|
b7d19b6d56 | ||
|
|
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 | ||
|
|
4cf7c47645 | ||
|
|
01bfe22d22 | ||
|
|
5f6265db01 |
No files matched your search
+1
-8
@@ -1,15 +1,8 @@
|
|||||||
.git
|
.git
|
||||||
.github
|
.github
|
||||||
.idea
|
.idea
|
||||||
.mvn
|
|
||||||
src
|
|
||||||
classes
|
classes
|
||||||
logs
|
logs
|
||||||
target/*
|
target
|
||||||
!target/netneutrality.jar
|
|
||||||
*.iml
|
*.iml
|
||||||
README.md
|
README.md
|
||||||
checkstyle.xml
|
|
||||||
mvnw
|
|
||||||
mvnw.cmd
|
|
||||||
pom.xml
|
|
||||||
+14
-7
@@ -1,26 +1,33 @@
|
|||||||
NETNEUTRALITY_TAG=latest
|
NETNEUTRALITY_TAG=latest
|
||||||
|
|
||||||
NETNEUTRALITY_BOT_ENV=dev
|
NETNEUTRALITY_BOT_ENV=dev
|
||||||
NETNEUTRALITY_BOT_TOKEN=
|
NETNEUTRALITY_BOT_TOKEN=
|
||||||
NETNEUTRALITY_BOT_PREFIX=$
|
NETNEUTRALITY_BOT_PREFIX=$
|
||||||
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
|
||||||
MYSQL_USER=java
|
MYSQL_USER=java
|
||||||
MYSQL_PASSWORD=password
|
MYSQL_PASSWORD=password
|
||||||
MYSQL_DATABASE=netneutrality
|
MYSQL_DATABASE=netneutrality
|
||||||
MYSQL_ROOT_PASSWORD=password
|
|
||||||
MARIADB_AUTO_UPGRADE=1
|
|
||||||
|
|
||||||
|
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
|
||||||
|
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
|
||||||
|
NETNEUTRALITY_LAVALINK_NAME=dev-player
|
||||||
NETNEUTRALITY_PO_TOKEN=
|
NETNEUTRALITY_PO_TOKEN=
|
||||||
NETNEUTRALITY_VISITOR_DATA=
|
NETNEUTRALITY_VISITOR_DATA=
|
||||||
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
NETNEUTRALITY_OAUTH_TOKEN=
|
||||||
|
NETNEUTRALITY_CIPHER_URL=http://ejs-api:8001
|
||||||
NETNEUTRALITY_CIPHER_PASSWORD=
|
NETNEUTRALITY_CIPHER_PASSWORD=
|
||||||
|
NETNEUTRALITY_WEBPO_URL=http://webpo:8080
|
||||||
|
NETNEUTRALITY_WEBPO_PASSWORD=
|
||||||
|
|
||||||
NETNEUTRALITY_SENTRY_DSN=
|
NETNEUTRALITY_SENTRY_DSN=
|
||||||
GITLAB_HOST=
|
NETNEUTRALITY_WEBHOOK_URL=
|
||||||
GITLAB_TOKEN=
|
NETNEUTRALITY_WEBHOOK_SECRET=
|
||||||
GITLAB_PROJECT_ID=
|
GITEA_HOST=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_OWNER=
|
||||||
|
GITEA_REPO=
|
||||||
@@ -50,6 +50,7 @@ jobs:
|
|||||||
name: maven-artifacts
|
name: maven-artifacts
|
||||||
path: |
|
path: |
|
||||||
target/netneutrality.jar
|
target/netneutrality.jar
|
||||||
|
target/lib
|
||||||
startup.sh
|
startup.sh
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
|||||||
+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,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.
|
|
||||||
+13
-1
@@ -1,3 +1,14 @@
|
|||||||
|
FROM eclipse-temurin:25-jdk AS build
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY .mvn .mvn
|
||||||
|
COPY mvnw pom.xml checkstyle.xml ./
|
||||||
|
RUN chmod +x mvnw && ./mvnw -B -q dependency:go-offline
|
||||||
|
|
||||||
|
COPY src src
|
||||||
|
RUN ./mvnw -B -q package -DskipTests
|
||||||
|
|
||||||
FROM eclipse-temurin:25-jre@sha256:ff82c74a630c08b417502c4c18189e62074edbb28eb1f2d31b799ac138c01058
|
FROM eclipse-temurin:25-jre@sha256:ff82c74a630c08b417502c4c18189e62074edbb28eb1f2d31b799ac138c01058
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd curl && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -8,7 +19,8 @@ ARG API_PORT=9000
|
|||||||
EXPOSE ${API_PORT}
|
EXPOSE ${API_PORT}
|
||||||
|
|
||||||
COPY startup.sh ./
|
COPY startup.sh ./
|
||||||
COPY target/netneutrality.jar ./
|
COPY --from=build /build/target/lib ./lib
|
||||||
|
COPY --from=build /build/target/netneutrality.jar ./
|
||||||
|
|
||||||
RUN ["chmod", "+x", "./startup.sh"]
|
RUN ["chmod", "+x", "./startup.sh"]
|
||||||
CMD ["./startup.sh"]
|
CMD ["./startup.sh"]
|
||||||
@@ -1,15 +1,100 @@
|
|||||||
# Net Neutrality Discord bot
|
# Net Neutrality Discord Bot
|
||||||
|
|
||||||
This is the source code for my custom discord bot.
|
A custom Discord bot written in Java. It uses [JDA](https://github.com/discord-jda/JDA) for Discord, a [Lavalink](https://github.com/lavalink-devs/Lavalink) node for music playback, MariaDB/MySQL for persistence, and exposes an HTTP API built with Javalin.
|
||||||
This is a personal project, so I offer no support on how to build it.
|
|
||||||
|
|
||||||
|
This is a personal project. No support is offered for building or running it.
|
||||||
|
|
||||||
The preferred method for running the bot is by using Docker Compose like this:
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Running with Docker Compose](#running-with-docker-compose)
|
||||||
|
- [Running Locally](#running-locally)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker and Docker Compose (recommended), or
|
||||||
|
- JDK 25 and Maven (the included `mvnw` wrapper works) for local runs
|
||||||
|
- A MariaDB/MySQL database
|
||||||
|
- A Lavalink node, version 4.2.0 or newer
|
||||||
|
- A Discord bot token
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings are provided as environment variables. A template with every supported variable is available in [`bot.properties.example`](bot.properties.example).
|
||||||
|
|
||||||
|
Key variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token |
|
||||||
|
| `NETNEUTRALITY_BOT_PREFIX` | Command prefix |
|
||||||
|
| `NETNEUTRALITY_BOT_OWNER` | Discord user ID of the bot owner |
|
||||||
|
| `NETNEUTRALITY_API_PORT` | Port for the HTTP API (default `9000`) |
|
||||||
|
| `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` | Database connection settings |
|
||||||
|
| `NETNEUTRALITY_LAVALINK_URI`, `NETNEUTRALITY_LAVALINK_PASSWORD` | Lavalink connection settings |
|
||||||
|
| `NETNEUTRALITY_SENTRY_DSN` | Optional Sentry DSN for error reporting |
|
||||||
|
| `GITEA_HOST`, `GITEA_TOKEN`, `GITEA_OWNER`, `GITEA_REPO` | Optional Gitea settings for issue reporting |
|
||||||
|
| `NETNEUTRALITY_WEBHOOK_URL`, `NETNEUTRALITY_WEBHOOK_SECRET` | Optional frontend webhook URL and HMAC secret (see below) |
|
||||||
|
|
||||||
|
### Webhook events
|
||||||
|
|
||||||
|
When `NETNEUTRALITY_WEBHOOK_URL` is set, the bot POSTs JSON music-state events (`TRACK_START`, `TRACK_END`, `QUEUE_UPDATED`, `PLAYER_PAUSED`, `PLAYER_RESUMED`, `VOLUME_CHANGED`, `VOICE_CONNECTED`, `VOICE_DISCONNECTED`) to it asynchronously. Each event has `type`, `guildId`, `timestamp`, a per-guild `seq` and a `payload`. Requests carry an `X-Signature: sha256=<hex HMAC-SHA256 of the body>` header computed with `NETNEUTRALITY_WEBHOOK_SECRET`. Failed deliveries are retried 3 times with exponential backoff and then dropped, so the frontend should still do a full resync on load and periodically. The polling endpoints remain available.
|
||||||
|
|
||||||
|
`PLAYER_SEEKED` is sent after a seek with payload `{ position }` (milliseconds). `VOTE_SKIP_UPDATED` is sent after a vote that doesn't skip the track yet, with payload `{ userId, votes, threshold }` (`threshold` is the percentage of listeners needed). A vote that does skip produces the usual `TRACK_END` / `TRACK_START` / `QUEUE_UPDATED` events instead.
|
||||||
|
|
||||||
|
`TRACK_START` / `TRACK_END` payloads are `{ identifier, title, author, length, uri, artworkUrl }` (plus `reason` on `TRACK_END`). `uri` and `artworkUrl` may be `null` when the source doesn't provide them.
|
||||||
|
|
||||||
|
`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, username, nickname, bot, self }`, where `nickname` is the member's effective display name in the guild, `bot` marks bot accounts and `self` marks this bot's own voice changes.
|
||||||
|
|
||||||
|
## Running with Docker Compose
|
||||||
|
|
||||||
|
Docker Compose is the preferred way to run the bot. It starts the bot, the database, Lavalink, and the supporting YouTube cipher and PO token services.
|
||||||
|
|
||||||
|
1. Copy `bot.properties.example` and fill in the values (see [Configuration](#configuration)).
|
||||||
|
2. Build and start the stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build
|
docker compose build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The environment variables needed for the bot to run are available in the file **bot.properties.example**
|
3. View logs:
|
||||||
If running locally without Docker, you need to copy the example file to **bot.properties** and fill the values.
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f netneutrality-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs are also written to the `logs` directory.
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
1. Copy `bot.properties.example` to `bot.properties` and fill in the values.
|
||||||
|
2. Make sure a database and a Lavalink node are reachable at the configured addresses.
|
||||||
|
3. Build the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw package
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the bot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar target/netneutrality.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies are copied to `target/lib` during packaging and referenced by the jar manifest.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test
|
||||||
|
```
|
||||||
|
|
||||||
|
Database tests use Testcontainers, so Docker must be running. Checkstyle runs during the `test` phase.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See the [LICENSE](LICENSE) file.
|
||||||
+12
-3
@@ -6,6 +6,8 @@ NETNEUTRALITY_BOT_PREFIX=$
|
|||||||
NETNEUTRALITY_BOT_OWNER=
|
NETNEUTRALITY_BOT_OWNER=
|
||||||
NETNEUTRALITY_BOT_INVITE=
|
NETNEUTRALITY_BOT_INVITE=
|
||||||
NETNEUTRALITY_API_PORT=9000
|
NETNEUTRALITY_API_PORT=9000
|
||||||
|
# Optional: number of gateway shards. Leave unset to use Discord's recommended count.
|
||||||
|
# NETNEUTRALITY_SHARD_TOTAL=1
|
||||||
|
|
||||||
MYSQL_HOST=127.0.0.1
|
MYSQL_HOST=127.0.0.1
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
@@ -20,7 +22,14 @@ NETNEUTRALITY_VISITOR_DATA=
|
|||||||
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
||||||
NETNEUTRALITY_CIPHER_PASSWORD=
|
NETNEUTRALITY_CIPHER_PASSWORD=
|
||||||
|
|
||||||
|
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
|
||||||
|
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
|
||||||
|
NETNEUTRALITY_LAVALINK_NAME=main
|
||||||
|
|
||||||
NETNEUTRALITY_SENTRY_DSN=
|
NETNEUTRALITY_SENTRY_DSN=
|
||||||
GITLAB_HOST=
|
NETNEUTRALITY_WEBHOOK_URL=
|
||||||
GITLAB_TOKEN=
|
NETNEUTRALITY_WEBHOOK_SECRET=
|
||||||
GITLAB_PROJECT_ID=
|
GITEA_HOST=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_OWNER=
|
||||||
|
GITEA_REPO=
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
./mvnw versions:commit
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
./mvnw versions:set -DnewVersion=$CZ_PRE_NEW_VERSION
|
|
||||||
+56
-6
@@ -13,6 +13,12 @@ services:
|
|||||||
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
|
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
|
||||||
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password}
|
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password}
|
||||||
- MARIADB_AUTO_UPGRADE=1
|
- MARIADB_AUTO_UPGRADE=1
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
netneutrality-bot:
|
netneutrality-bot:
|
||||||
image: gitea.bobert.me/anthorob/netneutrality:${NETNEUTRALITY_TAG:-latest}
|
image: gitea.bobert.me/anthorob/netneutrality:${NETNEUTRALITY_TAG:-latest}
|
||||||
@@ -22,15 +28,42 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- netneutrality
|
- netneutrality
|
||||||
depends_on:
|
depends_on:
|
||||||
- netneutrality-db
|
netneutrality-db:
|
||||||
|
condition: service_healthy
|
||||||
|
lavalink:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
MYSQL_HOST: netneutrality-db
|
- MYSQL_HOST=${MYSQL_HOST:-netneutrality-db}
|
||||||
MYSQL_PORT: 3306
|
- MYSQL_PORT=${MYSQL_PORT:-3306}
|
||||||
MYSQL_USER: java
|
- MYSQL_USER=${MYSQL_USER:-java}
|
||||||
MYSQL_PASSWORD: password
|
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-password}
|
||||||
MYSQL_DATABASE: netneutrality
|
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
|
||||||
|
- API_PORT=${NETNEUTRALITY_API_PORT:-9000}
|
||||||
|
|
||||||
|
# Lavalink >= 4.2.0 is required: the node handles DAVE end-to-end encryption,
|
||||||
|
# so the bot no longer ships native encoders.
|
||||||
|
lavalink:
|
||||||
|
image: ghcr.io/lavalink-devs/lavalink:4.2
|
||||||
|
container_name: lavalink
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- netneutrality
|
||||||
|
depends_on:
|
||||||
|
- ejs-api
|
||||||
|
- webpo
|
||||||
|
volumes:
|
||||||
|
- ./lavalink/application.yml:/opt/Lavalink/application.yml:ro
|
||||||
|
environment:
|
||||||
|
- SERVER_PASSWORD=${NETNEUTRALITY_LAVALINK_PASSWORD:-youshallnotpass}
|
||||||
|
- NETNEUTRALITY_CIPHER_URL=${NETNEUTRALITY_CIPHER_URL:-http://ejs-api:8001}
|
||||||
|
- NETNEUTRALITY_CIPHER_PASSWORD=${NETNEUTRALITY_CIPHER_PASSWORD:-}
|
||||||
|
- NETNEUTRALITY_OAUTH_TOKEN=${NETNEUTRALITY_OAUTH_TOKEN:-}
|
||||||
|
- NETNEUTRALITY_POT_URL=${NETNEUTRALITY_POT_URL:-http://webpo:8080}
|
||||||
|
- NETNEUTRALITY_PO_TOKEN=${NETNEUTRALITY_PO_TOKEN:-}
|
||||||
|
- NETNEUTRALITY_VISITOR_DATA=${NETNEUTRALITY_VISITOR_DATA:-}
|
||||||
|
|
||||||
ejs-api:
|
ejs-api:
|
||||||
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449
|
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449
|
||||||
container_name: ejs_signature_api
|
container_name: ejs_signature_api
|
||||||
@@ -39,8 +72,25 @@ services:
|
|||||||
- netneutrality
|
- netneutrality
|
||||||
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
|
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
|
||||||
# - API_TOKEN=test
|
# - API_TOKEN=test
|
||||||
|
|
||||||
|
webpo:
|
||||||
|
image: ghcr.io/ashton045/webpo-generator
|
||||||
|
container_name: webpo-generator
|
||||||
|
environment:
|
||||||
|
- HOST=${HOST:-::}
|
||||||
|
- PORT=${PORT:-8080}
|
||||||
|
- WORKERS=${WORKERS:-1}
|
||||||
|
- QUEUE_SIZE=${QUEUE_SIZE:-32}
|
||||||
|
- MAX_PENDING_REQUESTS=${MAX_PENDING_REQUESTS:-256}
|
||||||
|
- REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30000}
|
||||||
|
- CACHE_SIZE=${CACHE_SIZE:-100}
|
||||||
|
- CACHE_TTL=${CACHE_TTL:-300000}
|
||||||
|
- VISITOR_TTL=${VISITOR_TTL:-600000}
|
||||||
|
- API_TOKEN=
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
netneutrality-db: null
|
netneutrality-db: null
|
||||||
|
netneutrality-lavalink-plugins: null
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
netneutrality: null
|
netneutrality: null
|
||||||
+2
-2
@@ -16,8 +16,8 @@ subsystem, how they fit together, and the end-to-end flows that tie them.
|
|||||||
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
|
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
|
||||||
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
|
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
|
||||||
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
|
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
|
||||||
| [music.md](music.md) | Music — Lavaplayer pipeline, queue, custom Udio source |
|
| [music.md](music.md) | Music — Lavalink node & client, queue, event dispatch |
|
||||||
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
|
| [web-api.md](web-api.md) | HTTP Web API — Javalin server for control, introspection & music |
|
||||||
| [threading.md](threading.md) | Threading & scheduling infrastructure |
|
| [threading.md](threading.md) | Threading & scheduling infrastructure |
|
||||||
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
|
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
|
||||||
|
|
||||||
|
|||||||
+15
-16
@@ -37,7 +37,7 @@ execution paths:
|
|||||||
directly and no context is routed onward. See [events.md](events.md).
|
directly and no context is routed onward. See [events.md](events.md).
|
||||||
|
|
||||||
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
|
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
|
||||||
[music](music.md) (Lavaplayer), the [web API](web-api.md), and the [threading/scheduling](threading.md)
|
[music](music.md) (a remote Lavalink node), the [web API](web-api.md), and the [threading/scheduling](threading.md)
|
||||||
infrastructure.
|
infrastructure.
|
||||||
|
|
||||||
## Technology stack
|
## Technology stack
|
||||||
@@ -45,15 +45,13 @@ infrastructure.
|
|||||||
| Concern | Library | Version |
|
| Concern | Library | Version |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
|
||||||
| Voice / DAVE (E2EE) | `club.minnced:jdave-*` | 0.1.8 |
|
| Audio engine | Lavalink v4 node (`ghcr.io/lavalink-devs/lavalink`) | 4.2 |
|
||||||
| Audio engine | `dev.arbjerg:lavaplayer` | 2.2.6 |
|
| Audio client | `dev.arbjerg:lavalink-client` | 3.4.0 |
|
||||||
| YouTube source | `dev.lavalink.youtube:v2` | 1.18.2 |
|
|
||||||
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
|
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
|
||||||
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
|
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
|
||||||
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
|
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
|
||||||
| DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 |
|
| DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 |
|
||||||
| Error tracking | `io.sentry:sentry` | 8.41.0 |
|
| Error tracking | `io.sentry:sentry` | 8.41.0 |
|
||||||
| GitLab integration | `org.gitlab4j:gitlab4j-api` | 6.2.0 |
|
|
||||||
| HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 |
|
| HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 |
|
||||||
| JSON | `org.json:json` + `com.google.code.gson:gson` | — |
|
| JSON | `org.json:json` + `com.google.code.gson:gson` | — |
|
||||||
| Classpath scanning | `org.reflections:reflections` | 0.10.2 |
|
| Classpath scanning | `org.reflections:reflections` | 0.10.2 |
|
||||||
@@ -61,8 +59,8 @@ infrastructure.
|
|||||||
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
|
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
|
||||||
| Testing | JUnit Jupiter 6 + Mockito 5 | — |
|
| Testing | JUnit Jupiter 6 + Mockito 5 | — |
|
||||||
|
|
||||||
The HTTP **server** for the internal API uses the JDK built-in
|
The HTTP **server** for the internal API uses embedded Javalin (with OpenAPI/Swagger plugins); see
|
||||||
`com.sun.net.httpserver.HttpServer` (no external framework); see [web-api.md](web-api.md).
|
[web-api.md](web-api.md).
|
||||||
|
|
||||||
## Startup lifecycle
|
## Startup lifecycle
|
||||||
|
|
||||||
@@ -77,7 +75,7 @@ component in ascending priority order.
|
|||||||
also registered into the context as `SettingsProvider` for constructor injection).
|
also registered into the context as `SettingsProvider` for constructor injection).
|
||||||
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
|
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
|
||||||
(environment, release = `VERSION`, traces sample rate 1.0).
|
(environment, release = `VERSION`, traces sample rate 1.0).
|
||||||
3. **Log levels** — in `dev`, raise JDA / Lavalink-YouTube / sedmelluq logging to `DEBUG`.
|
3. **Log levels** — in `dev`, raise JDA and Lavalink client logging to `DEBUG`.
|
||||||
4. `startup()`:
|
4. `startup()`:
|
||||||
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
|
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
|
||||||
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
|
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
|
||||||
@@ -92,7 +90,7 @@ component in ascending priority order.
|
|||||||
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
|
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
|
||||||
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
|
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
|
||||||
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
|
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
|
||||||
| 40 | `MusicServiceImpl` (`MusicService`) | Initialise the shared Lavaplayer `AudioPlayerManager`. |
|
| 40 | `MusicServiceImpl` (`MusicService`) | Create the shared `LavalinkClient`, register the node and subscribe to its events. |
|
||||||
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
|
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
|
||||||
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
|
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
|
||||||
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
|
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
|
||||||
@@ -106,8 +104,10 @@ context (falling back to a no-arg constructor when none match).
|
|||||||
### Gateway intents
|
### Gateway intents
|
||||||
|
|
||||||
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
|
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
|
||||||
`MESSAGE_CONTENT`. It also installs the DAVE (end-to-end encrypted voice) session factory via
|
`MESSAGE_CONTENT`. It also installs `new JDAVoiceUpdateListener(music.getClient())` as the voice
|
||||||
`AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())`.
|
dispatch interceptor, so voice server/state updates are forwarded to Lavalink instead of JDA's own
|
||||||
|
audio stack. DAVE (end-to-end encrypted voice) is handled by the node, which is why the bot no
|
||||||
|
longer ships native audio encoders.
|
||||||
|
|
||||||
## Shutdown lifecycle
|
## Shutdown lifecycle
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ block the others. Effectively this means:
|
|||||||
2. Post-JDA adapter — no-op.
|
2. Post-JDA adapter — no-op.
|
||||||
3. JDA adapter — `jda.shutdown()`.
|
3. JDA adapter — `jda.shutdown()`.
|
||||||
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
|
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
|
||||||
5. `MusicServiceImpl.stop()` — disconnect players / leave voice channels.
|
5. `MusicServiceImpl.stop()` — dispose the event subscriptions, destroy the players and close the Lavalink client.
|
||||||
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
|
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
|
||||||
7. `ThreadManager.stop()` — shut down the shared executors.
|
7. `ThreadManager.stop()` — shut down the shared executors.
|
||||||
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
|
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
|
||||||
@@ -139,14 +139,14 @@ 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` |
|
||||||
@@ -155,7 +155,6 @@ block the others. Effectively this means:
|
|||||||
| `…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, sources, tracks) |
|
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
|
||||||
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
|
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
|
||||||
| `…shared.util` | Cross-cutting helpers |
|
|
||||||
| `…exceptions` | Custom exception hierarchy |
|
| `…exceptions` | Custom exception hierarchy |
|
||||||
+6
-5
@@ -1,7 +1,7 @@
|
|||||||
# Commands
|
# Commands
|
||||||
|
|
||||||
**Packages:** `me.bobert.netneutrality.framework.command` (framework),
|
**Packages:** `me.bobert.netneutrality.core.command` (framework),
|
||||||
`me.bobert.netneutrality.feature.*.command` (concrete commands)
|
`me.bobert.netneutrality.command.*` (concrete commands)
|
||||||
|
|
||||||
A **Command** is what a user executes on Discord. It receives a
|
A **Command** is what a user executes on Discord. It receives a
|
||||||
[`CommandContext`](contexts.md#commandcontext) and runs logic based on it. There are two primary
|
[`CommandContext`](contexts.md#commandcontext) and runs logic based on it. There are two primary
|
||||||
@@ -129,7 +129,7 @@ At runtime, an incoming command event becomes a `CommandContext` in the
|
|||||||
|
|
||||||
## Command catalog
|
## Command catalog
|
||||||
|
|
||||||
Concrete commands live under `me.bobert.netneutrality.feature.*.command`, grouped by feature package:
|
Concrete commands live under `me.bobert.netneutrality.command.*`, grouped by feature package:
|
||||||
|
|
||||||
| Command | Package | Type | Description |
|
| Command | Package | Type | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -139,7 +139,7 @@ Concrete commands live under `me.bobert.netneutrality.feature.*.command`, groupe
|
|||||||
| `PingCommand` | `basic` | Slash | Reply with the gateway ping. |
|
| `PingCommand` | `basic` | Slash | Reply with the gateway ping. |
|
||||||
| `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). |
|
| `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). |
|
||||||
| `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. |
|
| `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. |
|
||||||
| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the GitLab report flow). |
|
| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the Gitea report flow). |
|
||||||
| `PlayCommand` | `music` | Slash | Search/queue and start playback. |
|
| `PlayCommand` | `music` | Slash | Search/queue and start playback. |
|
||||||
| `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. |
|
| `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. |
|
||||||
| `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. |
|
| `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. |
|
||||||
@@ -147,6 +147,7 @@ Concrete commands live under `me.bobert.netneutrality.feature.*.command`, groupe
|
|||||||
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
|
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
|
||||||
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
|
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
|
||||||
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
|
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
|
||||||
|
| `AutoplayCommand` | `music` | Slash | Toggle autoplay: queues the YouTube radio mix of the last track so music keeps playing. |
|
||||||
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
|
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
|
||||||
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
|
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
|
||||||
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
|
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
# Contexts
|
# Contexts
|
||||||
|
|
||||||
**Packages:** `me.bobert.netneutrality.framework.context` (framework),
|
**Packages:** `me.bobert.netneutrality.core.context` (framework),
|
||||||
`me.bobert.netneutrality.framework.context` (concrete implementations)
|
`me.bobert.netneutrality.context.command` / `me.bobert.netneutrality.context.tasks` (concrete implementations)
|
||||||
|
|
||||||
A **Context** is a high-level abstraction over a Discord event. It hides the differences between the
|
A **Context** is a high-level abstraction over a Discord event. It hides the differences between the
|
||||||
many JDA event types (slash command, message, button, modal, reaction, …) behind a single, uniform
|
many JDA event types (slash command, message, button, modal, reaction, …) behind a single, uniform
|
||||||
@@ -117,7 +117,7 @@ Abstract base for the [task flow](tasks.md). Two things distinguish it:
|
|||||||
|
|
||||||
### `AsyncContext`
|
### `AsyncContext`
|
||||||
|
|
||||||
`me.bobert.netneutrality.framework.context.AsyncContext` wraps the **result** of a previous reply — either an
|
`me.bobert.netneutrality.core.context.AsyncContext` wraps the **result** of a previous reply — either an
|
||||||
`InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
|
`InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
|
||||||
expressed the same way regardless of the original trigger:
|
expressed the same way regardless of the original trigger:
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,6 @@
|
|||||||
# Event Handling & Routing
|
# Event Handling & Routing
|
||||||
|
|
||||||
**Package:** `me.bobert.netneutrality.platform.event`
|
**Package:** `me.bobert.netneutrality.hooks`
|
||||||
**Primary class:** `EventsHandler`
|
**Primary class:** `EventsHandler`
|
||||||
|
|
||||||
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
|
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
|
||||||
@@ -91,6 +91,21 @@ Key points:
|
|||||||
`TaskManager.getInstance().handle(context)`.
|
`TaskManager.getInstance().handle(context)`.
|
||||||
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
|
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
|
||||||
|
|
||||||
|
## Webhook music events
|
||||||
|
|
||||||
|
`MusicEvent` instances are delivered by `WebhookDispatcher`. Every event carries `type`, `guildId`,
|
||||||
|
`timestamp` and `seq`; the payloads of the queue and player events are:
|
||||||
|
|
||||||
|
| Type | Payload |
|
||||||
|
|---|---|
|
||||||
|
| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`playlist_added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `author`, `duration_ms`, `uri`, `artwork_url`, `requester_id` and `playlist` (`{name, identifier, track_count}` or `null` when the track was not queued from a playlist). `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). `playlist_added` is emitted once per playlist load (instead of one `added` event per track), lists only the tracks actually added (duplicates are skipped) and carries a top-level `playlist` object (`name`, `identifier`, `track_count`, the number actually added). |
|
||||||
|
| `QUEUE_SHUFFLED` | `size` |
|
||||||
|
| `TRACK_ERROR` | `track` (`identifier`, `title`, `author`, `length`, `uri`, `artworkUrl`), `message`, `severity` |
|
||||||
|
| `TRACK_STUCK` | `track` (same fields as above), `thresholdMs` |
|
||||||
|
| `PLAYER_POSITION` | `position` (ms), `timestamp` (node time, epoch ms), `connected`, `ping` (ms). Sent from Lavalink `PlayerUpdateEvent`, at most once every 5 seconds per guild and only while a track is playing. |
|
||||||
|
|
||||||
|
Consumers must handle the `playlist_added` change value and the per-item `playlist` field. The REST queue listing (`GET /music/{guildId}/queue`) also reports `playlist` on every entry.
|
||||||
|
|
||||||
## Extending the event system
|
## Extending the event system
|
||||||
|
|
||||||
To handle a new event type:
|
To handle a new event type:
|
||||||
|
|||||||
+116
-39
@@ -1,35 +1,51 @@
|
|||||||
# Music
|
# Music
|
||||||
|
|
||||||
**Package:** `me.bobert.netneutrality.feature.music`
|
**Package:** `me.bobert.netneutrality.music`
|
||||||
**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube
|
**Engine:** a remote [Lavalink](https://lavalink.dev) v4 node driven by
|
||||||
source 1.18.2.
|
[`dev.arbjerg:lavalink-client`](https://github.com/lavalink-devs/Lavalink-Client) 3.4.0.
|
||||||
|
|
||||||
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
|
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
|
||||||
sources, queueing, scheduling, and streaming audio into a Discord voice channel.
|
sources, queueing, scheduling, and streaming audio into a Discord voice channel. Since the
|
||||||
|
migration to Lavalink, **decoding, source resolution and voice sending happen on the node**, not in
|
||||||
|
the bot process. The bot only sends REST/WebSocket commands and reacts to the node's events, which
|
||||||
|
means new sources (YouTube, LavaSrc, …) are added by configuring server-side plugins rather than by
|
||||||
|
changing Java code.
|
||||||
|
|
||||||
## Key classes
|
## Key classes
|
||||||
|
|
||||||
| Class | Responsibility |
|
| Class | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `MusicManager` | Central per-guild coordinator; owns the `AudioPlayer` and `TrackScheduler`. |
|
| `MusicServiceImpl` | Owns the shared `LavalinkClient`, registers the node, and dispatches its event flux. |
|
||||||
|
| `MusicManager` | Central per-guild coordinator; resolves the guild's `Link` and exposes a synchronous facade. |
|
||||||
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
|
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
|
||||||
| `TrackScheduler` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). |
|
| `TrackScheduler` | Event sink for `onTrackStart` / `onTrackEnd` / `onTrackException` / `onTrackStuck`; caches the current track. |
|
||||||
| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. |
|
| `TrackLoadingHandler` | `AbstractAudioLoadResultHandler`; handles track/playlist/search/no-match/failure results. |
|
||||||
| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). |
|
| `TrackInfo` | Value object pairing a Lavalink `Track` with its requester (`Member`). |
|
||||||
| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). |
|
| `SourceFinder` | Identifies the source/type of a query (YouTube, SoundCloud, HTTP, …). |
|
||||||
| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. |
|
| `StuckTrackRecoveryHandler` | Retry-then-skip logic for tracks the node reports as stuck. |
|
||||||
| `source/UdioAudioSourceManager` | Custom Lavaplayer source manager for udio.com. |
|
|
||||||
| `source/UdioAudioTrack` | Udio track; delegates playback to `Mp3AudioTrack`. |
|
|
||||||
|
|
||||||
`MusicManager` keeps a static `Map<String, MusicManager>` of managers keyed by guild id, so each guild
|
`MusicServiceImpl` keeps a `Map<String, MusicManager>` of managers keyed by guild id, so each guild
|
||||||
has its own player, scheduler, and queue.
|
has its own link, scheduler, and queue.
|
||||||
|
|
||||||
## Startup
|
## Startup
|
||||||
|
|
||||||
`MusicManager.initPlayerManager()` (called from
|
`MusicServiceImpl` is a priority-40 [`Lifecycle`](architecture.md#startup-lifecycle) component. Its
|
||||||
[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer
|
constructor creates the `LavalinkClient` (the bot user id is derived from the token) and `start()`
|
||||||
`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom
|
registers the configured node. **A node that is unreachable never prevents the bot from booting**:
|
||||||
[Udio source](#custom-udio-source), and HTTP).
|
the client retries in the background and music commands reply with an error in the meantime.
|
||||||
|
|
||||||
|
Because the client must exist before JDA is built, `App.initJDA()` (priority 60) installs
|
||||||
|
`new JDAVoiceUpdateListener(music.getClient())` as the gateway's voice dispatch interceptor. JDA's
|
||||||
|
`AudioManager` is therefore **not** used for playback; voice connections are opened and closed
|
||||||
|
through `JDA#getDirectAudioController()`.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
| Setting | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `NETNEUTRALITY_LAVALINK_URI` | `ws://lavalink:2333` | WebSocket URI of the node |
|
||||||
|
| `NETNEUTRALITY_LAVALINK_PASSWORD` | — | Node password; music is disabled when missing |
|
||||||
|
| `NETNEUTRALITY_LAVALINK_NAME` | `main` | Node name used in logs |
|
||||||
|
|
||||||
## Audio pipeline (end-to-end)
|
## Audio pipeline (end-to-end)
|
||||||
|
|
||||||
@@ -38,50 +54,111 @@ has its own player, scheduler, and queue.
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
MusicManager.play(query, ctx)
|
MusicManager.play(query, ctx)
|
||||||
│ SourceFinder identifies the source
|
│ SourceFinder identifies the source and builds the identifier
|
||||||
▼
|
▼
|
||||||
audioPlayerManager.loadItemOrdered(guild, identifier, new TrackLoadingHandler(...))
|
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
|
||||||
│
|
│
|
||||||
├─ trackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
|
├─ ontrackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
|
||||||
├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons)
|
├─ onPlaylistLoaded / onSearchResultLoaded ──▶ install a LoadPlaylistTask (choice buttons)
|
||||||
├─ noMatches ──▶ reply "nothing found"
|
├─ noMatches ──▶ reply "nothing found"
|
||||||
└─ loadFailed ──▶ reply / log error
|
└─ loadFailed ──▶ reply / log error
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
QueueManager: if the player is idle, start immediately; else append to the deque
|
QueueManager: if nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
AudioPlayerSendHandler streams PCM/Opus frames to the voice channel
|
Lavalink node decodes and streams the audio to the Discord voice gateway
|
||||||
│
|
│
|
||||||
▼ (track ends)
|
▼ (track ends)
|
||||||
TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
|
node event ──▶ MusicServiceImpl dispatcher ──▶ TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Event dispatch** — `MusicServiceImpl` holds a single subscription per event type
|
||||||
|
(`TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`,
|
||||||
|
`WebSocketClosedEvent`) and routes each event to the owning guild's `TrackScheduler`. All
|
||||||
|
subscriptions are disposed in `stop()`.
|
||||||
|
- **Synchronous facade** — every Lavalink operation returns a reactive `Mono`. `MusicManager`
|
||||||
|
subscribes internally so the 13 music commands keep their existing synchronous signatures;
|
||||||
|
failures are reported through `LogHandler` and the attached `MusicChannelContext`.
|
||||||
|
- **Current track** — the node's player state is only readable via REST, so the last
|
||||||
|
`TrackStartEvent` is cached on the scheduler and used by `getCurrentlyPlaying()` and by the queue
|
||||||
|
to decide whether playback must be started.
|
||||||
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
|
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
|
||||||
now-playing/queue displays and vote-skip.
|
now-playing/queue displays and vote-skip. Duplicate rejection compares the encoded track payload.
|
||||||
- **Playlists / search** — playlist and multi-result loads produce a
|
- **Playlists / search** — playlist and search loads produce a
|
||||||
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
|
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
|
||||||
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
|
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
|
||||||
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
|
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
|
||||||
- **Stuck tracks** — `TrackScheduler.onTrackStuck` attempts recovery (reload/skip).
|
- **Stuck tracks** — the first `TrackStuckEvent` re-queues the track and seeks back to the last
|
||||||
|
known position; a second consecutive event skips to the next track.
|
||||||
- **Output** — playback messages are posted through a
|
- **Output** — playback messages are posted through a
|
||||||
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
|
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
|
||||||
music channel.
|
music channel.
|
||||||
|
|
||||||
## Custom Udio source
|
## Running the node
|
||||||
|
|
||||||
`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist,
|
`docker-compose.yml` defines a `lavalink` service (image `ghcr.io/lavalink-devs/lavalink:4.2`, the
|
||||||
duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the
|
minimum version that handles DAVE end-to-end encrypted voice) on the `netneutrality` network, with
|
||||||
page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the
|
`lavalink/application.yml` mounted read-only at `/opt/Lavalink/application.yml`. The bot depends on
|
||||||
`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's
|
it and the node depends on the existing `ejs-api` (yt-cipher) container.
|
||||||
`Mp3AudioTrack`.
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d lavalink
|
||||||
|
docker compose logs -f lavalink # wait for "Lavalink is ready to accept connections"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a server-side plugin
|
||||||
|
|
||||||
|
Plugins are declared in `lavalink/application.yml` and downloaded by the node on boot into the
|
||||||
|
`netneutrality-lavalink-plugins` volume — no bot rebuild is required:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
- dependency: "dev.lavalink.youtube:youtube-plugin:1.18.2"
|
||||||
|
snapshot: false
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin that is not on the default Lavalink repository can be pulled from any Maven repository by
|
||||||
|
adding `repository:` to the entry. The node currently runs a JitPack build of the SABR-enabled
|
||||||
|
YouTube fork ([`ashton045/youtube-source`](https://jitpack.io/#ashton045/youtube-source), branch
|
||||||
|
`feat/sabr-support`) — no local build of the fork is required, JitPack publishes a ready Lavalink
|
||||||
|
plugin jar:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:17619654b1"
|
||||||
|
repository: "https://jitpack.io"
|
||||||
|
snapshot: false
|
||||||
|
```
|
||||||
|
|
||||||
|
The node logs `Failed to check for updates for youtube-plugin: Not Found` on boot; this is harmless,
|
||||||
|
JitPack publishes no `maven-metadata.xml` for commit-pinned versions.
|
||||||
|
|
||||||
|
Plugin-specific configuration goes under the top-level `plugins:` key. The YouTube plugin is
|
||||||
|
configured there with the remote cipher URL, poToken and visitorData, which used to live in
|
||||||
|
`MusicServiceImpl.start()`.
|
||||||
|
|
||||||
|
## Udio support (deferred)
|
||||||
|
|
||||||
|
Udio playback is **temporarily unavailable**. The previous implementation was a client-side
|
||||||
|
Lavaplayer `AudioSourceManager`, and Lavalink can only resolve sources that live on the server.
|
||||||
|
`SourceFinder` still recognises Udio URLs but returns no identifier, and `MusicManager.play` replies
|
||||||
|
that Udio is temporarily unsupported.
|
||||||
|
|
||||||
|
The original `UdioAudioSourceManager`, `UdioAudioTrack` and their test have been preserved verbatim
|
||||||
|
under [`docs/reference/udio-lavalink-plugin/`](reference/udio-lavalink-plugin) — they are **not
|
||||||
|
compiled** — as the reference for a future Lavalink **server plugin** port. That plugin is out of
|
||||||
|
scope of this migration and is tracked as a follow-up.
|
||||||
|
|
||||||
## Guild lifecycle & cleanup
|
## Guild lifecycle & cleanup
|
||||||
|
|
||||||
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the player has been idle.
|
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the channel is empty or the queue
|
||||||
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the
|
has run out; the connected channel is read from the bot's gateway voice state.
|
||||||
static map.
|
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances.
|
||||||
- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice
|
- On shutdown, `MusicServiceImpl.stop()` disposes the event subscriptions, clears the queues,
|
||||||
|
destroys every player and closes the client
|
||||||
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
|
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
|
||||||
|
|
||||||
## Related commands
|
## Related commands
|
||||||
@@ -89,5 +166,5 @@ page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** th
|
|||||||
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
|
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
|
||||||
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
|
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
|
||||||
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
|
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
|
||||||
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
|
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `AutoplayCommand`, `BitconnectCommand`. See
|
||||||
[commands.md](commands.md#command-catalog).
|
[commands.md](commands.md#command-catalog).
|
||||||
+12
-7
@@ -1,8 +1,9 @@
|
|||||||
# Persistence & Database
|
# Persistence & Database
|
||||||
|
|
||||||
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
|
**Packages:** `me.bobert.netneutrality.db` (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
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ Repositories obtain sessions through the factory; transactional access uses help
|
|||||||
|
|
||||||
## Entity catalog
|
## Entity catalog
|
||||||
|
|
||||||
Entities live in `me.bobert.netneutrality.data.entity` and are prefixed with `O`. `OGuild` is the hub
|
Entities live in `me.bobert.netneutrality.db.entities` and are prefixed with `O`. `OGuild` is the hub
|
||||||
of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
|
of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
|
||||||
|
|
||||||
| Entity | Table | Key | Relationships / notes |
|
| Entity | Table | Key | Relationships / notes |
|
||||||
@@ -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.
|
||||||
+52
-77
@@ -1,7 +1,7 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
**Packages:** `me.bobert.netneutrality.framework.task` (framework),
|
**Packages:** `me.bobert.netneutrality.core.tasks` (framework),
|
||||||
`me.bobert.netneutrality.feature.*.task` (concrete tasks)
|
`me.bobert.netneutrality.tasks.*` (concrete tasks)
|
||||||
|
|
||||||
Tasks are one of the more advanced concepts in the bot. A **Task** is a unit of logic that is looked
|
Tasks are one of the more advanced concepts in the bot. A **Task** is a unit of logic that is looked
|
||||||
up and executed by **id** when a matching Discord event arrives, as opposed to a
|
up and executed by **id** when a matching Discord event arrives, as opposed to a
|
||||||
@@ -60,7 +60,7 @@ AbstractTask (implements Task)
|
|||||||
|
|
||||||
## `TaskManager`
|
## `TaskManager`
|
||||||
|
|
||||||
`me.bobert.netneutrality.framework.task.TaskManager` is the registry, lookup, and execution
|
`me.bobert.netneutrality.core.tasks.TaskManager` is the registry, lookup, and execution
|
||||||
coordinator.
|
coordinator.
|
||||||
|
|
||||||
### Registration — `insert`
|
### Registration — `insert`
|
||||||
@@ -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`.
|
||||||
@@ -273,7 +249,7 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in
|
|||||||
| `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. |
|
| `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. |
|
||||||
| `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. |
|
| `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. |
|
||||||
| `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. |
|
| `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. |
|
||||||
| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a GitLab issue. |
|
| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a Gitea issue. |
|
||||||
| `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. |
|
| `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. |
|
||||||
| `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. |
|
| `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. |
|
||||||
| `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. |
|
| `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. |
|
||||||
@@ -296,15 +272,14 @@ 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 — Gitea bug report (modal)
|
||||||
|
|
||||||
1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields.
|
1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields.
|
||||||
2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values.
|
2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values.
|
||||||
3. The task connects to GitLab (using `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` from
|
3. The task calls the Gitea API via `GiteaClient` (using `GITEA_HOST` / `GITEA_TOKEN` /`r`n `GITEA_OWNER` / `GITEA_REPO` from configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
|
||||||
configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
|
|
||||||
|
|
||||||
### Worked example — music results (parent/children)
|
### Worked example — music results (parent/children)
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,6 @@
|
|||||||
# Threading & Scheduling
|
# Threading & Scheduling
|
||||||
|
|
||||||
**Package:** `me.bobert.netneutrality.platform.concurrency`
|
**Packages:** `me.bobert.netneutrality.core.thread` and `me.bobert.netneutrality.core.scheduled`
|
||||||
|
|
||||||
This subsystem centralizes asynchronous execution and recurring jobs.
|
This subsystem centralizes asynchronous execution and recurring jobs.
|
||||||
|
|
||||||
@@ -21,11 +21,11 @@ This subsystem centralizes asynchronous execution and recurring jobs.
|
|||||||
|
|
||||||
## `BaseThreadFactory`
|
## `BaseThreadFactory`
|
||||||
|
|
||||||
Names created threads (e.g. `web-api-1`) so they are identifiable in logs and stack dumps, and installs
|
Names created threads (e.g. `pool-1`) so they are identifiable in logs and stack dumps, and installs
|
||||||
an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
|
an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
|
||||||
`UncaughtExceptionHandler` so crashes on worker threads are still routed through
|
`UncaughtExceptionHandler` so crashes on worker threads are still routed through
|
||||||
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used both by `ThreadManager` and by the
|
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used by `ThreadManager`; the
|
||||||
[Web API](web-api.md) server pool.
|
[Web API](web-api.md) runs on Javalin's own thread pool instead.
|
||||||
|
|
||||||
## `ScheduledTaskManager`
|
## `ScheduledTaskManager`
|
||||||
|
|
||||||
@@ -37,7 +37,8 @@ startup by [`TaskManager.init()`](tasks.md#taskmanager)):
|
|||||||
- `cancelAll()` — cancels every scheduled task; called first in
|
- `cancelAll()` — cancels every scheduled task; called first in
|
||||||
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
|
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
|
||||||
|
|
||||||
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect`, and
|
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect` (which reads the
|
||||||
|
bot's gateway voice state rather than JDA's audio manager, see [music.md](music.md)), and
|
||||||
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
|
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
|
||||||
|
|
||||||
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
|
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ 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). |
|
| `bootstrap.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).
|
||||||
@@ -37,14 +36,14 @@ 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` |
|
| `utils.SettingsProvider` | `utils.Settings` |
|
||||||
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
|
| `db.SessionProvider` | `db.SessionManager` |
|
||||||
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
|
| `core.thread.ExecutorProvider` | `core.thread.ThreadManager` |
|
||||||
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
|
| `core.command.CommandRegistry` | `core.command.CommandManager` |
|
||||||
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
|
| `core.tasks.TaskManager` | `core.tasks.TaskManagerImpl` |
|
||||||
| `feature.music.MusicService` | `music.MusicServiceImpl` |
|
| `music.MusicService` | `music.MusicServiceImpl` |
|
||||||
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
|
| `db.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:
|
||||||
@@ -61,8 +60,8 @@ static access points left are:
|
|||||||
|
|
||||||
### `Settings` / `SettingsProvider`
|
### `Settings` / `SettingsProvider`
|
||||||
|
|
||||||
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements
|
**Class:** `me.bobert.netneutrality.utils.Settings` implements
|
||||||
`me.bobert.netneutrality.platform.config.SettingsProvider`.
|
`me.bobert.netneutrality.utils.SettingsProvider`.
|
||||||
|
|
||||||
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties`
|
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties`
|
||||||
file and overlays **environment variables** on top (environment variables win).
|
file and overlays **environment variables** on top (environment variables win).
|
||||||
@@ -76,7 +75,7 @@ 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 `$`). |
|
||||||
@@ -85,12 +84,13 @@ An example configuration file is `bot.properties.example`. Key configuration val
|
|||||||
| `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)). |
|
| `GITEA_HOST` / `GITEA_TOKEN` / `GITEA_OWNER` / `GITEA_REPO` | Gitea issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitea-bug-report-modal)). Token needs `write:issue`. |
|
||||||
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube playback auth/cipher settings for the [music](music.md) source. |
|
| `NETNEUTRALITY_LAVALINK_URI` / `_PASSWORD` / `_NAME` | Connection settings for the [Lavalink node](music.md#settings). |
|
||||||
|
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. |
|
||||||
|
|
||||||
## Utility helpers
|
## Utility helpers
|
||||||
|
|
||||||
**Package:** `me.bobert.netneutrality.shared.util` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.platform.*`
|
**Package:** `me.bobert.netneutrality.utils` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.exceptions`
|
||||||
|
|
||||||
| Class | Purpose |
|
| Class | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -125,7 +125,7 @@ singletons.
|
|||||||
|
|
||||||
### `LogHandler`
|
### `LogHandler`
|
||||||
|
|
||||||
**Class:** `me.bobert.netneutrality.platform.logging.LogHandler`.
|
**Class:** `me.bobert.netneutrality.core.LogHandler`.
|
||||||
|
|
||||||
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
|
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
|
||||||
|
|
||||||
|
|||||||
+71
-30
@@ -1,51 +1,92 @@
|
|||||||
# HTTP Web API
|
# HTTP Web API
|
||||||
|
|
||||||
**Package:** `me.bobert.netneutrality.platform.web` (client module) and
|
**Package:** `me.bobert.netneutrality.http` (outbound client) and
|
||||||
`me.bobert.netneutrality.platform.web.server` (server).
|
`me.bobert.netneutrality.http.server` (server).
|
||||||
**Server:** JDK built-in `com.sun.net.httpserver.HttpServer` (no external web framework).
|
**Server:** embedded [Javalin](https://javalin.io) 7 with the OpenAPI and Swagger UI plugins.
|
||||||
|
|
||||||
The bot exposes a small internal HTTP API for health monitoring, control, and guild introspection —
|
The bot exposes an internal HTTP API for health monitoring, control, guild introspection, and music
|
||||||
intended for an external dashboard/companion, not for end users in Discord.
|
control — intended for an external dashboard/companion, not for end users in Discord.
|
||||||
|
|
||||||
## Server startup
|
## Server startup
|
||||||
|
|
||||||
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
|
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
|
||||||
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
|
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
|
||||||
It receives `SettingsProvider`, `GuildServiceProvider`, and suppliers for the live `JDA` /
|
It receives `SettingsProvider`, `GuildServiceProvider`, `MusicService`, and suppliers for the live
|
||||||
shutdown callback via its constructor. On `start()` it:
|
`JDA` / shutdown callback via its constructor. On `start()` it:
|
||||||
|
|
||||||
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
|
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
|
||||||
- creates an `HttpServer` bound to that port,
|
- creates a `Javalin` app (Javalin 7 requires routes and exception/error handlers to be registered
|
||||||
- installs an `Executor` backed by a fixed thread pool of 10 threads named via
|
upfront inside `Javalin.create`),
|
||||||
[`BaseThreadFactory`](threading.md),
|
- registers the `OpenApiPlugin` (title "Net Neutrality API", `X-API-Key` security scheme) and the
|
||||||
- registers the handler contexts (below), and
|
`SwaggerPlugin`,
|
||||||
- starts the server.
|
- installs the `ApiKeyFilter` as a `before` handler,
|
||||||
|
- registers the route groups (below), the exception handlers and the 404 handler, and
|
||||||
|
- starts the server on the port.
|
||||||
|
|
||||||
On `stop()` the server and its executor are shut down gracefully.
|
On `stop()` the Javalin app is stopped.
|
||||||
|
|
||||||
## Handler model
|
## Authentication
|
||||||
|
|
||||||
`AbstractHandler` (implements `HttpHandler`) is the base for all handlers and provides helpers such as
|
`ApiKeyFilter` compares the `X-API-Key` request header with the `NETNEUTRALITY_API_KEY` setting.
|
||||||
`sendJsonResponse(...)` and `sendStringResponse(...)`. Concrete handlers extend it and are mounted at
|
A missing/invalid key — or no configured key at all — yields `401` ("Missing or invalid API key").
|
||||||
a path prefix.
|
Public paths that skip the check: `/`, and anything starting with `/openapi`, `/swagger` or
|
||||||
|
`/webjars` (Swagger UI and its assets).
|
||||||
|
|
||||||
| Handler | Base path | Endpoints |
|
## Error handling
|
||||||
|---|---|---|
|
|
||||||
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
|
|
||||||
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
|
|
||||||
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown; `/actions/settings/current/{guildId}` — current guild settings; `/actions/settings/reload/{guildId}` — invalidate & reload the settings cache. |
|
|
||||||
|
|
||||||
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the
|
- `HttpResponseException` (e.g. `BadRequestResponse`, `NotFoundResponse`) →
|
||||||
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings
|
`{"status":"error","message":"..."}` with the exception's status.
|
||||||
endpoints).
|
- Any other exception → logged, `500` with `{"status":"error"}`.
|
||||||
|
- Unknown paths → `404` with `{"status":"error","message":"Not found"}`.
|
||||||
|
|
||||||
|
## Route groups
|
||||||
|
|
||||||
|
Each group is a `*Routes` class with a static `register(RoutesConfig, ...)` method; handlers are
|
||||||
|
annotated with `@OpenApi` so they appear in the generated OpenAPI document. Request/response shapes
|
||||||
|
live in the `http.server.dto` package.
|
||||||
|
|
||||||
|
| Class | Endpoints |
|
||||||
|
|---|---|
|
||||||
|
| `WebApi` | `GET /` — HTML liveness message. |
|
||||||
|
| `GuildRoutes` | `GET /guilds` (optional `user_id` query restricts to mutual guilds); `GET /guilds/owner`; `GET /guilds/{guildId}/owner`; `/channels`; `/roles`; `/members/{userId}`, `/members/{userId}/access`, `/members/{userId}/roles`, `/members/{userId}/admin`; `/voice-channels`, `/voice-channels/{channelId}`; `GET /guilds/{guildId}/config`; `PUT /guilds/{guildId}/debug`, `/logging`, `/log-channel`. |
|
||||||
|
| `ActionRoutes` | `GET /actions/status` — JDA health + gateway ping; `POST /actions/shutdown` — graceful shutdown. |
|
||||||
|
| `SettingsRoutes` | `GET /settings/catalog` — every known setting with default and description; `GET /settings/current/{guildId}` — current guild settings; `PUT /settings/current/{guildId}/{name}` — update a setting; `PUT /settings/reload/{guildId}` — invalidate & reload the settings cache. |
|
||||||
|
| `StatsRoutes` | `GET /stats/bot`; `GET /stats/guilds/{guildId}`. |
|
||||||
|
| `MusicRoutes` | See below. |
|
||||||
|
|
||||||
|
### Music endpoints
|
||||||
|
|
||||||
|
Mirror the slash commands (see [music.md](music.md)); announcements are also posted to the guild's
|
||||||
|
music channel when a context is attached.
|
||||||
|
|
||||||
|
| Method & path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /music/sources` | Supported music sources. |
|
||||||
|
| `GET /music/{guildId}/status` | Connection, playback, current track, position, queue size. |
|
||||||
|
| `GET /music/{guildId}/queue` | Queued tracks. |
|
||||||
|
| `POST /music/{guildId}/search` | Resolve a query (optional `searchEngine`) into candidates; returns a `search_id`. Nothing is queued. |
|
||||||
|
| `POST /music/{guildId}/queue` | Queue a candidate by `searchId` + `index` (or all with `queueAll=true`) for `userId`; the bot must already be in a voice channel. |
|
||||||
|
| `DELETE /music/{guildId}/queue` | Clear the queue, or remove one track with `?position=` (1-based). |
|
||||||
|
| `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). |
|
||||||
|
| `POST /music/{guildId}/skip`, `/stop`, `/pause`, `/resume`, `/shuffle`, `/leave` | Playback controls. |
|
||||||
|
| `GET` / `PUT /music/{guildId}/volume` | Read / set volume (0–100). |
|
||||||
|
| `GET` / `PUT /music/{guildId}/autoplay` | Read / set autoplay (`{"enabled": true}`). In-memory, resets when the player is destroyed. |
|
||||||
|
| `PUT /music/{guildId}/seek` | Seek to `positionMs`. |
|
||||||
|
| `GET /music/{guildId}/vote-skip` | Vote-skip status. |
|
||||||
|
| `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. |
|
||||||
|
| `GET /music/{guildId}/dj/{userId}` | Whether the member is currently allowed to act as DJ. |
|
||||||
|
|
||||||
|
Search results are held in `PendingSearchResults` and expire after a TTL (returned as
|
||||||
|
`search_id_ttl_seconds`).
|
||||||
|
|
||||||
## HTTP client — `WebModule`
|
## HTTP client — `WebModule`
|
||||||
|
|
||||||
`WebModule` wraps an `OkHttpClient` for the bot's own outbound HTTP calls (e.g. checking for updates
|
`WebModule` (package `me.bobert.netneutrality.http`) wraps an `OkHttpClient` for the bot's own
|
||||||
via GitLab). It keeps a small `LinkedHashMap`-based response cache limited to 50 entries.
|
outbound HTTP calls (e.g. checking for updates via GitLab). Responses are cached in
|
||||||
|
`ResponseCache`, a `LinkedHashMap`-based LRU limited to 50 entries.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- There is no authentication layer documented on these endpoints; the API is expected to be bound to a
|
- All routes except the public ones require the API key. Even so, the API is expected to be bound to
|
||||||
trusted/internal network (e.g. within the Docker network). `/actions/shutdown` is destructive, so the
|
a trusted/internal network (e.g. within the Docker network); `/actions/shutdown` is destructive,
|
||||||
port should not be exposed publicly.
|
so the port should not be exposed publicly.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
server:
|
||||||
|
port: 2333
|
||||||
|
address: 0.0.0.0
|
||||||
|
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
# Replacement for the in-process dev.lavalink.youtube:v2 source manager.
|
||||||
|
# Custom SABR-enabled fork (ashton045/youtube-source, feat/sabr-support) built by JitPack.
|
||||||
|
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:33d50abd5f"
|
||||||
|
repository: "https://jitpack.io"
|
||||||
|
snapshot: false
|
||||||
|
server:
|
||||||
|
password: "${SERVER_PASSWORD:youshallnotpass}"
|
||||||
|
sources:
|
||||||
|
# Handled by the youtube-plugin below, must stay disabled here.
|
||||||
|
youtube: false
|
||||||
|
soundcloud: true
|
||||||
|
bandcamp: false
|
||||||
|
twitch: false
|
||||||
|
vimeo: false
|
||||||
|
nico: false
|
||||||
|
http: true
|
||||||
|
local: false
|
||||||
|
filters:
|
||||||
|
volume: true
|
||||||
|
bufferDurationMs: 400
|
||||||
|
frameBufferDurationMs: 5000
|
||||||
|
opusEncodingQuality: 10
|
||||||
|
resamplingQuality: LOW
|
||||||
|
trackStuckThresholdMs: 10000
|
||||||
|
useSeekGhosting: true
|
||||||
|
youtubePlaylistLoadLimit: 6
|
||||||
|
playerUpdateInterval: 5
|
||||||
|
youtubeSearchEnabled: true
|
||||||
|
soundcloudSearchEnabled: true
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
youtube:
|
||||||
|
enabled: true
|
||||||
|
allowSearch: true
|
||||||
|
allowDirectVideoIds: true
|
||||||
|
allowDirectPlaylistIds: true
|
||||||
|
clients:
|
||||||
|
- TV
|
||||||
|
- MWEB
|
||||||
|
- WEB
|
||||||
|
- MUSIC
|
||||||
|
- WEBEMBEDDED
|
||||||
|
- ANDROID_VR
|
||||||
|
- TVHTML5_SIMPLY
|
||||||
|
oauth:
|
||||||
|
enabled: true
|
||||||
|
refreshToken: "${NETNEUTRALITY_OAUTH_TOKEN}"
|
||||||
|
remoteCipher:
|
||||||
|
url: "${NETNEUTRALITY_CIPHER_URL:http://ejs-api:8001}"
|
||||||
|
password: "${NETNEUTRALITY_CIPHER_PASSWORD:}"
|
||||||
|
remotePot:
|
||||||
|
url: "${NETNEUTRALITY_POT_URL:http://webpo:8080}"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
lavalink: DEBUG
|
||||||
|
# Set to INFO to see the OAuth flow instructions (device code / verification URL) in the terminal.
|
||||||
|
dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
@@ -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>
|
||||||
@@ -62,9 +87,12 @@
|
|||||||
<artifactId>maven-jar-plugin</artifactId>
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
<version>3.5.0</version>
|
<version>3.5.0</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
|
<finalName>netneutrality</finalName>
|
||||||
<archive>
|
<archive>
|
||||||
<manifest>
|
<manifest>
|
||||||
<mainClass>${main.class}</mainClass>
|
<mainClass>${main.class}</mainClass>
|
||||||
|
<addClasspath>true</addClasspath>
|
||||||
|
<classpathPrefix>lib/</classpathPrefix>
|
||||||
</manifest>
|
</manifest>
|
||||||
</archive>
|
</archive>
|
||||||
</configuration>
|
</configuration>
|
||||||
@@ -72,28 +100,18 @@
|
|||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-shade-plugin</artifactId>
|
<artifactId>maven-dependency-plugin</artifactId>
|
||||||
<version>3.6.2</version>
|
<version>3.8.1</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<phase>package</phase>
|
<id>copy-libs</id>
|
||||||
|
<phase>prepare-package</phase>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>shade</goal>
|
<goal>copy-dependencies</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<finalName>netneutrality</finalName>
|
<outputDirectory>${project.build.directory}/lib</outputDirectory>
|
||||||
<transformers>
|
<includeScope>runtime</includeScope>
|
||||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
|
||||||
<mainClass>${main.class}</mainClass>
|
|
||||||
</transformer>
|
|
||||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
|
||||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
|
||||||
<manifestEntries>
|
|
||||||
<Multi-Release>true</Multi-Release>
|
|
||||||
</manifestEntries>
|
|
||||||
</transformer>
|
|
||||||
</transformers>
|
|
||||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
@@ -146,35 +164,11 @@
|
|||||||
<artifactId>JDA</artifactId>
|
<artifactId>JDA</artifactId>
|
||||||
<version>6.4.1</version>
|
<version>6.4.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Lavalink v4 client; audio decoding happens on the remote node. -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.arbjerg</groupId>
|
<groupId>dev.arbjerg</groupId>
|
||||||
<artifactId>lavaplayer</artifactId>
|
<artifactId>lavalink-client</artifactId>
|
||||||
<version>2.2.7</version>
|
<version>3.4.0</version>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.lavalink.youtube</groupId>
|
|
||||||
<artifactId>v2</artifactId>
|
|
||||||
<version>1.18.2</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-api</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-native-linux-x86-64</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-native-win-x86-64</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-native-darwin</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -183,6 +177,34 @@
|
|||||||
<version>5.3.2</version>
|
<version>5.3.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.javalin</groupId>
|
||||||
|
<artifactId>javalin</artifactId>
|
||||||
|
<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>
|
||||||
<groupId>org.hibernate.orm</groupId>
|
<groupId>org.hibernate.orm</groupId>
|
||||||
<artifactId>hibernate-core</artifactId>
|
<artifactId>hibernate-core</artifactId>
|
||||||
@@ -193,6 +215,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>
|
||||||
@@ -215,11 +242,6 @@
|
|||||||
<artifactId>sentry</artifactId>
|
<artifactId>sentry</artifactId>
|
||||||
<version>8.41.0</version>
|
<version>8.41.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.gitlab4j</groupId>
|
|
||||||
<artifactId>gitlab4j-api</artifactId>
|
|
||||||
<version>6.2.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>net.fellbaum</groupId>
|
<groupId>net.fellbaum</groupId>
|
||||||
@@ -228,14 +250,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>
|
||||||
@@ -289,6 +306,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>
|
||||||
@@ -6,7 +6,6 @@ package me.bobert.netneutrality;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
|
|
||||||
import io.sentry.Sentry;
|
import io.sentry.Sentry;
|
||||||
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
||||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||||
@@ -14,6 +13,9 @@ import me.bobert.netneutrality.bootstrap.BotState;
|
|||||||
import me.bobert.netneutrality.core.LogHandler;
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.command.CommandManager;
|
import me.bobert.netneutrality.core.command.CommandManager;
|
||||||
import me.bobert.netneutrality.db.SessionManager;
|
import me.bobert.netneutrality.db.SessionManager;
|
||||||
|
import me.bobert.netneutrality.events.EventPublisher;
|
||||||
|
import me.bobert.netneutrality.events.NoopEventPublisher;
|
||||||
|
import me.bobert.netneutrality.events.WebhookDispatcher;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
|
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
|
||||||
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
|
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
|
||||||
import me.bobert.netneutrality.core.thread.ThreadManager;
|
import me.bobert.netneutrality.core.thread.ThreadManager;
|
||||||
@@ -21,22 +23,31 @@ import me.bobert.netneutrality.db.service.GuildServiceProviderImpl;
|
|||||||
import me.bobert.netneutrality.music.MusicService;
|
import me.bobert.netneutrality.music.MusicService;
|
||||||
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.hooks.EventListenerHandler;
|
||||||
import me.bobert.netneutrality.hooks.EventsHandler;
|
import me.bobert.netneutrality.hooks.EventsHandler;
|
||||||
|
import me.bobert.netneutrality.hooks.GuildLifecycleHandler;
|
||||||
|
import me.bobert.netneutrality.hooks.InteractionHandlers;
|
||||||
|
import me.bobert.netneutrality.hooks.MessageReceivedHandler;
|
||||||
|
import me.bobert.netneutrality.hooks.ReadyHandler;
|
||||||
|
import me.bobert.netneutrality.hooks.VoiceEventPublisher;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import me.bobert.netneutrality.http.server.WebApi;
|
import me.bobert.netneutrality.http.server.WebApi;
|
||||||
import me.bobert.netneutrality.music.MusicServiceImpl;
|
import me.bobert.netneutrality.music.MusicServiceImpl;
|
||||||
import me.bobert.netneutrality.core.thread.ExecutorProvider;
|
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 me.bobert.netneutrality.shard.ShardService;
|
||||||
import net.dv8tion.jda.api.JDABuilder;
|
|
||||||
import net.dv8tion.jda.api.audio.AudioModuleConfig;
|
|
||||||
import net.dv8tion.jda.api.entities.Activity;
|
import net.dv8tion.jda.api.entities.Activity;
|
||||||
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
|
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
|
||||||
import org.apache.logging.log4j.Level;
|
import org.apache.logging.log4j.Level;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
@@ -46,7 +57,7 @@ public class App {
|
|||||||
private static final Logger logger = LogManager.getLogger(App.class);
|
private static final Logger logger = LogManager.getLogger(App.class);
|
||||||
private static App instance;
|
private static App instance;
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private JDA jda;
|
private EventPublisher events = NoopEventPublisher.INSTANCE;
|
||||||
private final ApplicationContext context = new ApplicationContext();
|
private final ApplicationContext context = new ApplicationContext();
|
||||||
|
|
||||||
static void main() {
|
static void main() {
|
||||||
@@ -71,8 +82,7 @@ public class App {
|
|||||||
|
|
||||||
if ("dev".equals(env)) {
|
if ("dev".equals(env)) {
|
||||||
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
|
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
|
||||||
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
|
Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG);
|
||||||
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instance = new App(settings);
|
instance = new App(settings);
|
||||||
@@ -95,7 +105,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));
|
||||||
@@ -113,9 +125,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());
|
||||||
@@ -129,29 +143,42 @@ 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.
|
// Webhook event dispatch (priority 50); stays a no-op publisher when no URL is configured.
|
||||||
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks));
|
String webhookUrl = settings.get("NETNEUTRALITY_WEBHOOK_URL");
|
||||||
|
if (webhookUrl != null && !webhookUrl.isBlank()) {
|
||||||
|
events = context.register(new WebhookDispatcher(webhookUrl, settings.get("NETNEUTRALITY_WEBHOOK_SECRET")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Music (priority 62, above the shards so it stops first): disconnect players while JDA is still up.
|
||||||
|
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors, events));
|
||||||
BotState.bind(guildServices, music);
|
BotState.bind(guildServices, music);
|
||||||
|
|
||||||
// JDA gateway; disconnected on shutdown.
|
// JDA shards (priority 60); the events handler is attached to every shard before it connects.
|
||||||
context.register(lifecycle(60, this::initJDA, this::disconnect));
|
List<EventListenerHandler<?>> handlers = new ArrayList<>();
|
||||||
|
handlers.add(new MessageReceivedHandler(settings, guildServices));
|
||||||
|
handlers.addAll(InteractionHandlers.create(settings));
|
||||||
|
handlers.add(new ReadyHandler(commands));
|
||||||
|
handlers.addAll(new GuildLifecycleHandler(guildServices).handlers());
|
||||||
|
handlers.add(new VoiceEventPublisher(events));
|
||||||
|
ShardService shards = context.register(new ShardService(
|
||||||
|
settings, music, new EventsHandler(handlers, commands, tasks)));
|
||||||
|
|
||||||
// Register slash commands, attach the event handler and set the presence once JDA is ready.
|
// Slash commands are registered from EventsHandler on ReadyEvent; set the presence here.
|
||||||
context.register(lifecycle(65, () -> {
|
context.register(lifecycle(65, () -> {
|
||||||
commands.registerSlashCommands(jda);
|
shards.setActivity(Activity.watching("you"));
|
||||||
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
|
|
||||||
jda.getPresence().setActivity(Activity.watching("you"));
|
|
||||||
}, () -> { }));
|
}, () -> { }));
|
||||||
|
|
||||||
// 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, shards, this::shutdown));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
|
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
|
||||||
@@ -182,48 +209,10 @@ public class App {
|
|||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void initJDA() {
|
|
||||||
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
|
|
||||||
if (token == null) {
|
|
||||||
SessionManager sessions = context.get(SessionManager.class);
|
|
||||||
if (sessions != null) {
|
|
||||||
sessions.stop();
|
|
||||||
}
|
|
||||||
throw new RuntimeException("No bot token provided, exiting.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
jda = JDABuilder
|
|
||||||
.createDefault(token)
|
|
||||||
.enableIntents(
|
|
||||||
GatewayIntent.GUILD_MEMBERS,
|
|
||||||
GatewayIntent.GUILD_MESSAGE_REACTIONS,
|
|
||||||
GatewayIntent.GUILD_VOICE_STATES,
|
|
||||||
GatewayIntent.MESSAGE_CONTENT)
|
|
||||||
.setAudioModuleConfig(
|
|
||||||
new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
|
|
||||||
.setActivity(Activity.watching("me load..."))
|
|
||||||
.build();
|
|
||||||
jda.awaitReady();
|
|
||||||
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
|
|
||||||
LogHandler.log(e);
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void shutdown() {
|
public void shutdown() {
|
||||||
logger.info("[Net Neutrality] Shutting down...");
|
logger.info("[Net Neutrality] Shutting down...");
|
||||||
context.stop();
|
context.stop();
|
||||||
logger.info("[Net Neutrality] Shutdown complete");
|
logger.info("[Net Neutrality] Shutdown complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
public JDA getJDA() {
|
|
||||||
return jda;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void disconnect() {
|
|
||||||
if (jda != null) {
|
|
||||||
jda.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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,69 +78,186 @@ 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) {
|
||||||
|
case "music-role" -> configureMusicRole(context, settingService);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configureIntSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String successFormat) {
|
||||||
|
int value = context.getOption(optionName, Integer.parseInt(setting.getDefaultValue()), CommandOption::getAsInt);
|
||||||
|
settingService.set(setting, String.valueOf(value));
|
||||||
|
|
||||||
|
return CommandExecutionResult.success(successFormat, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult configureStringSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String clearedMessage, String successFormat) {
|
||||||
|
String value = context.getOption(optionName, "", CommandOption::getAsString);
|
||||||
|
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
settingService.set(setting, null);
|
||||||
|
return CommandExecutionResult.success(clearedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
settingService.set(setting, value);
|
||||||
|
return CommandExecutionResult.success(successFormat, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandExecutionResult toggleDisabledCommand(CommandContext context, GuildSettingService settingService, boolean disable) {
|
||||||
|
String name = context.getOption("name", "", CommandOption::getAsString).trim();
|
||||||
|
|
||||||
|
Set<String> disabledCommands = Arrays.stream(settingService.getOrDefault(GSettings.DISABLED_COMMANDS).split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(value -> !value.isEmpty())
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
|
||||||
|
if (disable) {
|
||||||
|
if (disabledCommands.stream().noneMatch(value -> value.equalsIgnoreCase(name))) {
|
||||||
|
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);
|
Role role = context.getOption("role", CommandOption::getAsRole);
|
||||||
assert role != null;
|
assert role != null;
|
||||||
|
|
||||||
if (role.getPosition() == -1) {
|
if (role.getPosition() == -1) {
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
|
settingService.set(GSettings.MUSIC_ROLE, null);
|
||||||
|
|
||||||
return CommandExecutionResult.success("Everyone can now use music commands");
|
return CommandExecutionResult.success("Everyone can now use music commands");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role.getGuild().getId().equals(context.getGuild().getId())) {
|
if (role.getGuild().getId().equals(context.getGuild().getId())) {
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, role.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.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return CommandExecutionResult.error("Unknown subcommand: music-role");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("music-channel".equals(sub)) {
|
private CommandExecutionResult configureMusicChannel(CommandContext context, GuildSettingService settingService) {
|
||||||
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
|
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
|
||||||
|
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, null);
|
settingService.set(GSettings.MUSIC_CHANNEL, null);
|
||||||
|
|
||||||
return CommandExecutionResult.success("No channel restrictions for music commands");
|
return CommandExecutionResult.success("No channel restrictions for music commands");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel instanceof TextChannel) {
|
if (channel instanceof TextChannel && channel.getGuild().getId().equals(context.getGuild().getId())) {
|
||||||
if (channel.getGuild().getId().equals(context.getGuild().getId())) {
|
settingService.set(GSettings.MUSIC_CHANNEL, channel.getId());
|
||||||
guildService.getGuildSettingService().setSettingValue(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.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) {
|
||||||
|
|
||||||
if ("music-volume".equals(sub)) {
|
|
||||||
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
|
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
|
||||||
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume));
|
settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume));
|
||||||
musicService.get(context.getGuild()).setVolume(volume);
|
musicService.get(context.getGuild()).setVolume(volume);
|
||||||
|
|
||||||
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
|
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("error-logging".equals(sub)) {
|
private CommandExecutionResult configureErrorLogging(CommandContext context, GuildService guildService) {
|
||||||
boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
|
boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
|
||||||
guildService.updateDebugMode(debug);
|
guildService.updateDebugMode(debug);
|
||||||
|
|
||||||
return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
|
return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("show-all".equals(sub)) {
|
private CommandExecutionResult showAll(CommandContext context, GuildSettingService settingService) {
|
||||||
Map<String, String> settings = guildService.getGuildSettingService().getSettings();
|
Map<String, String> settings = settingService.snapshot();
|
||||||
MessageCreateBuilder builder = new MessageCreateBuilder();
|
MessageCreateBuilder builder = new MessageCreateBuilder();
|
||||||
|
|
||||||
for (GSettings o : GSettings.values()) {
|
for (GSettings o : GSettings.values()) {
|
||||||
@@ -142,13 +267,12 @@ public class ConfigureCommand extends AdminCommand {
|
|||||||
value = settings.get(o.getName());
|
value = settings.get(o.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.addContent(o.getName()).addContent(": ").addContent(value);
|
builder.addContent(o.getName()).addContent(" (").addContent(o.getDescription()).addContent("): ").addContent(value);
|
||||||
builder.addContent("\n");
|
builder.addContent("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
context.replyEphemeral(builder.build());
|
context.replyEphemeral(builder.build());
|
||||||
}
|
|
||||||
|
|
||||||
return CommandExecutionResult.error("Unknown subcommand: " + sub);
|
return CommandExecutionResult.success("Displayed current configuration");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
|||||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||||
import me.bobert.netneutrality.core.command.impl.AbstractCommand;
|
import me.bobert.netneutrality.core.command.impl.AbstractCommand;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.http.WebModule;
|
import me.bobert.netneutrality.utils.GiteaClient;
|
||||||
import me.bobert.netneutrality.tasks.git.ReportIssueTask;
|
import me.bobert.netneutrality.tasks.git.ReportIssueTask;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
|
|
||||||
@@ -72,15 +71,16 @@ public class ReportIssueCommand extends AbstractCommand {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandExecutionResult execute(CommandContext context) {
|
public CommandExecutionResult execute(CommandContext context) {
|
||||||
String gitlabHost = settings.get("GITLAB_HOST");
|
String host = settings.get("GITEA_HOST");
|
||||||
String gitlabToken = settings.get("GITLAB_TOKEN");
|
String token = settings.get("GITEA_TOKEN");
|
||||||
String projectId = settings.get("GITLAB_PROJECT_ID");
|
String owner = settings.get("GITEA_OWNER");
|
||||||
|
String repo = settings.get("GITEA_REPO");
|
||||||
|
|
||||||
if (gitlabHost == null || gitlabToken == null || projectId == null) {
|
if (host == null || token == null || owner == null || repo == null) {
|
||||||
return CommandExecutionResult.error("Command is not configured, failed to create issue.");
|
return CommandExecutionResult.error("Command is not configured, failed to create issue.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) {
|
if (new GiteaClient(host, token, owner, repo).ping()) {
|
||||||
return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings));
|
return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package me.bobert.netneutrality.command.music;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
|
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||||
|
import me.bobert.netneutrality.core.command.impl.MusicCommand;
|
||||||
|
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||||
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
|
import me.bobert.netneutrality.music.MusicService;
|
||||||
|
import me.bobert.netneutrality.music.TrackScheduler;
|
||||||
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
|
import me.bobert.netneutrality.utils.UserFunction;
|
||||||
|
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||||
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
|
|
||||||
|
public class AutoplayCommand extends MusicCommand {
|
||||||
|
|
||||||
|
private final MusicService musicService;
|
||||||
|
|
||||||
|
public AutoplayCommand() {
|
||||||
|
this(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutoplayCommand(MusicService musicService) {
|
||||||
|
this(musicService, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutoplayCommand(MusicService musicService, GuildServiceProvider guildServices) {
|
||||||
|
super(guildServices);
|
||||||
|
this.musicService = musicService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCommandName() {
|
||||||
|
return "autoplay";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDescription() {
|
||||||
|
return "Toggles autoplay, which keeps the music going with similar tracks when the queue ends";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerCommandOptions(CommandOptionsBuilder builder) {
|
||||||
|
builder.addOptions(new OptionData(OptionType.BOOLEAN, "enabled", "Turn autoplay on or off; omit to toggle", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommandExecutionResult execute(CommandContext context) {
|
||||||
|
MusicManager manager = musicService.get(context.getGuild());
|
||||||
|
Boolean requested = context.getOption("enabled", CommandOption::getAsBoolean);
|
||||||
|
|
||||||
|
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||||
|
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
|
||||||
|
TrackScheduler scheduler = manager.getScheduler();
|
||||||
|
boolean enabled = requested != null ? requested : !scheduler.isAutoplayEnabled();
|
||||||
|
scheduler.setAutoplayEnabled(enabled);
|
||||||
|
return CommandExecutionResult.success("Autoplay is now " + (enabled ? "enabled" : "disabled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommandExecutionResult.none();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
+161
-140
@@ -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,73 +78,48 @@ 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) {
|
||||||
|
if (data.isRequired()) {
|
||||||
|
invalidateContext(data, "missing required argument at index " + position);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case STRING -> {
|
case STRING -> {
|
||||||
int currentOptionIndex = commandOptions.indexOf(data);
|
String str;
|
||||||
boolean nextOptionIsString = false;
|
if (i + 1 < commandOptions.size() && commandOptions.get(i + 1).getType() == OptionType.STRING) {
|
||||||
if (currentOptionIndex + 1 < commandOptions.size()) {
|
// The next option is also a string, so only the current argument belongs to this one
|
||||||
OptionData nextOption = commandOptions.get(currentOptionIndex + 1);
|
str = args[index++];
|
||||||
nextOptionIsString = nextOption.getType() == OptionType.STRING;
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder strBuilder = new StringBuilder();
|
|
||||||
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 {
|
} else {
|
||||||
// Next option is also a string, so just take the current argument
|
StringJoiner joiner = new StringJoiner(" ");
|
||||||
if (index < args.length) {
|
while (index < args.length && !stopsTextCapture(args[index], commandOptions, i)) {
|
||||||
strBuilder.append(args[index]);
|
joiner.add(args[index++]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
str = joiner.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str.isEmpty()) {
|
||||||
|
if (data.isRequired()) {
|
||||||
|
invalidateContext(data, "missing required argument at index " + position);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strBuilder.isEmpty()) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
String str = strBuilder.toString();
|
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsString().equalsIgnoreCase(str))) {
|
||||||
|
|
||||||
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));
|
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
||||||
}
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer min = data.getMinLength();
|
Integer min = data.getMinLength();
|
||||||
@@ -143,165 +127,202 @@ public class MessageCommandContext extends CommandContext implements MessageCont
|
|||||||
|
|
||||||
if (min != null && str.length() < min) {
|
if (min != null && str.length() < min) {
|
||||||
invalidateContext(data, "minimum length for argument is " + min);
|
invalidateContext(data, "minimum length for argument is " + min);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (max != null && str.length() > max) {
|
if (max != null && str.length() > max) {
|
||||||
invalidateContext(data, "maximum length for argument is " + max);
|
invalidateContext(data, "maximum length for argument is " + max);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
value = str;
|
value = str;
|
||||||
}
|
}
|
||||||
case INTEGER -> {
|
case INTEGER -> {
|
||||||
long num = Long.parseLong(args[index]);
|
String str = args[index++];
|
||||||
if (!choices.isEmpty()) {
|
if (!INTEGER_PATTERN.matcher(str).matches()) {
|
||||||
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsLong() == num).toList();
|
invalidateContext(data, "the argument provided is not a number.");
|
||||||
if (matched.isEmpty()) {
|
break;
|
||||||
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
value = num;
|
|
||||||
|
|
||||||
Number max = data.getMaxValue();
|
|
||||||
Number min = data.getMinValue();
|
Number min = data.getMinValue();
|
||||||
|
Number max = data.getMaxValue();
|
||||||
|
|
||||||
if (min != null && num < min.longValue()) {
|
if (min != null && num < min.longValue()) {
|
||||||
value = min.longValue();
|
invalidateContext(data, "minimum value for argument is " + min.longValue());
|
||||||
} else if (max != null && num > max.longValue()) {
|
|
||||||
value = max.longValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case BOOLEAN -> {
|
|
||||||
String str = args[index];
|
|
||||||
if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("false")) {
|
|
||||||
value = Boolean.parseBoolean(str);
|
|
||||||
break;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = Boolean.parseBoolean(str);
|
||||||
}
|
}
|
||||||
case NUMBER -> {
|
case NUMBER -> {
|
||||||
double num = Double.parseDouble(args[index]);
|
String str = args[index++];
|
||||||
if (!choices.isEmpty()) {
|
if (!NUMBER_PATTERN.matcher(str).matches()) {
|
||||||
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsDouble() == num).toList();
|
invalidateContext(data, "the argument provided is not a number.");
|
||||||
if (matched.isEmpty()) {
|
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, "not a valid choice, valid choices are: " + validChoices(choices));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Number max = data.getMaxValue();
|
|
||||||
Number min = data.getMinValue();
|
Number min = data.getMinValue();
|
||||||
value = num;
|
Number max = data.getMaxValue();
|
||||||
|
|
||||||
if (min != null && num < min.doubleValue()) {
|
if (min != null && num < min.doubleValue()) {
|
||||||
value = min.doubleValue();
|
invalidateContext(data, "minimum value for argument is " + min.doubleValue());
|
||||||
} else if (max != null && num > max.doubleValue()) {
|
break;
|
||||||
value = max.doubleValue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (max != null && num > max.doubleValue()) {
|
||||||
|
invalidateContext(data, "maximum value for argument is " + max.doubleValue());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = num;
|
||||||
}
|
}
|
||||||
case ROLE -> {
|
case ROLE -> {
|
||||||
if (mentions.getRoles().size() > roleMentionIndex) {
|
index++;
|
||||||
Role role = mentions.getRoles().get(roleMentionIndex++);
|
Role role = mentions.getRoles().size() > roleMentionIndex ? mentions.getRoles().get(roleMentionIndex++) : null;
|
||||||
if (role.getGuild().getId().equals(getGuild().getId())) {
|
if (role == null || !isFromCurrentGuild(role.getGuild())) {
|
||||||
value = role;
|
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid role.");
|
value = role;
|
||||||
}
|
}
|
||||||
case USER -> {
|
case USER -> {
|
||||||
if (mentions.getMembers().size() > memberMentionIndex) {
|
index++;
|
||||||
Member member = mentions.getMembers().get(memberMentionIndex++);
|
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
|
||||||
if (member.getGuild().getId().equals(getGuild().getId())) {
|
if (value instanceof Member) {
|
||||||
value = member;
|
memberMentionIndex++;
|
||||||
break;
|
} else if (value != null) {
|
||||||
}
|
userMentionIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mentions.getUsers().size() > userMentionIndex) {
|
if (value == null) {
|
||||||
value = mentions.getUsers().get(userMentionIndex++);
|
invalidateContext(data, "provided argument at position " + position + " is not a valid user.");
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user.");
|
|
||||||
}
|
}
|
||||||
case MENTIONABLE -> {
|
case MENTIONABLE -> {
|
||||||
|
index++;
|
||||||
if (mentions.getRoles().size() > roleMentionIndex) {
|
if (mentions.getRoles().size() > roleMentionIndex) {
|
||||||
Role role = mentions.getRoles().get(roleMentionIndex++);
|
Role role = mentions.getRoles().get(roleMentionIndex++);
|
||||||
if (role.getGuild().getId().equals(getGuild().getId())) {
|
if (!isFromCurrentGuild(role.getGuild())) {
|
||||||
|
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
value = role;
|
value = role;
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mentions.getMembers().size() > memberMentionIndex) {
|
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
|
||||||
Member member = mentions.getMembers().get(memberMentionIndex++);
|
if (value instanceof Member) {
|
||||||
if (member.getGuild().getId().equals(getGuild().getId())) {
|
memberMentionIndex++;
|
||||||
value = member;
|
} else if (value != null) {
|
||||||
|
userMentionIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
if (value == null) {
|
||||||
|
invalidateContext(data, "provided argument at position " + position + " is not a valid user or role.");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 -> {
|
case CHANNEL -> {
|
||||||
if (mentions.getChannels().size() > channelMentionIndex) {
|
index++;
|
||||||
GuildChannel channel = mentions.getChannels().get(channelMentionIndex++);
|
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())) {
|
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) {
|
||||||
invalidateContext(data, "not a valid channel type at index " + (index + 1) +
|
invalidateContext(data, "not a valid channel type at index " + position +
|
||||||
" , valid types are: " + validTypes(requiredChannelTypes));
|
" , valid types are: " + validTypes(requiredChannelTypes));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.getGuild().getId().equals(getGuild().getId())) {
|
|
||||||
value = channel;
|
value = channel;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
default -> index++;
|
||||||
|
|
||||||
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid channel.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IndexOutOfBoundsException e) {
|
|
||||||
if (options.size() < required) {
|
|
||||||
invalidateContext(data, "missing required argument at index " + (index + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.isRequired() && index < required) {
|
|
||||||
// This should never happen because required options are validated before calling parseOptions()
|
|
||||||
throw new IllegalArgumentException("Required option " + data.getName() + " was not found at index " + index);
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
invalidateContext(data, "the argument provided is not a number.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
|
|||||||
|
|
||||||
public interface Command {
|
public interface Command {
|
||||||
enum Type {
|
enum Type {
|
||||||
SLASH, USER, MESSAGE
|
CHAT, USER, MESSAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean needAdmin();
|
boolean needAdmin();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.command;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
|
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||||
@@ -13,25 +12,47 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
|
|||||||
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
|
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.StringJoiner;
|
|
||||||
|
|
||||||
public class CommandContainer {
|
public class CommandContainer {
|
||||||
public static final CommandContainer NULL_COMMAND = new CommandContainer(null);
|
public static final CommandContainer NULL_COMMAND = new CommandContainer(null);
|
||||||
|
private static final CommandUsageFormatter USAGE_FORMATTER = new CommandUsageFormatter();
|
||||||
private final Command command;
|
private final Command command;
|
||||||
private CommandDataImpl commandData;
|
private final CommandDataImpl commandData;
|
||||||
private List<OptionData> options;
|
private final List<OptionData> options;
|
||||||
private List<SubcommandData> subcommands;
|
private final List<SubcommandData> subcommands;
|
||||||
private List<SubcommandGroupData> subcommandGroups;
|
private final List<SubcommandGroupData> subcommandGroups;
|
||||||
|
|
||||||
public CommandContainer(Command command) {
|
public CommandContainer(Command command) {
|
||||||
this.command = command;
|
this(command, build(command));
|
||||||
|
|
||||||
if (command != null) {
|
|
||||||
CommandOptionsBuilder commandOptionsBuilder = new CommandOptionsBuilder(command.getType(), command);
|
|
||||||
command.registerCommandOptions(commandOptionsBuilder);
|
|
||||||
initContainer(commandOptionsBuilder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CommandContainer(Command command, CommandDataImpl commandData) {
|
||||||
|
this.command = command;
|
||||||
|
this.commandData = commandData;
|
||||||
|
|
||||||
|
if (commandData != null) {
|
||||||
|
this.options = commandData.getOptions();
|
||||||
|
this.subcommands = commandData.getSubcommands();
|
||||||
|
this.subcommandGroups = commandData.getSubcommandGroups();
|
||||||
|
} else {
|
||||||
|
this.options = List.of();
|
||||||
|
this.subcommands = List.of();
|
||||||
|
this.subcommandGroups = List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CommandContainer of(Command command) {
|
||||||
|
return new CommandContainer(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CommandDataImpl build(Command command) {
|
||||||
|
if (command == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandOptionsBuilder builder = new CommandOptionsBuilder(command.getType(), command);
|
||||||
|
command.registerCommandOptions(builder);
|
||||||
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Command getCommand() {
|
public Command getCommand() {
|
||||||
@@ -54,129 +75,11 @@ public class CommandContainer {
|
|||||||
return subcommandGroups;
|
return subcommandGroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initContainer(CommandOptionsBuilder builder) {
|
|
||||||
this.commandData = builder.build();
|
|
||||||
this.options = commandData.getOptions();
|
|
||||||
this.subcommands = commandData.getSubcommands();
|
|
||||||
this.subcommandGroups = commandData.getSubcommandGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isEmpty() {
|
public boolean isEmpty() {
|
||||||
return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty();
|
return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String printUsage() {
|
public String printUsage() {
|
||||||
StringBuilder usage = new StringBuilder();
|
return USAGE_FORMATTER.format(this);
|
||||||
if (!options.isEmpty()) {
|
|
||||||
usage.append("- Usage: /").append(command.getCommandName());
|
|
||||||
for (OptionData option : options) {
|
|
||||||
usage.append(" <").append(option.getName()).append(">");
|
|
||||||
}
|
|
||||||
|
|
||||||
usage.append(printOptions(options, null, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subcommands.isEmpty()) {
|
|
||||||
for (SubcommandData subcommand : subcommands) {
|
|
||||||
usage.append("- Usage: /").append(command.getCommandName()).append(" ").append(subcommand.getName());
|
|
||||||
|
|
||||||
for (OptionData option : subcommand.getOptions()) {
|
|
||||||
usage.append(" <").append(option.getName()).append(">");
|
|
||||||
}
|
|
||||||
|
|
||||||
usage.append(" (").append(subcommand.getDescription()).append(")");
|
|
||||||
usage.append(printOptions(subcommand.getOptions(), null, false));
|
|
||||||
|
|
||||||
usage.append("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subcommandGroups.isEmpty()) {
|
|
||||||
for (SubcommandGroupData subcommandGroup : subcommandGroups) {
|
|
||||||
usage.append("- Subcommand: /").append(command.getCommandName())
|
|
||||||
.append(" ").append(subcommandGroup.getName())
|
|
||||||
.append(" (").append(subcommandGroup.getDescription()).append(")")
|
|
||||||
.append("\n");
|
|
||||||
|
|
||||||
for (SubcommandData subcommand : subcommandGroup.getSubcommands()) {
|
|
||||||
usage.append(" - Usage: /").append(command.getCommandName())
|
|
||||||
.append(" ").append(subcommandGroup.getName())
|
|
||||||
.append(" ").append(subcommand.getName());
|
|
||||||
|
|
||||||
for (OptionData option : subcommand.getOptions()) {
|
|
||||||
usage.append(" <").append(option.getName()).append(">");
|
|
||||||
}
|
|
||||||
|
|
||||||
usage.append(printOptions(subcommand.getOptions(), subcommand.getDescription(), true));
|
|
||||||
|
|
||||||
usage.append("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEmpty()) {
|
|
||||||
usage.append("\n\n-# *: required argument");
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String printOptions(List<OptionData> options, String description, boolean group) {
|
|
||||||
StringBuilder usage = new StringBuilder();
|
|
||||||
|
|
||||||
if (description != null) {
|
|
||||||
usage.append(" (").append(description).append(")");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (OptionData option : options) {
|
|
||||||
usage.append("\n");
|
|
||||||
if (group) {
|
|
||||||
usage.append(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
usage.append(" - ");
|
|
||||||
if (option.isRequired()) {
|
|
||||||
usage.append("*");
|
|
||||||
}
|
|
||||||
usage.append(option.getName()).append(": ").append(option.getDescription());
|
|
||||||
|
|
||||||
if (option.getMinValue() != null) {
|
|
||||||
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.getMaxValue() != null) {
|
|
||||||
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.getMinLength() != null) {
|
|
||||||
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.getMaxLength() != null) {
|
|
||||||
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
|
|
||||||
if (!choices.isEmpty()) {
|
|
||||||
StringJoiner joiner = new StringJoiner(", ");
|
|
||||||
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
|
|
||||||
joiner.add(choice.getAsString());
|
|
||||||
}
|
|
||||||
|
|
||||||
usage.append(" [Available choices: ").append(joiner).append("]");
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<ChannelType> requiredChannelTypes = option.getChannelTypes();
|
|
||||||
if (!requiredChannelTypes.isEmpty()) {
|
|
||||||
StringJoiner joiner = new StringJoiner(", ");
|
|
||||||
for (ChannelType type : requiredChannelTypes) {
|
|
||||||
joiner.add(type.name());
|
|
||||||
}
|
|
||||||
|
|
||||||
usage.append(" [Required channel types: ").append(joiner).append("]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage.toString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ public class CommandManager implements CommandRegistry {
|
|||||||
public void init() {
|
public void init() {
|
||||||
logger.info("Loading commands..");
|
logger.info("Loading commands..");
|
||||||
|
|
||||||
initCommands();
|
ReflectionHelper.loadCommands(Command.class, context, this::onCommandLoaded);
|
||||||
|
|
||||||
logger.info("Loaded {} commands!", commands.size());
|
logger.info("Loaded {} commands!", commands.size());
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,7 @@ public class CommandManager implements CommandRegistry {
|
|||||||
return commands;
|
return commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void initCommands() {
|
void onCommandLoaded(Command command) {
|
||||||
Consumer<Command> onLoaded = command -> {
|
|
||||||
if (!command.isEnabled()) {
|
if (!command.isEnabled()) {
|
||||||
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
|
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
|
||||||
return;
|
return;
|
||||||
@@ -96,7 +95,7 @@ public class CommandManager implements CommandRegistry {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.getDescription() == null && command.getType() == Command.Type.SLASH) {
|
if (command.getDescription() == null && command.getType() == Command.Type.CHAT) {
|
||||||
logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
|
logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,9 +104,6 @@ public class CommandManager implements CommandRegistry {
|
|||||||
if (!commands.containsKey(command.getCommandName())) {
|
if (!commands.containsKey(command.getCommandName())) {
|
||||||
commands.put(command.getCommandName(), container);
|
commands.put(command.getCommandName(), container);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class CommandOptionsBuilder implements SlashCommandData {
|
|||||||
|
|
||||||
public CommandOptionsBuilder(Command.Type type, Command command) {
|
public CommandOptionsBuilder(Command.Type type, Command command) {
|
||||||
|
|
||||||
if (type == Command.Type.SLASH) {
|
if (type == Command.Type.CHAT) {
|
||||||
commandData = (CommandDataImpl) Commands.slash(command.getCommandName(), command.getDescription());
|
commandData = (CommandDataImpl) Commands.slash(command.getCommandName(), command.getDescription());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
@@ -81,7 +83,7 @@ public class CommandProcessor {
|
|||||||
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
|
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
|
||||||
configureSentry(command);
|
configureSentry(command);
|
||||||
|
|
||||||
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) {
|
if (command.getType() == Command.Type.CHAT && !usageValidator.validate(context, container)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package me.bobert.netneutrality.core.command;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||||
|
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||||
|
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||||
|
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.StringJoiner;
|
||||||
|
|
||||||
|
final class CommandUsageFormatter {
|
||||||
|
private static final String REQUIRED_LEGEND = "\n\n-# *: required argument";
|
||||||
|
|
||||||
|
String format(CommandContainer container) {
|
||||||
|
if (container.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
String commandName = container.getCommand().getCommandName();
|
||||||
|
StringBuilder usage = new StringBuilder();
|
||||||
|
appendOptions(usage, commandName, container.getOptions());
|
||||||
|
appendSubcommands(usage, commandName, container.getSubcommands());
|
||||||
|
appendGroups(usage, commandName, container.getSubcommandGroups());
|
||||||
|
usage.append(REQUIRED_LEGEND);
|
||||||
|
|
||||||
|
return usage.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendOptions(StringBuilder usage, String commandName, List<OptionData> options) {
|
||||||
|
if (options.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.append("- Usage: /").append(commandName);
|
||||||
|
appendArgs(usage, options);
|
||||||
|
usage.append(describeOptions(options, null, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendSubcommands(StringBuilder usage, String commandName, List<SubcommandData> subcommands) {
|
||||||
|
for (SubcommandData subcommand : subcommands) {
|
||||||
|
usage.append("- Usage: /").append(commandName).append(" ").append(subcommand.getName());
|
||||||
|
appendArgs(usage, subcommand.getOptions());
|
||||||
|
usage.append(" (").append(subcommand.getDescription()).append(")");
|
||||||
|
usage.append(describeOptions(subcommand.getOptions(), null, false));
|
||||||
|
usage.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendGroups(StringBuilder usage, String commandName, List<SubcommandGroupData> groups) {
|
||||||
|
for (SubcommandGroupData group : groups) {
|
||||||
|
usage.append("- Subcommand: /").append(commandName)
|
||||||
|
.append(" ").append(group.getName())
|
||||||
|
.append(" (").append(group.getDescription()).append(")")
|
||||||
|
.append("\n");
|
||||||
|
|
||||||
|
for (SubcommandData subcommand : group.getSubcommands()) {
|
||||||
|
usage.append(" - Usage: /").append(commandName)
|
||||||
|
.append(" ").append(group.getName())
|
||||||
|
.append(" ").append(subcommand.getName());
|
||||||
|
appendArgs(usage, subcommand.getOptions());
|
||||||
|
usage.append(describeOptions(subcommand.getOptions(), subcommand.getDescription(), true));
|
||||||
|
usage.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendArgs(StringBuilder usage, List<OptionData> options) {
|
||||||
|
for (OptionData option : options) {
|
||||||
|
usage.append(" <").append(option.getName()).append(">");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String describeOptions(List<OptionData> options, String description, boolean group) {
|
||||||
|
StringBuilder usage = new StringBuilder();
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
usage.append(" (").append(description).append(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (OptionData option : options) {
|
||||||
|
usage.append("\n");
|
||||||
|
if (group) {
|
||||||
|
usage.append(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.append(" - ");
|
||||||
|
if (option.isRequired()) {
|
||||||
|
usage.append("*");
|
||||||
|
}
|
||||||
|
usage.append(option.getName()).append(": ").append(option.getDescription());
|
||||||
|
|
||||||
|
appendConstraints(usage, option);
|
||||||
|
appendChoices(usage, option);
|
||||||
|
appendChannelTypes(usage, option);
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendConstraints(StringBuilder usage, OptionData option) {
|
||||||
|
if (option.getMinValue() != null) {
|
||||||
|
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.getMaxValue() != null) {
|
||||||
|
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.getMinLength() != null) {
|
||||||
|
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.getMaxLength() != null) {
|
||||||
|
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendChoices(StringBuilder usage, OptionData option) {
|
||||||
|
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
|
||||||
|
if (choices.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringJoiner joiner = new StringJoiner(", ");
|
||||||
|
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
|
||||||
|
joiner.add(choice.getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.append(" [Available choices: ").append(joiner).append("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendChannelTypes(StringBuilder usage, OptionData option) {
|
||||||
|
Set<ChannelType> types = option.getChannelTypes();
|
||||||
|
if (types.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringJoiner joiner = new StringJoiner(", ");
|
||||||
|
for (ChannelType type : types) {
|
||||||
|
joiner.add(type.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.append(" [Required channel types: ").append(joiner).append("]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ public abstract class AbstractCommand implements Command {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Type getType() {
|
public Type getType() {
|
||||||
return Type.SLASH;
|
return Type.CHAT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ 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.MusicAccess;
|
||||||
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.channel.ChannelType;
|
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||||
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
|
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
|
||||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||||
@@ -43,7 +42,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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,26 +76,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Guild guild = member.getGuild();
|
return MusicAccess.canUse(member, guildService(member.getGuild()));
|
||||||
|
|
||||||
String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
|
|
||||||
|
|
||||||
if (roleDb != null && !roleDb.isEmpty()) {
|
|
||||||
if (UserFunction.isAdmin(member)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Role neededRole = guild.getRoleById(roleDb);
|
|
||||||
if (neededRole == null) {
|
|
||||||
// Role doesn't exist on guild anymore, remove from db
|
|
||||||
guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return member.getRoles().contains(neededRole);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -106,7 +86,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-15
@@ -7,13 +7,13 @@ 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 {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean check(CommandContext context, Command command) {
|
public boolean check(CommandContext context, Command command) {
|
||||||
if (command.getType() != Command.Type.SLASH) {
|
if (command.getType() != Command.Type.CHAT) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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");
|
||||||
}
|
|
||||||
|
|
||||||
if (command.canBeExecutedBy(context.getGuildUser())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.needAdmin() && UserFunction.isAdmin(context.getGuildUser())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.reply("You are not powerful enough, scrub");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.needAdmin() && !UserFunction.isAdmin(context.getGuildUser())) {
|
||||||
|
context.replyEphemeral("You are not powerful enough, scrub");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command.canBeExecutedBy(context.getGuildUser())) {
|
||||||
|
context.replyEphemeral("You are not powerful enough, scrub");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -23,13 +25,13 @@ import net.dv8tion.jda.api.interactions.InteractionHook;
|
|||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -41,25 +43,28 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
private static final Logger logger = LogManager.getLogger(TaskManagerImpl.class);
|
private static final Logger logger = LogManager.getLogger(TaskManagerImpl.class);
|
||||||
private final TaskService taskService;
|
private final TaskService taskService;
|
||||||
private final Map<String, Task> tasks;
|
private final Map<String, Task> tasks;
|
||||||
private final List<Task> pendingTasks;
|
private final Map<String, Task> pendingTasks;
|
||||||
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 CopyOnWriteArrayList<>();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +92,14 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Task getTask(String id) {
|
public Task getTask(String id) {
|
||||||
return tasks.get(id);
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Task task = tasks.get(id);
|
||||||
|
if (task != null) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
return pendingTasks.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,12 +109,17 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean hasTask(String id) {
|
public boolean hasTask(String id) {
|
||||||
return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id));
|
if (id == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return tasks.containsKey(id) || pendingTasks.containsKey(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<Task> getTasks() {
|
public Collection<Task> getTasks() {
|
||||||
return tasks.values();
|
List<Task> allTasks = new ArrayList<>(tasks.values());
|
||||||
|
allTasks.addAll(pendingTasks.values());
|
||||||
|
return allTasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -123,7 +141,7 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (task instanceof StepTask) {
|
if (task instanceof StepTask) {
|
||||||
pendingTasks.add(task);
|
pendingTasks.put(id, task);
|
||||||
} else {
|
} else {
|
||||||
tasks.put(id, task);
|
tasks.put(id, task);
|
||||||
}
|
}
|
||||||
@@ -136,7 +154,7 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
if (task instanceof WithChildren taskWithChildren) {
|
if (task instanceof WithChildren taskWithChildren) {
|
||||||
List<Task> childrens = taskWithChildren.getChildTasks();
|
List<Task> childrens = taskWithChildren.getChildTasks();
|
||||||
if (childrens == null || childrens.isEmpty()) {
|
if (childrens == null || childrens.isEmpty()) {
|
||||||
logger.warn("Parent task '{}' doesn't create any childs", task.getName());
|
logger.warn("Parent task '{}' doesn't create any children", task.getName());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,23 +166,37 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void activateTask(Task task, String oldId) {
|
public void activateTask(Task task, String oldId) {
|
||||||
if (pendingTasks.remove(task)) {
|
if (task == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean removed = oldId != null && pendingTasks.remove(oldId) != null;
|
||||||
|
if (!removed) {
|
||||||
|
removed = pendingTasks.remove(task.getId()) != null;
|
||||||
|
}
|
||||||
|
if (!removed) {
|
||||||
|
pendingTasks.values().remove(task);
|
||||||
|
}
|
||||||
tasks.put(task.getId(), task);
|
tasks.put(task.getId(), task);
|
||||||
logger.info("Updating task {} with new id: {} (was: {})",
|
logger.info("Updating task {} with new id: {} (was: {})",
|
||||||
task.getName(), task.getId(), oldId);
|
task.getName(), task.getId(), oldId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeTask(Task task) {
|
public void removeTask(Task task) {
|
||||||
|
if (task == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String id = task.getId();
|
String id = task.getId();
|
||||||
|
|
||||||
if (tasks.remove(id) != null) {
|
boolean removed = tasks.remove(id) != null;
|
||||||
|
removed |= pendingTasks.remove(id) != null;
|
||||||
|
removed |= pendingTasks.values().remove(task);
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
logger.debug("Removing task: {}", id);
|
logger.debug("Removing task: {}", id);
|
||||||
|
|
||||||
ReentrantLock lock = tasksLock.remove(task.getLockId());
|
if (task instanceof PersistableTask persistableTask) {
|
||||||
if (lock != null) {
|
taskService.unpersistTask(persistableTask);
|
||||||
logger.debug("Cleaning up lock for removed task: {}", task.getId());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,11 +218,11 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (lock != null) {
|
if (lock != null) {
|
||||||
logger.debug("Aquiring lock for task: {}", taskId);
|
logger.debug("Acquiring lock for task: {}", taskId);
|
||||||
lock.lock();
|
lock.lock();
|
||||||
|
|
||||||
if (!tasks.containsKey(taskId)) {
|
if (!tasks.containsKey(taskId)) {
|
||||||
logger.debug("Task {} is no longer available after aquiring lock", taskId);
|
logger.debug("Task {} is no longer available after acquiring lock", taskId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,16 +256,25 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
|
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
|
||||||
List<E> tasks = findTasksByPartialId(clazz, partialId);
|
if (clazz == null || partialId == null) {
|
||||||
return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst());
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return tasks.values().stream()
|
||||||
|
.filter(clazz::isInstance)
|
||||||
|
.map(clazz::cast)
|
||||||
|
.filter(task -> task.getId() != null && task.getId().contains(partialId))
|
||||||
|
.findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
|
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
|
||||||
|
if (clazz == null || partialId == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
return tasks.values().stream()
|
return tasks.values().stream()
|
||||||
.filter(clazz::isInstance)
|
.filter(clazz::isInstance)
|
||||||
.map(clazz::cast)
|
.map(clazz::cast)
|
||||||
.filter(task -> task.getId().contains(partialId))
|
.filter(task -> task.getId() != null && task.getId().contains(partialId))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +313,9 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
|
|
||||||
|
|
||||||
private void deleteInteraction(Task task) {
|
private void deleteInteraction(Task task) {
|
||||||
|
if (task == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (task instanceof WithParent<?> taskWithParent) {
|
if (task instanceof WithParent<?> taskWithParent) {
|
||||||
Task parent = taskWithParent.getParentTask();
|
Task parent = taskWithParent.getParentTask();
|
||||||
|
|
||||||
@@ -289,18 +333,16 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
private void deleteInteraction(String taskId, Deletable deletable) {
|
private void deleteInteraction(String taskId, Deletable deletable) {
|
||||||
logger.debug("Deleting interaction: {}", taskId);
|
logger.debug("Deleting interaction: {}", taskId);
|
||||||
|
|
||||||
CommandContext context = deletableTasksContext.get(taskId);
|
CommandContext commandContext = deletableTasksContext.remove(taskId);
|
||||||
if (context != null) {
|
if (commandContext != null) {
|
||||||
deletableTasksContext.remove(taskId);
|
InteractionHook hook = commandContext.getInteractionHook();
|
||||||
|
|
||||||
InteractionHook hook = context.getInteractionHook();
|
|
||||||
if (hook != null) {
|
if (hook != null) {
|
||||||
if (hook.isExpired()) {
|
if (hook.isExpired()) {
|
||||||
context.replyEphemeral("This interaction has expired, can't remove it");
|
commandContext.replyEphemeral("This interaction has expired, can't remove it");
|
||||||
}
|
} else {
|
||||||
|
hook.deleteOriginal().queue(null, error ->
|
||||||
if (!hook.isExpired()) {
|
logger.warn("Failed to delete interaction for task {}: {}", taskId, error.getMessage())
|
||||||
hook.deleteOriginal().queue();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,12 +361,32 @@ public class TaskManagerImpl implements TaskManager {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case REMOVE_SELF_AND_CHILDREN:
|
case REMOVE_SELF_AND_CHILDREN:
|
||||||
if (task instanceof WithChildren taskWithChildren) {
|
if (task instanceof WithParent<?> taskWithParent) {
|
||||||
for (Task child : taskWithChildren.getChildTasks()) {
|
Task parent = taskWithParent.getParentTask();
|
||||||
|
if (parent != null) {
|
||||||
|
if (parent instanceof WithChildren parentWithChildren) {
|
||||||
|
List<Task> childTasks = parentWithChildren.getChildTasks();
|
||||||
|
if (childTasks != null) {
|
||||||
|
for (Task child : childTasks) {
|
||||||
|
removeTask(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeTask(parent);
|
||||||
|
} else {
|
||||||
|
removeTask(task);
|
||||||
|
}
|
||||||
|
} else if (task instanceof WithChildren taskWithChildren) {
|
||||||
|
List<Task> childTasks = taskWithChildren.getChildTasks();
|
||||||
|
if (childTasks != null) {
|
||||||
|
for (Task child : childTasks) {
|
||||||
removeTask(child);
|
removeTask(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
removeTask(task);
|
removeTask(task);
|
||||||
|
} else {
|
||||||
|
removeTask(task);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case KEEP:
|
case KEEP:
|
||||||
|
|||||||
@@ -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,49 @@
|
|||||||
|
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");
|
||||||
|
String url = String.format("jdbc:mysql://%s:%s/%s?createDatabaseIfNotExist=true", dbHost, dbPort, dbName);
|
||||||
|
return new DatabaseConfig(url, 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);
|
|
||||||
}
|
}
|
||||||
|
return factory;
|
||||||
stop();
|
|
||||||
logger.error("Error connecting to database. Shutting down..");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
bl.setGuild(oGuild);
|
|
||||||
bl.setCreatorId(member.getId());
|
|
||||||
bl.setWord(word);
|
|
||||||
bl.setEnabled(true);
|
|
||||||
|
|
||||||
session.persist(bl);
|
|
||||||
} else {
|
|
||||||
bl.setEnabled(true);
|
|
||||||
session.merge(bl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the word to the guild's blacklist, or re-enables it if it already exists. Lookup and
|
||||||
|
* write happen in the same transaction.
|
||||||
|
*
|
||||||
|
* @param guildId the guild id (the guild row must exist)
|
||||||
|
* @param creatorId the user adding the word
|
||||||
|
* @param word the word
|
||||||
|
* @return what changed
|
||||||
|
*/
|
||||||
|
public EnableOutcome enableWord(String guildId, String creatorId, String word) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<OBlacklist> changed = new ArrayList<>();
|
||||||
|
for (OBlacklist blacklist : existing) {
|
||||||
|
if (!Boolean.TRUE.equals(blacklist.getEnabled())) {
|
||||||
|
blacklist.setEnabled(true);
|
||||||
|
changed.add(blacklist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.isEmpty()) {
|
||||||
|
return EnableOutcome.UNCHANGED;
|
||||||
|
}
|
||||||
|
queries.updateAll(changed);
|
||||||
|
return EnableOutcome.ENABLED;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(OGuild oGuild, String word) {
|
/**
|
||||||
JpaUtils.runWithTransaction(session -> {
|
* Deletes the word (case-insensitive) from the guild's blacklist.
|
||||||
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word);
|
*
|
||||||
if (bl != null) {
|
* @param guildId the guild id
|
||||||
session.remove(bl);
|
* @param word the word
|
||||||
}
|
* @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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private OBlacklist getWordFromDb(String guildId, String word) {
|
public List<OBlacklist> findAll() {
|
||||||
try (Session session = JpaUtils.openSession()) {
|
return tx.read(session -> new BlacklistQueries_(session).findAll().toList());
|
||||||
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);
|
|
||||||
query.setParameter("word", word);
|
|
||||||
OBlacklist blacklist = query.uniqueResult();
|
|
||||||
|
|
||||||
session.close();
|
|
||||||
return blacklist;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
|
||||||
session.remove(oGuild);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates the entity.
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user