Remove deprecated classes and static singleton-based architecture

This commit is contained in:
anthorob committed 2026-09-06 07:38:02 -04:00
1 parent 54a27883d1
commit b8786c8dc9
133 files changed
+4979 -1033

No files matched your search

+57 -49
View File
@@ -10,10 +10,15 @@ on:
branches: branches:
- "main" - "main"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
package: package:
name: Package name: Package
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 15
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -32,7 +37,7 @@ jobs:
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: junit-reports name: junit-reports
path: | path: |
@@ -40,7 +45,7 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload Maven artifacts - name: Upload Maven artifacts
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: maven-artifacts name: maven-artifacts
path: | path: |
@@ -48,11 +53,40 @@ jobs:
startup.sh startup.sh
if-no-files-found: error if-no-files-found: error
docker-main: maven-publish:
name: Docker Build and Push (main) name: Publish Maven Package
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 15
needs: package needs: package
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
server-id: gitea
server-username: REGISTRY_USER
server-password: REGISTRY_PASSWORD
- name: Publish Maven package
run: |
chmod +x ./mvnw
./mvnw --batch-mode deploy -DskipTests
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
docker:
name: Docker Build and Push
runs-on: ubuntu-24.04
timeout-minutes: 15
needs: package
if: ${{ github.event_name == 'push' }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -61,51 +95,25 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download build artifacts - name: Download build artifacts
uses: actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: maven-artifacts name: maven-artifacts
path: . path: .
- name: Log in to registry - name: Compute Docker tags
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 id: meta
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push (main)
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: ./Dockerfile
push: true
tags: "${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:main"
docker-release:
name: Docker Build and Push (release)
runs-on: ubuntu-latest
needs: package
if: ${{ startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download build artifacts
uses: actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3
with:
name: maven-artifacts
path: .
- name: Compute release version
id: rel
shell: bash shell: bash
run: | run: |
# Strip leading 'v' if present (e.g., v1.2.3 -> 1.2.3) REGISTRY="${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
ver="${GITHUB_REF_NAME#v}" ver="${GITHUB_REF_NAME#v}"
echo "version=$ver" >> "$GITHUB_OUTPUT" echo "tags=${REGISTRY}:${ver},${REGISTRY}:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${REGISTRY}:main" >> "$GITHUB_OUTPUT"
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to registry - name: Log in to registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
@@ -114,12 +122,12 @@ jobs:
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push (release + latest) - name: Build and push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: | tags: ${{ steps.meta.outputs.tags }}
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:${{ steps.rel.outputs.version }} cache-from: type=gha
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:latest cache-to: type=gha,mode=max
+302
View File
@@ -0,0 +1,302 @@
---
sessionId: session-260826-092009-17wb
---
# Requirements
### Overview & Goals
Replace the singleton/static-heavy bootstrap of the bot with an explicit, deterministic **lifecycle-managed application** driven by a small hand-rolled `ApplicationContext` and a `Lifecycle` contract. Dependencies are wired through **constructors** (including for reflectively-loaded commands and tasks), and **interfaces** are introduced for the key services so the code becomes testable, maintainable, and mockable — without adopting Spring Boot or any DI framework.
The current design (documented in `docs/architecture.md`) uses:
- `NetNeutrality` as a static singleton holding `instance` and `jda`, with a manual, order-sensitive `startup()`/`shutdown()` chain.
- Static singletons everywhere: `Settings.getInstance()`, `SessionManager` (static `getSessionFactory`/`shutdown`), `CommandManager` (static `init`/maps), `TaskManager.getInstance()`, `MusicManager` (static factory + static `playerManager`), `ThreadManager` (static executors), `ScheduledTaskManager` (static), `GuildService.get(...)` (static cache).
- `ReflectionHelper` instantiating commands/tasks via **no-arg constructors**, which forces them to reach out to singletons.
### Scope
**In Scope**
- A `Lifecycle` interface (`start()`/`stop()`/`priority()`) and an `ApplicationContext` that owns instances, wires them, and drives ordered startup/shutdown.
- Converting the core singletons to constructor-injected **instances**.
- Introducing **interfaces for key services** (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider) — decision confirmed with the user.
- Reworking `ReflectionHelper` + command/task base classes to use **constructor injection** (decision confirmed).
- Symmetric, deterministic startup/shutdown replacing `NetNeutrality.startup()/shutdown()` and the JVM shutdown hook.
- **Reorganizing the package layout** into `bootstrap` / `framework` / `platform` / `feature` / `data` / `shared` (see the Package Structure tab), done in the same effort as the DI refactor so imports are touched once. This includes renaming the entry point `NetNeutrality` → `App`.
**Out of Scope**
- Introducing Spring Boot, Guice, or Dagger (explicitly rejected — manual context chosen).
- Full interface-per-service/repository abstraction (only key services get interfaces).
- Dropping the `O*`/`G*` entity name prefixes (optional cosmetic churn, deferred).
- Behavioral/feature changes to commands, tasks, music, or the web API — this is a structural refactor only.
- Database schema / Flyway migration changes.
### User Stories
- As a maintainer, I want services created and wired in one explicit place so the dependency graph and startup order are obvious and not implicit.
- As a maintainer, I want symmetric `start()`/`stop()` for every component so shutdown reliably tears down what startup created, in reverse order.
- As a developer, I want commands/tasks to receive their dependencies via constructor so they no longer call `getInstance()` and can be unit-tested with mocks.
- As a developer, I want key services behind interfaces so I can mock them in tests (building on the existing JUnit 5 + Mockito setup).
### Functional Requirements
- The bot must start and shut down with identical externally-observable behavior to today (same ordered steps from `docs/architecture.md`).
- Startup order must be preserved: persistence/session → migrations → executors → command/task registries → music sources → JDA → web API → presence.
- Shutdown must run in reverse of startup: cancel scheduled tasks → disconnect music players → shut down executors → shut down JDA → release the Hibernate registry.
- Commands and tasks discovered by `ReflectionHelper` must continue to auto-load ("drop a class in the package" model) but now receive dependencies via constructor.
- No public command/task behavior changes.
### Non-Functional Requirements
- **No new runtime dependencies** (manual container only).
- **Incremental & low-risk**: migrate one area at a time using temporary static shims that delegate to the context, so the build stays green throughout.
- **Compatibility**: fat-JAR packaging and `main` entry point (`me.bobert.netneutrality.NetNeutrality`) remain unchanged.
- **Testability**: instance + interface-based services must be mockable with Mockito.
# Technical Design
### Current Implementation
- **Bootstrap** — `NetNeutrality` (`src/main/java/me/bobert/netneutrality/NetNeutrality.java`): static `instance`, static `jda`, `main()` builds `Settings` implicitly, constructor calls `startup()` which chains `SessionManager.getSessionFactory()` → `DatabaseMigration.migrate()` → `CommandManager.init()` → `MusicManager.initPlayerManager()` → `TaskManager.getInstance().init()` → `initJDA()` → `CommandManager.registerSlashCommands(jda)` → `new EventsHandler()` → `new WebApi(port)` → presence → shutdown hook. `shutdown()` runs `ScheduledTaskManager.cancelAll()` → `MusicManager.disconnectPlayers()` → `ThreadManager.shutdownExecutor()` → `disconnect()` → `SessionManager.shutdown()`.
- **Singletons/statics**: `Settings` (`utils/Settings.java`), `SessionManager` (`core/db/SessionManager.java`, all-static), `CommandManager` (`core/command/CommandManager.java`, static `commands` map + static `init`), `TaskManager` (`core/tasks/TaskManager.java`, `getInstance()`), `MusicManager` (`music/MusicManager.java`, static `managers` map + static `playerManager` + static `get`/`initPlayerManager`/`disconnectPlayers`), `ThreadManager` (`core/thread/ThreadManager.java`, static executors), `ScheduledTaskManager`, `GuildService` (`db/service/GuildService.java`, static `services` cache).
- **Reflective instantiation** — `ReflectionHelper.loadClassesFromPackage(...)` calls `s.getConstructor().newInstance()` (no-arg), used by `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()`.
- **Consumers of singletons** — ~110 call sites across commands (`command/**`), tasks (`tasks/**`), contexts (`context/**`, `core/context/**`), HTTP handlers (`http/server/**`), `EventsHandler`, repositories/services (`db/**`), etc.
### Key Decisions (confirmed with user)
1. **Manual `ApplicationContext` + `Lifecycle`** — no DI framework; the context owns instances, wires them via constructors, and drives ordered `start()`/`stop()`.
2. **Constructor injection for commands/tasks** — `ReflectionHelper` resolves a constructor that accepts the context (or specific dependencies) and passes them in; commands/tasks store deps as final fields instead of calling `getInstance()`.
3. **Lifecycle + key service interfaces** — introduce a `Lifecycle` interface plus interfaces for the main services (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider). Repositories and minor helpers stay concrete.
4. **Package restructure to `bootstrap`/`framework`/`platform`/`feature`/`data`/`shared`** — separate code by reason-of-change (engine vs. cross-cutting infra vs. concrete features vs. persistence model vs. leaf helpers), consolidate each feature vertical (e.g. music) into one package, and dissolve the catch-all `utils`. Performed right after the DI wiring, via the IDE's safe "Move Class" refactor, so the ~110 import updates happen once. Full details in the Package Structure tab.
### Proposed Changes
**1. Lifecycle contract** — new `me.bobert.netneutrality.bootstrap.Lifecycle`:
```java
public interface Lifecycle {
void start() throws Exception;
default void stop() {}
default int priority() { return 0; } // lower = started earlier, stopped later
}
```
**2. Application context** — new `me.bobert.netneutrality.bootstrap.ApplicationContext`:
```java
public final class ApplicationContext {
private final List<Lifecycle> components = new ArrayList<>();
public <T> T register(T component) {
if (component instanceof Lifecycle l) components.add(l);
return component;
}
public void start() throws Exception {
components.sort(Comparator.comparingInt(Lifecycle::priority));
for (Lifecycle c : components) c.start();
}
public void stop() {
var reversed = new ArrayList<>(components);
Collections.reverse(reversed);
reversed.forEach(Lifecycle::stop);
}
}
```
The context also exposes typed getters for wired services so `ReflectionHelper`/`EventsHandler`/contexts can resolve dependencies.
**3. Convert singletons to instances behind interfaces** (priority values in parentheses set start order):
- `SettingsProvider` ← `Settings` (config; constructed first, not itself a `Lifecycle`).
- `SessionProvider`/persistence ← `SessionManager` instance implementing `Lifecycle` (10): `start()` builds the `SessionFactory` + runs `DatabaseMigration.migrate()`; `stop()` destroys the registry.
- `ExecutorProvider` ← `ThreadManager` instance implementing `Lifecycle` (20): `stop()` = current `shutdownExecutor()`.
- `CommandRegistry` ← `CommandManager` instance implementing `Lifecycle` (30): `start()` = `init()`; `registerSlashCommands(jda)` called after JDA is ready.
- `TaskManager` interface ← impl implementing `Lifecycle` (30): `start()` = current `init()`.
- Scheduled tasks ← `ScheduledTaskManager` instance implementing `Lifecycle`: `stop()` = `cancelAll()`.
- `MusicService` ← new factory owning the shared `AudioPlayerManager` + per-guild `MusicManager` map, implementing `Lifecycle` (40): `start()` = `initPlayerManager()`; `stop()` = `disconnectPlayers()`. Per-guild `MusicManager` becomes a plain instance created by the factory.
- `GuildServiceProvider` ← registry owning the guild cache (replaces static `GuildService.get`).
- `WebApi` becomes a `Lifecycle` (60): `start()` binds the HTTP server; `stop()` stops it.
- JDA is built by the context (replacing static `jda`) and exposed via a getter/provider.
**4. Rework reflective instantiation** — extend `ReflectionHelper` so `loadClassesFromPackage` accepts a factory/`ApplicationContext` and instantiates via a constructor that takes the needed dependencies (falling back to no-arg for classes that declare it). `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` pass the context so commands/tasks are constructor-injected.
**5. Migrate consumers off `getInstance()`** — command/task base classes (`core/tasks/impl/AbstractTask.java`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand.java`) and contexts (`core/context/AbstractContext.java`, `context/**`) take dependencies via constructor. `EventsHandler` is constructed with the context and passes services when building contexts. During migration, keep **temporary static shims** (e.g. `Settings.getInstance()` delegating to the context instance) so untouched call sites keep compiling; remove them at the end.
**6. New bootstrap** — `NetNeutrality.main()` builds the `ApplicationContext`, constructs and `register()`s services in dependency order, calls `context.start()`, and installs **one** shutdown hook calling `context.stop()`. Static `instance`/`jda` are removed.
### Data Models / Contracts
Key new interfaces (illustrative signatures):
```java
interface SettingsProvider { String get(String key); String get(String key, String def); String getPrefix(); }
interface SessionProvider extends Lifecycle { org.hibernate.SessionFactory getSessionFactory(); }
interface ExecutorProvider extends Lifecycle { java.util.concurrent.ExecutorService getExecutor(String name); java.util.concurrent.ScheduledExecutorService getScheduledExecutor(); }
interface CommandRegistry extends Lifecycle { CommandContainer getCommandContainer(String name); void registerSlashCommands(JDA jda); }
interface TaskManager extends Lifecycle { Task getTask(String id); void insert(CommandContext ctx, Task task); void handle(TaskContext ctx); /* ... */ }
interface MusicService extends Lifecycle { MusicManager get(net.dv8tion.jda.api.entities.Guild guild); }
interface GuildServiceProvider { GuildService get(String guildId); }
```
### File Structure
New DI classes are created directly in their **target** packages (from the Package Structure tab) so they are not moved twice.
- **Added**: `bootstrap/Lifecycle.java`, `bootstrap/ApplicationContext.java`; service interfaces `platform/config/SettingsProvider.java`, `platform/persistence/SessionProvider.java`, `platform/concurrency/ExecutorProvider.java`, `framework/command/CommandRegistry.java`, `framework/task/TaskManager.java` [interface] + impl, `feature/music/MusicService.java`, `data/service/GuildServiceProvider.java`.
- **Moved / renamed (package restructure)**: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data`; `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`; `utils` dissolved into `platform.config` / `platform.persistence` / `bootstrap` / `shared.util`; `NetNeutrality` → `App`. See the Package Structure tab.
- **Modified**: `App.java` (bootstrap), `ReflectionHelper.java` (constructor injection), all converted managers/services, command/task base classes, `EventsHandler.java`, `WebApi.java`, HTTP handlers, `getInstance()` call sites, and `pom.xml` / `Dockerfile` / `startup.sh` for the `mainClass` rename.
- **Docs**: update `docs/architecture.md` (startup/shutdown + package map) and `docs/utilities-and-bootstrap.md` (bootstrap) to reflect the context and new layout.
### Architecture Diagram
```mermaid
graph TD
Main["NetNeutrality.main()"] --> Ctx[ApplicationContext]
Ctx -->|constructs & wires| Settings[SettingsProvider]
Ctx -->|register + start/stop| Session[SessionProvider]
Ctx -->|register + start/stop| Exec[ExecutorProvider]
Ctx -->|register + start/stop| Cmd[CommandRegistry]
Ctx -->|register + start/stop| Tasks[TaskManager]
Ctx -->|register + start/stop| Music[MusicService]
Ctx -->|register + start/stop| Web[WebApi]
Ctx -->|builds| JDA[JDA]
Cmd -->|constructor-injects| Refl[ReflectionHelper]
Tasks -->|constructor-injects| Refl
Refl -->|ctor deps| Impl[Commands / Tasks]
JDA -->|events| Events[EventsHandler]
Events -->|builds contexts with deps| Cmd
Events --> Tasks
```
### Risks
- **Large call-site surface (~110 usages)** — mitigated by temporary static shims delegating to context instances, migrating area-by-area, keeping the build green.
- **Startup ordering regressions** — mitigated by encoding order via `Lifecycle.priority()` and mirroring the exact sequence from `docs/architecture.md`.
- **Reflective constructor resolution** — commands/tasks must expose a consistent injectable constructor; mitigate by supporting a no-arg fallback and validating all classes load at startup.
- **Per-guild `MusicManager` vs shared factory** — clarify that the shared `AudioPlayerManager` and cache move to the `MusicService`, while per-guild `MusicManager` stays a lightweight instance.
- **`SessionManager.System.exit(1)` on DB failure** — preserve fail-fast behavior inside the new `SessionProvider.start()`.
# Package Structure
### Current Layout Problems
The tree today mixes three organizing principles at once: abstraction-vs-impl (`core.command`↔`command`, `core.context`↔`context`, `core.tasks`↔`tasks`), infra-vs-data (`core.db`↔`db`), and no split at all (`music`, `http`, `utils`, `hooks`). Consequences:
- `core` is simultaneously "the engine" and "misc" — it holds the framework sub-packages **and** loose classes (`LogHandler`, `BotState`).
- A single feature is scattered: music lives in `core.command.impl.MusicCommand`, `command.music`, `context.MusicChannelContext`, `tasks.music`, and `music/` — five places.
- `utils` is a catch-all mixing config (`Settings`), reflection (`ReflectionHelper`), JPA glue (`JpaUtils`), and formatting (`MessageFunction`, `UserFunction`).
- Naming drift: `db.entities` (plural) vs. the singular convention elsewhere.
### Guiding Principle
Separate code by the reason it changes:
- **framework** — the reusable bot engine (command/task/context machinery); rarely changes, knows nothing about specific commands.
- **platform** — cross-cutting technical services (persistence, concurrency, config, logging, event dispatch, web); changes when tech/tooling changes.
- **feature** — concrete commands + tasks + contexts + domain logic, grouped **by feature vertical**; changes most often.
- **data** — the persistence model (entities, repositories, services).
- **shared** — genuinely cross-feature leaf helpers.
### Target Layout
```
me.bobert.netneutrality
├── App.java // was NetNeutrality (main + bootstrap entry)
├── bootstrap/ // ApplicationContext, Lifecycle, ReflectionHelper (composition root)
├── framework/ // the reusable engine (was core, minus the misc)
│ ├── command/ // Command, CommandProcessor, CommandRegistry, options, build, impl bases
│ ├── context/ // Context hierarchy, reply/
│ └── task/ // Task, TaskManager, StepTask/PersistableTask, persistence/, trait/, impl bases
├── platform/ // cross-cutting technical services (horizontals)
│ ├── config/ // Settings / SettingsProvider
│ ├── persistence/ // SessionProvider, DatabaseMigration, Repository base, JpaUtils
│ ├── concurrency/ // ExecutorProvider, ScheduledTaskManager, BaseThreadFactory
│ ├── logging/ // LogHandler
│ ├── event/ // EventsHandler (was hooks)
│ └── web/ // WebApi, handlers, WebModule (was http)
├── feature/ // concrete features, one vertical per package
│ ├── music/ // MusicService/MusicManager, QueueManager, scheduler, source/, + music commands/tasks/contexts
│ ├── moderation/ // blacklist, purge, admin commands + related services
│ ├── reaction/ // reaction commands + emoji-role tasks
│ ├── git/ // report-issue command + task
│ ├── crypto/
│ └── help/
├── data/ // the persistence model (was the "db" half of the split)
│ ├── entity/ // O* entities (entities → entity, singular)
│ ├── repository/
│ └── service/ // GuildService, GuildServiceProvider, BlacklistService, …
├── shared/util/ // MiscUtils, MessageFunction, UserFunction, GuildUtils, ContextUtils
└── exceptions/ // unchanged (already coherent)
```
### Key Moves
- `core` → `framework`, now containing **only** the engine (no `LogHandler`, no `BotState`, no `db`, no `thread`).
- `core.db` (infra) → `platform.persistence`; `db` (model) → `data` — resolves the "`db` means two things" problem. `db.entities` → `data.entity`.
- `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
- `utils` dissolved: config → `platform.config`, JPA glue → `platform.persistence`, reflection → `bootstrap`, formatting/misc → `shared.util`.
- All music code consolidates under `feature.music`; the other verticals group their command/task/context/service triplets similarly.
- `LogHandler` → `platform.logging`; `BotState` → `bootstrap` (composition-root-adjacent state).
### Sequencing & Rationale
The repackage is done **immediately after the DI wiring, in the same effort**: the DI refactor already renames managers (`CommandManager`→`CommandRegistry`, `SessionManager`→`SessionProvider`, `ThreadManager`→`ExecutorProvider`) and touches imports at ~110 call sites, so combining them avoids editing the same imports twice. Moves use the IDE's safe **"Move Class"** refactor (references update automatically), one subsystem at a time, building after each. New DI classes are created directly in their target packages rather than moved later.
### Lower-Risk Variant (optional fallback)
If full feature-first grouping feels too large in one pass, a high-value subset fixes ~80% of the confusion with far fewer moves: rename `core` → `framework`, extract `platform` (evict `LogHandler`, `BotState`, `core.db`, `core.thread`, `hooks`, `http`), and dissolve `utils` — while leaving the concrete `command`/`task`/`context` packages where they are. The trade-off is that a single feature stays spread across those layer packages.
### Risks
- **Move touches nearly every import** — mitigated by IDE "Move Class" (auto-updates references), one subsystem per step, and a build after each move.
- **Main-class rename `NetNeutrality` → `App`** — must update the Shade `mainClass` in `pom.xml`, the `Dockerfile`/`startup.sh` entry, and docs atomically, or the fat JAR won't launch.
- **Feature-first churn** — grouping music/moderation/etc. is the largest move; the lower-risk variant above is the fallback if needed.
# Testing
### Validation Approach
The refactor is behavior-preserving, so validation focuses on: (a) the project compiles and the fat JAR builds after each stage, (b) existing tests still pass, and (c) new unit tests prove services are now mockable/instance-based. Use the existing Maven + JUnit 5 + Mockito setup (`./mvnw test`); Checkstyle runs in the `test` phase and must stay green.
### Key Scenarios
- **Build & Checkstyle** — `./mvnw package` succeeds after every stage (shade fat JAR, main class unchanged).
- **ApplicationContext ordering** — a unit test registers fake `Lifecycle` components with different `priority()` values and asserts `start()` runs ascending and `stop()` runs descending.
- **Command/task loading** — assert `CommandManager`/`TaskManager` still discover and instantiate all implementations via `ReflectionHelper` with constructor injection (no `getInstance()` calls at runtime).
- **Existing tests** — `CommandProcessorTest`, `SettingsTest`, and any others continue to pass; update them to use injected instances/mocks instead of wrestling with singletons.
### Edge Cases
- Missing `NETNEUTRALITY_BOT_TOKEN` → startup still fails fast with a clear error.
- DB connection failure inside `SessionProvider.start()` → preserves current fail-fast (Sentry capture in non-dev + exit).
- A command/task class that only declares a no-arg constructor → `ReflectionHelper` still loads it via the fallback path.
- Shutdown with services that never fully started → `stop()` must be null-safe (mirroring current guards like `jda != null`, `registry != null`).
### Test Changes
- **Add**: `ApplicationContextTest` (ordering + symmetric start/stop), a `ReflectionHelper` constructor-injection test.
- **Update**: `SettingsTest` and `CommandProcessorTest` to construct instances/mocks of the new interfaces instead of relying on static singletons.
- **Skip**: no integration test against a live Discord gateway or real MySQL (out of scope).
# Delivery Steps
### ✓ Step 1: Introduce Lifecycle contract and ApplicationContext bootstrap
The app boots through an `ApplicationContext` that drives ordered start/stop, while behavior stays identical via temporary shims.
- Add `me.bobert.netneutrality.bootstrap.Lifecycle` interface with `start()`, `stop()` (default no-op), and `priority()` (default 0, lower = started earlier / stopped later).
- Add `me.bobert.netneutrality.bootstrap.ApplicationContext` that registers components, sorts by `priority()`, starts ascending and stops descending, and exposes typed getters for wired services.
- Refactor `NetNeutrality.main()`/`startup()` to build the context and register the existing (still static) steps as thin `Lifecycle` adapters, install a single shutdown hook calling `context.stop()`, and remove static `instance`/`jda` (temporarily bridged where needed).
- Add `ApplicationContextTest` asserting ascending start and descending stop ordering.
### ✓ Step 2: Convert foundational services to instances behind interfaces
Configuration, persistence, and executors are constructor-injected instances behind interfaces, wired into the context with correct lifecycle order.
- Introduce `SettingsProvider` implemented by `Settings` (instance, constructed first); keep `Settings.getInstance()` as a temporary shim delegating to the context instance.
- Convert `core/db/SessionManager` into a `SessionProvider` instance implementing `Lifecycle` (priority ~10): `start()` builds the `SessionFactory` and runs `DatabaseMigration.migrate()`, `stop()` destroys the registry; preserve fail-fast on DB errors.
- Convert `core/thread/ThreadManager` into an `ExecutorProvider` instance implementing `Lifecycle` (priority ~20) with `stop()` = current `shutdownExecutor()`.
- Update `JpaUtils`/`Repository`/`db/**` call sites andhihims to use the injected providers.
### ✓ Step 3: Convert domain managers (command, task, scheduled, music, guild) to instances behind interfaces
The command/task/music/guild managers are instance-based `Lifecycle` components behind interfaces, registered in the context.
- Introduce `CommandRegistry` (impl = `CommandManager`, priority ~30): `start()` = `init()`; `registerSlashCommands(jda)` invoked after JDA is ready.
- Introduce a `TaskManager` interface + impl (priority ~30) implementing `Lifecycle` with `start()` = current `init()`; convert `ScheduledTaskManager` to an instance whose `stop()` = `cancelAll()`.
- Introduce `MusicService` factory owning the shared `AudioPlayerManager` and per-guild `MusicManager` map (priority ~40): `start()` = `initPlayerManager()`, `stop()` = `disconnectPlayers()`; make per-guild `MusicManager` a plain instance created by the factory.
- Introduce `GuildServiceProvider` owning the guild cache, replacing static `GuildService.get(...)`.
- Keep static shims on each so untouched call sites keep compiling.
### ✓ Step 4: Enable constructor injection for reflectively-loaded commands and tasks
Commands and tasks receive their dependencies via constructor instead of calling `getInstance()`.
- Extend `ReflectionHelper.loadClassesFromPackage` to accept the `ApplicationContext` and instantiate via a constructor taking the required dependencies, with a no-arg fallback for classes that declare it.
- Update `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` to pass the context.
- Update command/task base classes (`core/tasks/impl/AbstractTask`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand`) and contexts (`core/context/AbstractContext`, `context/**`, `EventsHandler`) to store injected dependencies as final fields.
- Add a `ReflectionHelper` test proving constructor injection and no-arg fallback both work.
### ✓ Step 5: Remove static shims, finalize interfaces, and update tests/docs
All singletons/`getInstance()` accessors are gone; services are wired only through the context, and tests/docs reflect the new model.
- Migrate remaining `getInstance()`/static call sites (commands, tasks, HTTP handlers in `http/server/**`, `LogHandler`, `BotState`) to injected interfaces and delete the temporary static shims and singleton fields.
- Convert `WebApi` into a `Lifecycle` component (priority ~60) with `start()`/`stop()` for the HTTP server.
- Update `SettingsTest` and `CommandProcessorTest` to use injected instances/mocks of the new interfaces; ensure `./mvnw package` (including Checkstyle) is green.
- Update `docs/architecture.md` (startup/shutdown lifecycle) and `docs/utilities-and-bootstrap.md` (bootstrap) to describe the `ApplicationContext` + `Lifecycle` design.
### ✓ Step 6: Reorganize packages into bootstrap/framework/platform/feature/data/shared
The codebase is regrouped by reason-of-change into the target layout (see the Package Structure tab), with all references updated and the build green.
- Rename the entry point `NetNeutrality` → `App`, and update the Shade `mainClass` in `pom.xml`, plus the `Dockerfile`/`startup.sh` launch entry.
- Using the IDE's safe "Move Class" refactor, one subsystem per step: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data` (`db.entities` → `data.entity`); `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
- Dissolve `utils`: `Settings`/`SettingsProvider` → `platform.config`, `JpaUtils` → `platform.persistence`, `ReflectionHelper` → `bootstrap`, formatting/misc helpers → `shared.util`; move `LogHandler` → `platform.logging` and `BotState` → `bootstrap`.
- Consolidate each feature vertical (music, moderation, reaction, git, crypto, help) into `feature.*`, pulling its commands/tasks/contexts/services together.
- Build and run tests after each subsystem move; update `docs/architecture.md` "Package map" to the new layout.
+214
View File
@@ -0,0 +1,214 @@
---
sessionId: session-260905-145156-xyio
---
# Requirements
### Overview & Goals
Revise the package structure section of the lifecycle refactor plan to use a hybrid layout that keeps the new `bootstrap/` package but reverts the rest to the familiar old-style naming and organization.
### Scope
**In Scope**
- Reverting `framework/` → `core/` (engine: command, context, task machinery)
- Reverting `platform/` hierarchy → flat old-style packages: `db/` (persistence + entities + repositories + services), `hooks/` (events), `http/` (web API), `music/` (music domain)
- Reverting `feature/` vertical grouping → separate top-level `command/`, `tasks/`, `context/` packages with subcategory folders (e.g. `command/music/`, `tasks/scheduled/`)
- Restoring `utils/` for remaining helpers (Settings, JpaUtils, MusicUtils, ChannelMessagePurger)
- Keeping `bootstrap/` (ApplicationContext, Lifecycle, BotState, ReflectionHelper) — this is new and stays
- Keeping `exceptions/` — unchanged
- Keeping `shared/util/` (ContextUtils, GuildUtils, MessageFunction, MiscUtils, UserFunction)
**Out of Scope**
- Any behavioral or DI/lifecycle logic changes — this is purely a package restructure
- Changing the DI refactor plan's other sections (Lifecycle contract, ApplicationContext, constructor injection, interfaces)
### Target Layout
```
me.bobert.netneutrality
├── App.java
├── bootstrap/ # NEW — keep as-is
│ ├── ApplicationContext.java
│ ├── BotState.java
│ ├── Lifecycle.java
│ └── ReflectionHelper.java
├── core/ # was framework/ → revert to core/
│ ├── command/ # Command, CommandProcessor, CommandRegistry, CommandManager, options/, impl/
│ ├── context/ # Context hierarchy, abstract bases
│ └── tasks/ # Task, TaskManager, StepTask, PersistableTask, persistence/, trait/, impl/
├── command/ # concrete command implementations (was feature/*/command/)
│ ├── admin/
│ ├── basic/
│ ├── bot/
│ ├── crypto/
│ ├── custom/
│ ├── git/
│ ├── music/
│ └── react/
├── context/ # concrete context implementations (was framework/context/command/ + task/)
│ ├── command/
│ ├── tasks/
│ └── MusicChannelContext.java
├── tasks/ # concrete task implementations (was feature/*/task/)
│ ├── emoji/
│ ├── generic/
│ ├── git/
│ ├── help/
│ ├── music/
│ ├── reaction/
│ └── scheduled/
├── db/ # persistence model (was data/ + platform/persistence/)
│ ├── entities/ # O* entities
│ ├── repository/
│ ├── service/ # GuildService, GuildServiceProvider, BlacklistService, etc.
│ ├── GSettings.java
│ ├── Repository.java
│ ├── DatabaseMigration.java
│ ├── SessionManager.java # SessionProvider impl
│ └── JpaUtils.java
├── hooks/ # was platform/event/
│ └── EventsHandler.java
├── http/ # was platform/web/
│ ├── server/
│ └── WebModule.java
├── music/ # music domain (was feature/music/ minus commands/tasks)
│ ├── AudioPlayerSendHandler.java
│ ├── MusicManager.java
│ ├── MusicService.java
│ ├── QueueManager.java
│ ├── SourceFinder.java
│ ├── source/
│ ├── TrackInfo.java
│ ├── TrackLoadingHandler.java
│ └── TrackScheduler.java
├── utils/ # remaining helpers not in shared/util/
│ ├── Settings.java # SettingsProvider impl
│ ├── ChannelMessagePurger.java
│ ├── JpaUtils.java # (or in db/, see note)
│ └── MusicUtils.java
├── shared/util/ # NEW — keep as-is
│ ├── ContextUtils.java
│ ├── GuildUtils.java
│ ├── MessageFunction.java
│ ├── MiscUtils.java
│ └── UserFunction.java
├── exceptions/ # unchanged
└── core/thread/ OR thread/ # ThreadManager, ScheduledTaskManager, BaseThreadFactory
```
**Note:** `core/thread/` was the old location for threading. The old layout also had a top-level `thread/` for `MusicManagerCleanupThread`. Both can be restored as they were.
### Key Moves (current → proposed)
| Current (new) | Proposed (hybrid) |
|---|---|
| `framework/command/` | `core/command/` |
| `framework/context/` | `core/context/` |
| `framework/task/` | `core/tasks/` |
| `framework/NetNeutralityBot.java` | remove or merge into App |
| `platform/config/Settings.java` | `utils/Settings.java` |
| `platform/persistence/*` | `db/` (SessionManager, DatabaseMigration, JpaUtils, Repository) |
| `platform/concurrency/*` | `core/thread/` |
| `platform/logging/LogHandler.java` | `core/LogHandler.java` |
| `platform/event/EventsHandler.java` | `hooks/EventsHandler.java` |
| `platform/web/*` | `http/*` |
| `data/entity/` | `db/entities/` |
| `data/repository/` | `db/repository/` |
| `data/service/` | `db/service/` |
| `feature/music/command/*` | `command/music/` |
| `feature/music/*.java` (domain) | `music/` |
| `feature/moderation/command/*` | `command/admin/` |
| `feature/moderation/ChannelMessagePurger.java` | `utils/ChannelMessagePurger.java` |
| `feature/reaction/command/*` | `command/react/` |
| `feature/reaction/task/*` | `tasks/reaction/` |
| `feature/git/command/*` | `command/git/` |
| `feature/git/task/*` | `tasks/git/` |
| `feature/crypto/command/*` | `command/crypto/` |
| `feature/help/command/*` | `command/basic/` + `command/bot/` |
| `feature/help/task/*` | `tasks/help/` |
| `feature/custom/command/*` | `command/custom/` |
| `bootstrap/` | `bootstrap/` (no change) |
| `shared/util/` | `shared/util/` (no change) |
| `exceptions/` | `exceptions/` (no change) |
# Technical Design
### Current Implementation
The codebase currently uses the post-refactor layout with:
- `bootstrap/` — ApplicationContext, Lifecycle, BotState, ReflectionHelper
- `framework/` — command, context, task engine
- `platform/` — config, persistence, concurrency, logging, event, web
- `feature/` — music, moderation, reaction, git, crypto, help, custom (feature-vertical grouping)
- `data/` — entity, repository, service
- `shared/util/` — cross-cutting helpers
- `exceptions/`
All DI/lifecycle wiring (constructor injection, interfaces like `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, `CommandRegistry`, `TaskManager`, `MusicService`, `GuildServiceProvider`) remains intact — only package locations change.
### Key Decisions
1. **Revert engine package name**: `framework/` → `core/` — matches the original naming the team is familiar with.
2. **Flatten infrastructure**: dissolve `platform/` hierarchy back to `db/`, `hooks/`, `http/`, `core/thread/`, `utils/` — removes the extra nesting that added cognitive overhead.
3. **Separate top-level command/tasks/context**: undo feature-vertical grouping in `feature/` — concrete implementations go back to `command/<category>/`, `tasks/<category>/`, `context/` as before.
4. **Keep bootstrap/ and shared/util/**: these are genuinely new and useful additions with no old equivalent.
5. **Restore `db/entities/`**: revert `data/entity/` back to `db/entities/` (plural, matching old convention).
6. **Service interfaces stay**: `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, etc. remain — they just live in their reverted package locations (e.g. `SettingsProvider` in `utils/` alongside `Settings`).
### Proposed Changes
This is a pure move/rename refactor using IDE safe "Move Class" operations. No logic changes. The DI wiring, lifecycle priorities, constructor injection, and all interfaces remain exactly as they are — only their package declarations and import statements change.
### Risks
- **Import churn** — same risk as the original restructure; mitigated by IDE "Move Class" refactor, one subsystem at a time, build after each.
- **Interface co-location** — service interfaces (e.g. `SessionProvider`) now live alongside their impls in `db/` or `utils/` rather than in a separate `platform/` package. This is acceptable for this project's size.
# Delivery Steps
### ✓ Step 1: Move engine packages from framework/ back to core/
The `framework/` package is renamed back to `core/` with its original subpackage names.
- Move `framework/command/` → `core/command/` (all engine classes: Command, CommandProcessor, CommandRegistry, CommandManager, CommandCategory, CommandContainer, CommandExecutionResult, CommandOptionsBuilder, impl/, options/)
- Move `framework/context/` → `core/context/` (Context, AbstractContext, CommandContext, TaskContext, InteractionContext, MessageContext, NoMessageContext, AsyncContext)
- Move `framework/task/` → `core/tasks/` (Task, TaskManager interface + impl, StepTask, PersistableTask, BuiltInTask, ScheduledTask, TaskCleanup, persistence/, trait/, impl/, generic/)
- Move `framework/NetNeutralityBot.java` into `core/` or merge into App if it's just a shell
- Move `platform/logging/LogHandler.java` → `core/LogHandler.java`
- Move `platform/concurrency/*` → `core/thread/` (ThreadManager/ExecutorProvider, ScheduledTaskManager, BaseThreadFactory)
- Update all imports; verify build with `./mvnw compile`
### ✓ Step 2: Move persistence and infrastructure packages back to db/, hooks/, http/, utils/
Infrastructure packages are flattened back to their original top-level locations.
- Move `data/entity/` → `db/entities/` (restore plural naming)
- Move `data/repository/` → `db/repository/`
- Move `data/service/` → `db/service/` (GuildService, GuildServiceProvider, GuildSettingService, BlacklistService, TaskService)
- Move `data/GSettings.java` → `db/GSettings.java`
- Move `platform/persistence/` contents → `db/` (SessionManager/SessionProvider, DatabaseMigration, Repository, JpaUtils, SessionProvider interface)
- Move `platform/config/Settings.java` + `SettingsProvider` → `utils/`
- Move `platform/event/EventsHandler.java` → `hooks/EventsHandler.java`
- Move `platform/web/` → `http/` (WebApi, server/, WebModule)
- Move `feature/moderation/ChannelMessagePurger.java` → `utils/ChannelMessagePurger.java`
- Move `feature/music/MusicUtils.java` → `utils/MusicUtils.java` (if it exists)
- Delete empty `platform/` and `data/` directories
- Update all imports; verify build with `./mvnw compile`
### ✓ Step 3: Restore music domain package and split feature/ into command/, tasks/, context/
Feature-vertical grouping is dissolved back into separate top-level packages.
- Move `feature/music/` domain classes (MusicManager, MusicService, QueueManager, AudioPlayerSendHandler, SourceFinder, source/, TrackInfo, TrackLoadingHandler, TrackScheduler, MusicChannelContext) → `music/`
- Move concrete context classes from `framework/context/command/` → `context/command/` and `framework/context/task/` → `context/tasks/`; move MusicChannelContext → `context/MusicChannelContext.java`
- Move `feature/music/command/*` → `command/music/`
- Move `feature/moderation/command/*` → `command/admin/`
- Move `feature/reaction/command/*` → `command/react/`
- Move `feature/git/command/*` → `command/git/`
- Move `feature/crypto/command/*` → `command/crypto/`
- Move `feature/help/command/*` → `command/basic/` and `command/bot/` (matching old categorization)
- Move `feature/custom/command/*` → `command/custom/`
- Move `feature/reaction/task/*` → `tasks/reaction/`
- Move `feature/git/task/*` → `tasks/git/`
- Move `feature/help/task/*` → `tasks/help/`
- Move `feature/music/task/*` → `tasks/music/`
- Move scheduled tasks → `tasks/scheduled/`
- Delete empty `feature/` directory
- Update all imports; verify full build with `./mvnw package`
- Update `docs/architecture.md` package map table to reflect the new hybrid layout
+37
View File
@@ -0,0 +1,37 @@
# Net Neutrality — Technical Documentation
This directory contains detailed technical documentation for the **Net Neutrality** Discord bot
(`me.bobert:netneutrality`, version `3.0.0`).
It is intended for developers working on the codebase. It documents every major system and
subsystem, how they fit together, and the end-to-end flows that tie them.
## Table of contents
| Document | Subsystem |
|---|---|
| [architecture.md](architecture.md) | High-level architecture, startup/shutdown lifecycle, tech stack |
| [events.md](events.md) | Event handling & routing (`EventsHandler`) |
| [contexts.md](contexts.md) | Contexts — the abstraction over Discord events |
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
| [music.md](music.md) | Music — Lavaplayer pipeline, queue, custom Udio source |
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
| [threading.md](threading.md) | Threading & scheduling infrastructure |
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
## How to read this documentation
Start with [architecture.md](architecture.md) for the big picture and the startup sequence.
Then read [events.md](events.md) and [contexts.md](contexts.md), because almost everything else
is triggered through a **Context** produced by the **EventsHandler**. From there, the two primary
execution paths are [commands.md](commands.md) and [tasks.md](tasks.md).
## Conventions
- Class names are given fully qualified on first mention, then by simple name.
- Database entity classes are prefixed with `O` (e.g. `OGuild`), and are documented in
[persistence.md](persistence.md).
- Environment variables / configuration keys use the `NETNEUTRALITY_*` and `MYSQL_*` prefixes and
are documented in [utilities-and-bootstrap.md](utilities-and-bootstrap.md).
+161
View File
@@ -0,0 +1,161 @@
# Architecture Overview
Net Neutrality is a Java 25 Discord bot built on top of [JDA](https://github.com/discord-jda/JDA).
It is packaged as a single fat JAR (via the Maven Shade plugin) whose entry point is
`me.bobert.netneutrality.App`.
## Big picture
Discord sends **events** over the gateway. A single JDA `EventListener`
(`me.bobert.netneutrality.hooks.EventsHandler`) receives every event and turns it into a
**Context** — a high-level abstraction over that event. Each context is then routed to one of three
execution paths:
```
┌──────────────────────────────┐
Discord Gateway ───────▶│ EventsHandler │
(JDA events) │ event class → Context factory│
└───────────────┬──────────────┘
│ builds a Context
┌─────────────────────────┼───────────────────────────┐
▼ ▼ ▼
CommandContext TaskContext (no Context)
│ │ lambda /
▼ ▼ consumer runs
CommandProcessor TaskManager.handle() directly
.dispatch() (lookup task by id) (e.g. GuildJoin)
│ │
▼ ▼
Command.execute() Task.execute()
```
1. **Command flow** — the context is a `CommandContext`; it is handed to `CommandProcessor` which
resolves and runs a `Command`. See [commands.md](commands.md).
2. **Task flow** — the context is a `TaskContext`; it is handed to `TaskManager` which looks up a
`Task` by its **id** and runs it. See [tasks.md](tasks.md).
3. **Lambda flow** — for simple events (e.g. `GuildJoinEvent`, `GuildLeaveEvent`) a consumer runs
directly and no context is routed onward. See [events.md](events.md).
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
[music](music.md) (Lavaplayer), the [web API](web-api.md), and the [threading/scheduling](threading.md)
infrastructure.
## Technology stack
| Concern | Library | Version |
|---|---|---|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
| Voice / DAVE (E2EE) | `club.minnced:jdave-*` | 0.1.8 |
| Audio engine | `dev.arbjerg:lavaplayer` | 2.2.6 |
| YouTube source | `dev.lavalink.youtube:v2` | 1.18.2 |
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
| DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 |
| Error tracking | `io.sentry:sentry` | 8.41.0 |
| GitLab integration | `org.gitlab4j:gitlab4j-api` | 6.2.0 |
| HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 |
| JSON | `org.json:json` + `com.google.code.gson:gson` | — |
| Classpath scanning | `org.reflections:reflections` | 0.10.2 |
| Emoji parsing | `net.fellbaum:jemoji` | 1.7.6 |
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
| Testing | JUnit Jupiter 6 + Mockito 5 | — |
The HTTP **server** for the internal API uses the JDK built-in
`com.sun.net.httpserver.HttpServer` (no external framework); see [web-api.md](web-api.md).
## Startup lifecycle
The entry point is `App.main()` followed by the `App()` constructor which calls
`startup()`. Bootstrap is driven by a hand-rolled
[`ApplicationContext`](utilities-and-bootstrap.md#applicationcontext--lifecycle) that owns wired
instances and starts every [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle)
component in ascending priority order.
1. **Print banner / read env** — reads `NETNEUTRALITY_BOT_ENV` (default `dev`) via the early-bootstrap
`Settings.getInstance()` singleton (the only remaining process-wide settings access point; it is
also registered into the context as `SettingsProvider` for constructor injection).
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
(environment, release = `VERSION`, traces sample rate 1.0).
3. **Log levels** — in `dev`, raise JDA / Lavalink-YouTube / sedmelluq logging to `DEBUG`.
4. `startup()`:
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
3. `context.start()` — start every `Lifecycle` component in ascending priority order.
4. Register a JVM shutdown hook calling `shutdown()` → `context.stop()`.
### Lifecycle priorities (startup order)
| Priority | Component | What `start()` does |
|---|---|---|
| 10 | `SessionManager` (`SessionProvider`) | Build Hibernate `SessionFactory` + run Flyway migrations. Binds itself into `JpaUtils`. |
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
| 40 | `MusicServiceImpl` (`MusicService`) | Initialise the shared Lavaplayer `AudioPlayerManager`. |
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
| 70 | `WebApi` | Start the internal HTTP server (`NETNEUTRALITY_API_PORT`, default 9000). |
Commands and tasks that need shared services declare them as constructor parameters
(`MusicService`, `TaskManager`, `GuildServiceProvider`, `SettingsProvider`, `CommandRegistry`, …).
`ReflectionHelper` picks the richest public constructor whose parameter types all resolve from the
context (falling back to a no-arg constructor when none match).
### Gateway intents
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
`MESSAGE_CONTENT`. It also installs the DAVE (end-to-end encrypted voice) session factory via
`AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())`.
## Shutdown lifecycle
`App.shutdown()` (invoked by the JVM shutdown hook, `ShutdownCommand`, or the HTTP
`/actions/shutdown` endpoint) calls `context.stop()`, which stops every registered `Lifecycle`
component in the **exact reverse** of start order. Failures in one component are logged and do not
block the others. Effectively this means:
1. `WebApi.stop()` — stop the HTTP server and its executor.
2. Post-JDA adapter — no-op.
3. JDA adapter — `jda.shutdown()`.
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
5. `MusicServiceImpl.stop()` — disconnect players / leave voice channels.
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
7. `ThreadManager.stop()` — shut down the shared executors.
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
## Configuration & build
- **Config sources** — `Settings` loads `bot.properties` and overlays environment variables (env
vars win). See [utilities-and-bootstrap.md](utilities-and-bootstrap.md#settings). An example file
lives at `bot.properties.example`.
- **Build** — `./mvnw package` produces `target/netneutrality.jar` (Shade fat JAR, main class
`me.bobert.netneutrality.App`). Checkstyle (`checkstyle.xml`) runs in the `test` phase
and fails the build on violations.
- **Run** — the preferred method is Docker Compose (`docker compose build && docker compose up -d`),
which also provisions the MariaDB database. See `README.md` and `docker-compose.yml`.
## Package map
| Package | Contents |
|---|---|
| `me.bobert.netneutrality` | Entry point (`App`) |
| `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` |
| `…core.command` | Command engine (registry, processor, manager, options, impl bases) |
| `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) |
| `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) |
| `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) |
| `…core` | `LogHandler`, `NetNeutralityBot` |
| `…command.*` | Concrete command implementations (`admin`, `basic`, `bot`, `crypto`, `custom`, `git`, `music`, `react`) |
| `…context.command` / `…context.tasks` | Concrete context implementations (slash, message, button, modal, emoji, etc.) |
| `…context` | `MusicChannelContext` |
| `…tasks.*` | Concrete task implementations (`git`, `help`, `music`, `reaction`) |
| `…db` | Session/migration, `Repository`, `JpaUtils`, `GSettings` |
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
| `…hooks` | `EventsHandler` |
| `…http` | Internal web API and HTTP client module |
| `…music` | Music domain (service, manager, queue, sources, tracks) |
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
| `…shared.util` | Cross-cutting helpers |
| `…exceptions` | Custom exception hierarchy |
+169
View File
@@ -0,0 +1,169 @@
# Commands
**Packages:** `me.bobert.netneutrality.framework.command` (framework),
`me.bobert.netneutrality.feature.*.command` (concrete commands)
A **Command** is what a user executes on Discord. It receives a
[`CommandContext`](contexts.md#commandcontext) and runs logic based on it. There are two primary
kinds:
- **Slash commands** — first-class Discord application commands. Their structure (options,
subcommands, groups) has rules defined by Discord and must be configured **procedurally** via
`CommandOptionsBuilder`.
- **Message (prefix) commands** — a plain message beginning with the configured prefix
(`NETNEUTRALITY_BOT_PREFIX`, default `$`).
A core goal of the framework is that **both kinds are authored the same way**: a command declares its
metadata and options once, and the framework adapts it to either interaction style. Most of that
adaptation logic lives in `CommandProcessor`.
## The `Command` contract
`Command` (interface) defines:
- Metadata — `getCommandName()`, `getDescription()`, `getCategory()`
([`CommandCategory`](#commandcategory)).
- Type — `getType()` (`SLASH` by default).
- Permissions — `needAdmin()`, `canBeExecutedBy(...)`, guild-only / slash-only constraints.
- Option declaration — `registerCommandOptions(CommandOptionsBuilder)` (procedural option setup).
- Execution — `execute(CommandContext) : CommandExecutionResult`.
### Base implementations (`core.command.impl`)
| Class | Adds |
|---|---|
| `AbstractCommand` | Default `Command` implementation; type defaults to `SLASH`; basic permission logic. |
| `AdminCommand` | Sets `needAdmin() = true` and category `ADMINISTRATION`. |
| `MusicCommand` | Guild-only; verifies the guild's music role and music channel before execution. |
| `MessageMenuCommand` | Discord **message** context-menu command; skips option registration; wraps the target `Message`. |
| `UserMenuCommand` | Discord **user** context-menu command; skips option registration; wraps the target `User`. |
## Unified options
To let one command definition serve both slash and message input, values are read through a wrapper
interface:
- `CommandOption` (interface) — uniform accessors: `getAsString()`, `getAsInt()`, `getAsMember()`,
`getAsBoolean()`, … .
- `SlashCommandOption` — wraps JDA's `OptionMapping` (Discord already parsed the value).
- `MessageCommandOption` — wraps a value parsed manually from the message text.
### Procedural option configuration — `CommandOptionsBuilder`
`CommandOptionsBuilder` implements JDA's `SlashCommandData` and acts as a proxy over JDA's internal
`CommandDataImpl`. A command builds its argument surface in `registerCommandOptions(builder)` by
adding options, subcommands, and subcommand groups. The same declaration is used both to:
1. register the slash command with Discord (see [registration](#registration-lifecycle)), and
2. drive the message-command parser (which tries to match tokens in the message against the declared
`OptionData`).
## Command management
### `CommandManager`
Responsible for **discovery** and **registration**.
- **Discovery** — at `init()`, uses [`ReflectionHelper`](utilities-and-bootstrap.md#reflectionhelper)
to scan for `Command` implementations and instantiate them.
- **Storage** — commands are kept in a `ConcurrentHashMap<String, CommandContainer>` keyed by command
name.
- **Slash registration** — `registerSlashCommands(JDA)` converts each command's declaration into JDA
`SlashCommandData` and pushes it to Discord's API.
### `CommandContainer`
Wraps a `Command` instance together with its JDA metadata (`CommandDataImpl`). It also builds the
"usage" string shown by the help command.
### `CommandCategory`
Enum grouping commands for help/display purposes (e.g. `ADMINISTRATION`, `UTILITY`, `MUSIC`,
`CRYPTO`, and an unlisted/hidden group for owner/debug commands).
### `CommandExecutionResult`
The value returned by `execute(...)`. It encodes the outcome and any response payload:
- `success(...)` — normal success (optionally with a message/embed to send).
- `error(...)` / `warning(...)` — failure/advisory outcomes with a message.
- `none()` — nothing to send synchronously (the command handles its own async response, e.g. music).
- May also carry a follow-up **`Task`** so a command can install an interactive
[task](tasks.md) (e.g. buttons) as part of its result.
## The dispatch flow — `CommandProcessor`
`CommandProcessor` is constructed with the `CommandContext` and driven by `dispatch()`:
1. **Resolution** — match the invoked name against a `CommandContainer` in `CommandManager`.
2. **Validation**
- command enabled?
- guild-only / slash-only constraints satisfied?
- **permissions** — `canBeExecutedBy(...)` and, for admin commands, `needAdmin()` verified against
the bot owner (`NETNEUTRALITY_BOT_OWNER`) and the member's guild-admin status.
3. **Usage parsing (`checkUsage`)** — detect whether a subcommand or subcommand group is being
invoked and record the active subcommand on the context.
4. **Option parsing**
- **Slash** — Discord already parsed the arguments; map each `OptionMapping` to a
`SlashCommandOption`.
- **Message** — `MessageCommandContext` tokenizes the message and matches tokens against the
declared `OptionData` (resolving mentions to members/roles, parsing integers/booleans, etc.).
5. **Execution** — call `command.execute(context)`.
6. **Result handling** — act on the returned `CommandExecutionResult`: send success/error/warning
responses, or, if a `Task` was returned, register it with the [`TaskManager`](tasks.md).
Errors are surfaced through the exception hierarchy
([`CommandExecutionException`](utilities-and-bootstrap.md#exceptions), and
`GracefulContextError` for user-facing, non-logged failures).
## Registration lifecycle
```
App.startup()
├─ CommandManager.init() // reflective discovery → ConcurrentHashMap
└─ CommandManager.registerSlashCommands(jda) // push SlashCommandData to Discord
```
At runtime, an incoming command event becomes a `CommandContext` in the
[`EventsHandler`](events.md), which calls `new CommandProcessor(context).dispatch()`.
## Command catalog
Concrete commands live under `me.bobert.netneutrality.feature.*.command`, grouped by feature package:
| Command | Package | Type | Description |
|---|---|---|---|
| `BlacklistCommand` | `admin` | Slash | Manage the server's word blacklist. |
| `ReactionCommand` | `admin` | Slash | Manage emoji-reaction roles on the server. |
| `HelpCommand` | `basic` | Slash | List every command with its usage. |
| `PingCommand` | `basic` | Slash | Reply with the gateway ping. |
| `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). |
| `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. |
| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the GitLab report flow). |
| `PlayCommand` | `music` | Slash | Search/queue and start playback. |
| `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. |
| `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. |
| `ClearQueueCommand` / `ShuffleCommand` | `music` | Slash | Clear / shuffle the queue. |
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
### Representative commands
**`PingCommand` (simplest slash command)** — extends `AbstractCommand`, overrides `execute` to return
`CommandExecutionResult.success(...)` containing the JDA gateway ping. It illustrates the minimal
execution path (no options, synchronous response).
**`ConfigureCommand` (complex slash command)** — uses `registerCommandOptions` to declare several
`SubcommandData` (e.g. music-volume, error-logging). In `execute`, it switches on
`context.getSubcommand()` and reads typed values via `context.getOption(name, resolver)`, then
persists them through the [guild settings service](persistence.md#service-layer).
**`PlayCommand` (music)** — extends `MusicCommand` (so it enforces guild-only + music role/channel).
It declares a single required `STRING` query option, delegates loading to
[`MusicManager`](music.md), and returns `CommandExecutionResult.none()` because the audio subsystem
produces the user-facing response asynchronously (often installing a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with choice buttons).
+154
View File
@@ -0,0 +1,154 @@
# Contexts
**Packages:** `me.bobert.netneutrality.framework.context` (framework),
`me.bobert.netneutrality.framework.context` (concrete implementations)
A **Context** is a high-level abstraction over a Discord event. It hides the differences between the
many JDA event types (slash command, message, button, modal, reaction, …) behind a single, uniform
API for reading who/where the event came from and for replying to it.
Contexts are created either by the [`EventsHandler`](events.md) when Discord triggers an event, or by
another context further down a flow (for example, a reply produces an [`AsyncContext`](#asynccontext)
that can be edited later). A context can be handled by one of three flows depending on its type and
the event it wraps: the [command processor](commands.md), the [task manager](tasks.md), or a simple
lambda. **The id of a context only matters for tasks** — the task manager uses `getId()` to locate
the `Task` that should run (see [tasks.md](tasks.md)).
## Type hierarchy
```
Context (interface)
├── InteractionContext (interface) ── reply/defer/ack via IReplyCallback + InteractionHook
├── NoMessageContext (interface) ── defaults for events without a triggering message
│ └── MessageContext (interface) ── adds a triggering Message (prefix commands, replies)
│
└── AbstractContext (abstract, implements Context) ── stores JDA/Guild/Channel/Member/User
├── CommandContext (abstract) ── command flow; holds CommandOptions + subcommand metadata
│ ├── SlashCommandContext
│ ├── MessageCommandContext
│ ├── MessageMenuContext
│ └── UserMenuContext
├── TaskContext (abstract) ── task flow; abstract getId(); contextData state map
│ ├── ButtonTaskContext
│ ├── EmojiTaskContext
│ ├── MessageTaskContext
│ └── ModalTaskContext
├── AsyncContext ── wraps the result of a previous reply (edit/follow-up)
└── MusicChannelContext ── synthetic context targeting a guild's music channel
```
## Core framework types
### `Context` (interface)
The root contract for every context. Notable methods:
- `getJDA()`, `getGuild()`, `getChannel()`, `getGuildUser()` (`Member`), `getDiscordUser()` (`User`)
- `reply(...)` — unified send; overloads support plain text, embeds, message components, an
`ephemeral` flag, and an async callback that receives an [`AsyncContext`](#asynccontext) for
follow-up edits.
- `defer(ephemeral, consumer)` — signals "thinking…" and later completes the response.
- `ack(ephemeral)` — minimal acknowledgement of the event.
- `getInteractionHook()` — the JDA `InteractionHook` when the underlying event is an interaction.
### `AbstractContext` (abstract class)
Base implementation that stores the common JDA entities (`JDA`, `Guild`, `MessageChannel`, `Member`,
`User`) and exposes them via the `Context` getters. Its constructor also sets the **Sentry user**
(id + effective name) so any error captured while handling this context is attributed to the right
Discord user:
```java
if (user != null) {
io.sentry.protocol.User sentryUser = new io.sentry.protocol.User();
sentryUser.setId(user.getId());
sentryUser.setUsername(user.getEffectiveName());
Sentry.setUser(sentryUser);
}
```
### `InteractionContext` (interface)
Behavioral mix-in for events that are JDA `Interaction`s (slash commands, buttons, modals, context
menus). It implements `reply`/`defer`/`ack` in terms of `IReplyCallback` and `InteractionHook`, so
these contexts can reply ephemerally, defer, and edit the original response.
### `NoMessageContext` / `MessageContext` (interfaces)
- `NoMessageContext` — provides sensible defaults for events that have **no** triggering message
(e.g. reactions, interactions).
- `MessageContext extends NoMessageContext` — adds access to a triggering `Message`, used by prefix
commands and by replies that drive step tasks.
## Command contexts
### `CommandContext`
Abstract base for the [command flow](commands.md). It carries the parsed
[`CommandOption`s](commands.md#unified-options) and the active subcommand / subcommand-group metadata,
and exposes type-safe option access, e.g. `getOption(name, resolver)`. Concrete subclasses adapt a
specific JDA event to this API.
| Class | Wraps | Notes |
|---|---|---|
| `SlashCommandContext` | `SlashCommandInteractionEvent` | Options already parsed by Discord; maps `OptionMapping` → `SlashCommandOption`. Interaction-based replies. |
| `MessageCommandContext` | `MessageReceivedEvent` (prefix) | Contains the **manual parser** that turns the raw message string into `CommandOption`s (mentions, integers, booleans, …). Message-based replies. |
| `MessageMenuContext` | `MessageContextInteractionEvent` | "Apps → …" action on a message; exposes the target `Message`. |
| `UserMenuContext` | `UserContextInteractionEvent` | "Apps → …" action on a user; exposes the target `User`. |
## Task contexts
### `TaskContext`
Abstract base for the [task flow](tasks.md). Two things distinguish it:
- **`getId()` (abstract)** — the id used by `TaskManager` to find the matching `Task`. How the id is
derived depends on the concrete subtype (see the table below and [tasks.md](tasks.md#the-task-id)).
- **`contextData` map** — a small state bag used to carry values between steps of a multi-step flow.
| Class | Wraps | `getId()` derived from |
|---|---|---|
| `ButtonTaskContext` | `ButtonInteractionEvent` | `getComponentId()` of the clicked button |
| `ModalTaskContext` | `ModalInteractionEvent` | modal `customId` |
| `EmojiTaskContext` | `MessageReactionAddEvent` | composite `guildId:messageId` |
| `MessageTaskContext` | `MessageReceivedEvent` (no prefix) | the id of the message being replied to (drives step tasks) |
## Reply mechanism & `AsyncContext`
### `AsyncContext`
`me.bobert.netneutrality.framework.context.AsyncContext` wraps the **result** of a previous reply — either an
`InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
expressed the same way regardless of the original trigger:
```java
context.reply("Initial response", async -> {
// 'async' is an AsyncContext for the message just sent
async.edit("Updated response");
});
```
Under the hood:
- **Interactions** — edits/follow-ups go through `hook.editOriginal()` / `hook.sendMessage()`.
- **Messages** — edits/follow-ups go through `message.editMessage()` / `message.reply()`.
This is what makes the command and task code path-agnostic: authors write against `Context` /
`AsyncContext` and never branch on "was this a slash command or a text message?".
### `MusicChannelContext`
A synthetic `AbstractContext` that targets a guild's configured **music channel** rather than the
channel an event came from. The music subsystem uses it to post now-playing / queue messages into the
correct channel even when playback state changes without a fresh user interaction. See
[music.md](music.md).
## End-to-end summary
| Event | Context | Flow | Async result wraps |
|---|---|---|---|
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command | `InteractionHook` |
| `MessageReceivedEvent` (prefix) | `MessageCommandContext` | Command | `Message` |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task | `InteractionHook` |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task | `Message` |
| `ModalInteractionEvent` | `ModalTaskContext` | Task | `InteractionHook` |
+102
View File
@@ -0,0 +1,102 @@
# Event Handling & Routing
**Package:** `me.bobert.netneutrality.platform.event`
**Primary class:** `EventsHandler`
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
(`jda.addEventListener(new EventsHandler())` in `App.startup()`). It is the funnel through
which **every** gateway event passes. Its job is to translate a raw JDA event into a
[Context](contexts.md) and route that context to the correct subsystem.
## Listener registry
Listeners are held in a static, thread-safe map keyed by the JDA event class:
```java
private static final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners
= new ConcurrentHashMap<>();
```
Each entry is a **factory function**: given an event of that type, it returns a `Context` (or `null`).
The map is populated once in a `static {}` block.
### `addListener` vs `addGenericListener`
- `addListener(Class<T>, Function<? super T, Context>)` — registers a factory that produces a
`Context` to be routed onward (command or task flow).
- `addGenericListener(Class<T>, Consumer<T>)` — registers a factory that runs a side-effecting
consumer and returns `null`, so nothing is routed onward. This is the **lambda flow** for simple
events.
## Registered events
| JDA event | Produces | Flow |
|---|---|---|
| `MessageReceivedEvent` (prefix match) | `MessageCommandContext` | Command |
| `MessageReceivedEvent` (no prefix) | `MessageTaskContext` | Task |
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command |
| `UserContextInteractionEvent` | `UserMenuContext` | Command |
| `MessageContextInteractionEvent` | `MessageMenuContext` | Command |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task |
| `ModalInteractionEvent` | `ModalTaskContext` | Task |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task |
| `GuildJoinEvent` | — (consumer) | Lambda |
| `GuildLeaveEvent` | — (consumer) | Lambda |
### Message routing detail
For `MessageReceivedEvent` the factory:
1. Ignores messages authored by bots and webhook messages (returns `null`).
2. Reads the configured prefix (`NETNEUTRALITY_BOT_PREFIX`, default `$`).
3. If the trimmed message starts with the prefix → **`MessageCommandContext`** (command flow).
4. Otherwise → **`MessageTaskContext`** (task flow). This lets ordinary messages participate in the
task system, e.g. as a reply that advances a [step task](tasks.md#33-step-tasks).
### Guild lifecycle (lambda flow)
- `GuildJoinEvent` — resolves `GuildService.get(guild)`; if debug logging is enabled for that guild,
sends a notice to the debug channel.
- `GuildLeaveEvent` — `GuildService.get(guild).deleteGuild()` removes the guild's stored data.
## Dispatch algorithm
```java
@Override
public void onEvent(@Nonnull GenericEvent event) {
handleEvent((Event) event);
}
private void handleEvent(Event event) {
Class<?> eventClass = event.getClass();
while (eventClass != null) {
if (listeners.containsKey(eventClass)) {
Context context = listeners.get(eventClass).apply(event);
if (context instanceof CommandContext c) handleCommand(c);
if (context instanceof TaskContext t) handleTask(t);
break;
}
eventClass = eventClass.getSuperclass(); // walk up the class hierarchy
}
}
```
Key points:
- **Superclass walk** — if the exact event class is not registered, the handler walks up the
superclass chain until it finds a match. This means a listener registered for a base event type
also catches subclasses.
- **Routing by context type** — the resulting object is routed purely by its runtime type:
a `CommandContext` goes to `CommandProcessor.dispatch()`, a `TaskContext` goes to
`TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
## Extending the event system
To handle a new event type:
1. Add an `addListener(SomeEvent.class, SomeContext::new)` (or `addGenericListener(...)` for a
consumer-only side effect) to the `static {}` block.
2. If it should run the command or task flow, make the produced context extend
[`CommandContext`](contexts.md#commandcontext) or [`TaskContext`](contexts.md#taskcontext)
respectively.
+93
View File
@@ -0,0 +1,93 @@
# Music
**Package:** `me.bobert.netneutrality.feature.music`
**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube
source 1.18.2.
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
sources, queueing, scheduling, and streaming audio into a Discord voice channel.
## Key classes
| Class | Responsibility |
|---|---|
| `MusicManager` | Central per-guild coordinator; owns the `AudioPlayer` and `TrackScheduler`. |
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
| `TrackScheduler` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). |
| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. |
| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). |
| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). |
| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. |
| `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
has its own player, scheduler, and queue.
## Startup
`MusicManager.initPlayerManager()` (called from
[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer
`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom
[Udio source](#custom-udio-source), and HTTP).
## Audio pipeline (end-to-end)
```
/play query
│
▼
MusicManager.play(query, ctx)
│ SourceFinder identifies the source
▼
audioPlayerManager.loadItemOrdered(guild, identifier, new TrackLoadingHandler(...))
│
├─ trackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons)
├─ noMatches ──▶ reply "nothing found"
└─ loadFailed ──▶ reply / log error
│
▼
QueueManager: if the player is idle, start immediately; else append to the deque
│
▼
AudioPlayerSendHandler streams PCM/Opus frames to the voice channel
│
▼ (track ends)
TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
```
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
now-playing/queue displays and vote-skip.
- **Playlists / search** — playlist and multi-result loads produce a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
- **Stuck tracks** — `TrackScheduler.onTrackStuck` attempts recovery (reload/skip).
- **Output** — playback messages are posted through a
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
music channel.
## Custom Udio source
`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist,
duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the
page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the
`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's
`Mp3AudioTrack`.
## Guild lifecycle & cleanup
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the player has been idle.
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the
static map.
- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
## Related commands
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
[commands.md](commands.md#command-catalog).
+128
View File
@@ -0,0 +1,128 @@
# Persistence & Database
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
`me.bobert.netneutrality.db` (entities, repositories, services).
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`.
## Stack
| Concern | Technology |
|---|---|
| ORM | Hibernate 7.1.9 (Jakarta JPA) |
| Engine | MariaDB 11 (per `docker-compose.yml`), MySQL Connector/J 9.7.0 driver, InnoDB |
| Pooling | HikariCP (via `hibernate-hikaricp`) |
| Migrations | Flyway 11.20.3 (`flyway-core` + `flyway-mysql`) |
Configuration is externalized via `Settings` (env vars / `bot.properties`):
`MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`.
## Session & migration lifecycle
Both are wired in at [startup](architecture.md#startup-lifecycle):
1. **`SessionManager.getSessionFactory()`** — lazily builds the Hibernate `SessionFactory`. It reads
the static config from `hibernate.cfg.xml` (dialect `MySQLDialect`, driver
`com.mysql.cj.jdbc.Driver`, the Hikari connection provider, and the mapped entity list) and
overrides the JDBC url/user/password from environment variables:
```java
settings.put(Environment.JAKARTA_JDBC_URL,
String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
settings.put(Environment.JAKARTA_JDBC_USER, dbUser);
settings.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
```
2. **`DatabaseMigration.migrate()`** — runs `flyway.migrate()` against the same database, applying the
versioned scripts under `src/main/resources/db/migration/` (starting with
`V1__initial_schema.sql`). The Flyway Maven plugin (`flyway-dev.sh`/`.ps1`) is available for local
development, using `FLYWAY_URL` / `FLYWAY_USER` / `FLYWAY_PASSWORD`.
3. **`SessionManager.shutdown()`** — destroys the service registry / releases the pool on shutdown.
Repositories obtain sessions through the factory; transactional access uses helpers in
[`JpaUtils`](utilities-and-bootstrap.md) (open session, run in a transaction, close).
## Entity catalog
Entities live in `me.bobert.netneutrality.data.entity` and are prefixed with `O`. `OGuild` is the hub
of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
| Entity | Table | Key | Relationships / notes |
|---|---|---|---|
| `OGuild` | `guilds` | `guild_id` (String) | Central entity; one-to-many with settings, blacklist, log messages, logging. |
| `OGuildSetting` | `guild_settings` | `id` (auto int) | Many-to-one `OGuild`; key/value guild settings. |
| `OBlacklist` | `blacklist` | `id` (auto int) | Many-to-one `OGuild`; blacklisted words. |
| `OLogging` | `logging` | `OLoggingId` (composite) | Composite key `guild_id` + `channel_id`; `@MapsId` to `OGuild`; debug/error log channel config. |
| `OLoggingId` | — (embeddable) | — | `@Embeddable`/`@IdClass` for the composite key. |
| `OLogMessage` | `log_message` | `id` (auto int) | Many-to-one `OGuild`; message logging history. |
| `OTask` | `tasks` | `id` (auto int) | [Persistable task](tasks.md#31-persistable-tasks) state; unique `task_id`, class name, JSON `payload`. |
| `OWebUser` | `web_users` | `id` (String) | Web dashboard user (tokens/expiry). *Present in schema & code but not listed in `hibernate.cfg.xml`.* |
### ER overview
```
┌───────────────┐
│ OGuild │ (guilds.guild_id)
└───────┬───────┘
┌───────────┬───────┼────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
OGuildSetting OBlacklist OLogging OLogMessage (…)
guild_settings blacklist logging log_message
(FK guild) (FK guild) (PK guild+ (FK guild)
channel)
OTask (tasks) OWebUser (web_users) ── standalone
```
`OTask` and `OWebUser` are not tied to the guild graph.
## Repository layer
A lightweight generic repository pattern is used.
- **`Repository<O>`** (interface) — generic CRUD: `findAll()`, `findById(String)`, `save(O)` (via
`session.merge()`), `deleteBy(String)`.
- **`BlacklistRepository`** — find-by-guild and case-insensitive word lookups.
- **`GuildRepository`** — CRUD for guilds.
- **`GuildSettingRepository`** — set/get setting values; removes rows that equal the default value.
- **`MessageRepository`** — message logging / edit history.
- **`TaskRepository`** — CRUD for `OTask` rows (used by the task persistence flow).
## Service layer
Services wrap repositories with business logic and **caching**.
- **`GuildService`** — gateway for guild operations; caches one `GuildService` per guild id
(`ConcurrentHashMap`); `findOrCreate` guarantees a guild row exists; `deleteGuild()` on leave;
`isDebugEnabled()` / debug-channel helpers used by [`EventsHandler`](events.md) and
[`LogHandler`](utilities-and-bootstrap.md#loghandler).
- **`BlacklistService`** — maintains a `blacklistCache` (guild id → set of words) for fast message
filtering.
- **`GuildSettingService`** — reads/writes guild settings backed by [`GSettings`](#gsettings); caches
values in `settingsCache` and reloads on change; used by the
[web API settings endpoints](web-api.md).
- **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON
for the `tasks` table and reconstructs them at startup via
[`TaskRegistry`/`TaskFactory`](tasks.md#31-persistable-tasks).
## GSettings
`GSettings` is an **enum** of the available per-guild configuration keys and their defaults:
| Setting | Default |
|---|---|
| `MUSIC_ROLE` | (none) |
| `MUSIC_CHANNEL` | (none) |
| `MUSIC_VOLUME` | `30` |
| `MUSIC_MAXPLAYLISTSIZE` | `20` |
`GuildSettingService` looks up an `OGuildSetting` row by the enum `name()`; if no row exists for a
guild/setting, the enum's `defaultValue` is used. This is how `MusicCommand` obtains the guild's music
role/channel and how `ConfigureCommand` persists changes.
## Schema (`V1__initial_schema.sql`)
The initial migration creates the tables backing the entities above (`guilds`, `guild_settings`,
`blacklist`, `logging`, `log_message`, `tasks`, `web_users`), with `guild_id` foreign keys and
`ON DELETE CASCADE` from the guild-scoped tables to `guilds`. Subsequent schema changes should be
added as new `V2__…`, `V3__…` Flyway scripts in the same directory.
+315
View File
@@ -0,0 +1,315 @@
# Tasks
**Packages:** `me.bobert.netneutrality.framework.task` (framework),
`me.bobert.netneutrality.feature.*.task` (concrete tasks)
Tasks are one of the more advanced concepts in the bot. A **Task** is a unit of logic that is looked
up and executed by **id** when a matching Discord event arrives, as opposed to a
[command](commands.md) which is looked up by name.
The original motivation was **Discord interactions** (buttons). When a user clicks a button, Discord
sends a `ButtonInteractionEvent` whose id is `getComponentId()`. A `Task` with that same id was
inserted earlier (typically by the command that created the button), so the `TaskManager` can find
the task and execute it. The system has since grown to cover several task subtypes:
| Subtype | Trigger | Id source |
|---|---|---|
| [Button task](#button-emoji-and-modal-tasks) | `ButtonInteractionEvent` | button `componentId` |
| [Modal task](#button-emoji-and-modal-tasks) | `ModalInteractionEvent` | modal `customId` |
| [Emoji task](#button-emoji-and-modal-tasks) | `MessageReactionAddEvent` | composite `guildId:messageId` |
| [Step task](#33-step-tasks) | replied-to message | last-message id (assigned dynamically) |
| [Built-in task](#32-built-in-tasks) | button with a hardcoded id | hardcoded id |
| [Persistable task](#31-persistable-tasks) | any of the above, restored from DB | as stored |
| [Scheduled task](#34-scheduled-tasks) | timer | n/a (time-based, not id-based) |
## The `Task` id
The id is the **primary lookup key** used by `TaskManager`. How it is derived depends on the subtype:
- **Buttons** → the button's `componentId`.
- **Modals** → the modal's `customId`.
- **Emoji reactions** → a composite `guildId:messageId` (a reaction is tied to a specific message).
- **Step tasks** → start life with a random UUID, then re-key themselves to the DM/last-message id
once that message exists (see [step tasks](#33-step-tasks)).
## `Task` contract & lifecycle
`Task` (interface) defines `execute(TaskContext)` and returns a **`Lifecycle`** value that tells the
`TaskManager` what to do with the task afterwards:
| Lifecycle | Meaning |
|---|---|
| `BUILTIN` | Task is static and stays registered indefinitely. |
| `KEEP` | Keep the task in memory after execution (can run again). |
| `REMOVE_SELF` | Remove this task after execution (one-shot). |
| `REMOVE_SELF_AND_CHILDREN` | Remove this task and all of its [child tasks](#trait-interfaces). |
### Task base classes (`core.tasks.impl`)
```
AbstractTask (implements Task)
├── ButtonTask ── executed from a ButtonTaskContext
├── EmojiTask ── executed from an EmojiTaskContext
├── ModalTask ── executed from a ModalTaskContext
├── GenericCustomIdTask ── task keyed by an explicit custom id
├── GenericRandomIdTask ── task keyed by a generated random id
├── AbstractBuiltInTask ── base for hardcoded, reflectively-loaded tasks
├── AbstractPersistableTask ── base for DB-backed tasks (implements PersistableTask)
└── AbstractStepTask ── base for multi-step Q&A flows (implements StepTask)
```
## `TaskManager`
`me.bobert.netneutrality.framework.task.TaskManager` is the registry, lookup, and execution
coordinator.
### Registration — `insert`
- Adds the task to the active map keyed by its id.
- If the task is a [`PersistableTask`](#31-persistable-tasks), it is also **saved to the database**
via [`TaskService`](persistence.md#service-layer).
- If the task is a [`StepTask`](#33-step-tasks) without a message id yet, it is held in a separate
`pendingTasks` collection until its id is assigned.
### Execution — `handle(TaskContext)`
1. Look up the task by `context.getId()`.
2. For ordinary (non-built-in, non-persistable) tasks, acquire a **`ReentrantLock`** so the same task
cannot be executed concurrently (prevents double-clicks / races). Tasks that share a
[parent](#trait-interfaces) share the same lock id.
3. Call `task.execute(context)`.
4. Apply the returned `Lifecycle` (keep / remove / remove-with-children); persistable tasks removed
here are also deleted from the DB.
5. If the task is [`Deletable`](#trait-interfaces), delete the original message/interaction.
6. Unless the task is [`NotAcknowledgeable`](#trait-interfaces), acknowledge the interaction.
### Startup — `init`
Called from `App.startup()`:
1. Reflectively load all [`BuiltInTask`](#32-built-in-tasks) implementations.
2. Reflectively load all [`ScheduledTask`](#34-scheduled-tasks) implementations and register them
with [`ScheduledTaskManager`](threading.md).
3. Load persistable tasks from the database (via `TaskService`) and reconstruct them (see
[persistence rules](#31-persistable-tasks)).
## Button, emoji and modal tasks
These are the "interaction" tasks and the original purpose of the subsystem:
- **`ButtonTask`** — executed when a button whose `componentId` matches the task id is clicked. The
matching context is a [`ButtonTaskContext`](contexts.md#task-contexts).
- **`ModalTask`** — executed when a modal is submitted; matched by the modal `customId`. A modal is
typically opened by another interaction (e.g. `ReportIssueTask` opens a title/description modal).
- **`EmojiTask`** — executed when a reaction is added to a specific message; matched by the composite
`guildId:messageId` id.
The generic helpers `GenericCustomIdTask` / `GenericRandomIdTask` let a command install an ad-hoc,
function-backed task under a chosen or generated id (e.g. the generic `AcceptButton` / `CancelButton`
under `tasks/generic`).
## 3.1 Persistable tasks
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
`core.tasks.persistence.TaskRegistry`, `core.tasks.persistence.TaskFactory`;
resource `src/main/resources/persistable-tasks.json`; entity
[`OTask`](persistence.md#entity-catalog).
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup. The
rules for *how* a persistable task can be defined and reconstructed live in `TaskRegistry` and
`TaskFactory`.
### The mapping file — `persistable-tasks.json`
The file declares, for each persistable task class, the constructor parameters and their types:
```json
{
"tasks": [
{
"class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask",
"params": ["guildId", "channelId", "messageId", "emojiMapping"],
"types": ["string", "string", "string", "map"]
}
]
}
```
Schema:
| Key | Meaning |
|---|---|
| `tasks` | array of task mappings |
| `class` | fully-qualified class name; **must** implement `PersistableTask` |
| `params` | ordered list of payload field names |
| `types` | parallel list of types for those fields |
Supported types (`TaskFactory.ParamType`): `string` → `String`, `int` → `Integer`, `map` → `Map`.
### `TaskRegistry` — loading the mappings
`TaskRegistry.loadTaskConfig()` reads `persistable-tasks.json` from the classpath and, for each entry,
registers a `TaskCreator` (a functional interface `PersistableTask fromPayload(JSONObject payload)`)
keyed by class name:
```java
taskCreators.put(className, payload -> {
try {
return TaskFactory.createTaskInstance(className, params, types, payload);
} catch (Exception e) {
logger.error("Failed to create instance of '{}': {}", className, e.getMessage());
return null;
}
});
```
Entries missing `class`, `params`, or `types` are logged and skipped. Consumers later look up a
creator via `TaskRegistry.getTaskCreator(className)`.
### `TaskFactory` — reconstructing an instance
`TaskFactory.createTaskInstance(className, params, types, payload)`:
1. Validates that `params.length == types.length`.
2. Loads the class and verifies it implements `PersistableTask`.
3. For each declared parameter, converts the JSON `types[i]` to a `ParamType`, then extracts
`payload[params[i]]` as the corresponding Java type:
- `string` → `payload.getString(name)`
- `int` → `payload.getInt(name)`
- `map` → `payload.getJSONObject(name).toMap()`
4. Reflectively resolves the constructor whose parameter types match `types` and instantiates it with
the extracted values.
```java
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
```
> **Rule of thumb for adding a persistable task:** create a class that implements `PersistableTask`
> (usually by extending `AbstractPersistableTask`), give it a constructor whose parameters match the
> `params`/`types` you declare, ensure it serializes those same fields into its JSON payload, and add
> a matching entry to `persistable-tasks.json`. The payload is stored in the
> [`OTask.payload`](persistence.md#entity-catalog) column and reconstructed via the flow above on
> startup.
### Persistence round-trip
```
insert(task) ──▶ TaskService serializes payload ──▶ OTask row (task_id, class, payload JSON)
startup ──▶ TaskService reads OTask rows ──▶ TaskRegistry.getTaskCreator(class)
.fromPayload(payload) ──▶ TaskFactory ──▶ instance
```
## 3.2 Built-in tasks
**Classes:** `BuiltInTask`, `AbstractBuiltInTask`.
Built-in tasks are hardcoded tasks with hardcoded ids, conceptually similar to commands. They are
loaded reflectively at startup (in `TaskManager.init()`) and stay registered for the lifetime of the
bot (`Lifecycle.BUILTIN`).
A button that triggers a built-in task is created from its class:
```java
Button button = BuiltInTask.createButton(SomeBuiltInTask.class);
```
This produces a JDA `Button` whose component id equals the built-in task's hardcoded id, so clicking
it routes straight to that task. Examples: the help buttons `BotInfo`, `CommandHelp`, `ReportBug`
(`tasks/help`).
## 3.3 Step tasks
**Classes:** `StepTask` (interface), `AbstractStepTask`.
Step tasks ask the user a series of questions and run logic based on each answer. Because Discord has
no native "conversation" primitive, the abstraction tracks the **last message id** and only runs the
next step when *that* message is replied to.
Flow:
1. A step task is created (e.g. by a setup command) and sends a prompt message; initially it is keyed
by a random UUID and held in `pendingTasks`.
2. Once the prompt message exists, the task **re-keys itself to that message's id** and moves into the
active `tasks` map.
3. When the user replies to that message, the incoming `MessageReceivedEvent` becomes a
[`MessageTaskContext`](contexts.md#task-contexts) whose id is the replied-to message id — so
`TaskManager` finds the step task.
4. `executeStep` validates/processes the answer and returns a `StepResult`:
success, failure, or **next step** (which installs the following step task).
`AbstractStepTask` provides answer validation keyed to a step "type" (e.g. a role id, an emoji, …).
## 3.4 Scheduled tasks
**Classes:** `ScheduledTask`, coordinated by
[`ScheduledTaskManager`](threading.md#scheduledtaskmanager).
Scheduled tasks are time-based background jobs (as opposed to id-based interaction tasks). They are
loaded reflectively at startup and registered with `ScheduledTaskManager`, which schedules them at a
fixed interval derived from the task's `TimeFrame` (e.g. minutely / hourly / daily / custom) and
cancels them all on shutdown.
> **Status:** the scheduled-task abstraction is intended to eventually replace the ad-hoc
> [`ThreadManager`](threading.md) threads and is still evolving. The concrete scheduled tasks that
> exist today are listed in the [catalog](#concrete-task-catalog).
## Trait interfaces
Tasks can opt into extra behavior by implementing marker/behavior interfaces in
`core.tasks.trait`:
| Trait | Effect |
|---|---|
| `Deletable` | After successful execution, `TaskManager` deletes the original message/interaction. |
| `NotAcknowledgeable` | Suppresses the default acknowledgement (the task sends its own response). |
| `WithButton` | The task can render itself as a JDA `Button` (`createButton()`). |
| `WithChildren` | The task tracks child tasks and can clean them up together (`REMOVE_SELF_AND_CHILDREN`). |
| `WithParent` | Links a child task to a parent; children share the parent's execution lock. |
## Concrete task catalog
| Task | Package | Base type | Description |
|---|---|---|---|
| `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. |
| `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. |
| `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. |
| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a GitLab issue. |
| `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. |
| `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. |
| `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. |
| `LoadPlaylistTask` | `music` | `ButtonTask` (with children) | Present playlist/search results as choice buttons. |
| `LoadTrackFromPlaylistTask` | `music` | `ButtonTask` | Load one specific track from the results. |
| `EmojiRoleFirstStep` | `reaction` | `AbstractStepTask` | Ask for the target role id. |
| `EmojiRoleSecondStep` | `reaction` | `AbstractStepTask` | Ask for the emoji to map. |
| `EmojiRoleModify` | `reaction` | `AbstractStepTask` | Modify an existing emoji-role mapping. |
| `EmojiRoleRemoveStep` | `reaction` | `AbstractStepTask` | Remove an emoji-role mapping. |
| `MusicManagerCleanup` | `scheduled` | `ScheduledTask` | Evict unused per-guild music managers. |
| `MusicPlayerAutoDisconnect` | `scheduled` | `ScheduledTask` | Disconnect from voice when idle. |
| `TaskCleanup` | `scheduled` | `ScheduledTask` | Periodically remove expired non-persistable tasks. |
### Worked example — emoji-role setup (step → persistable)
1. `EmojiRoleFirstStep` sends a prompt: "reply with the role id"; `AbstractStepTask` validates it is a
real role in the guild.
2. `executeStep` returns `StepResult.next(new EmojiRoleSecondStep(...))`, carrying the chosen role.
3. `EmojiRoleSecondStep` asks for the emoji; on a valid answer it creates an
`EmojiReactionRoleTask`.
4. That `EmojiReactionRoleTask` is `insert`ed into `TaskManager` — because it is a
[persistable task](#31-persistable-tasks) it is written to the `tasks` table and will be restored
on the next startup via the [`persistable-tasks.json`](#the-mapping-file--persistable-tasksjson)
mapping.
### Worked example — GitLab bug report (modal)
1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields.
2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values.
3. The task connects to GitLab (using `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` from
configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
### Worked example — music results (parent/children)
1. `LoadPlaylistTask` posts a message with a button per result (children:
`LoadTrackFromPlaylistTask` / `AcceptButton`).
2. All children share the parent's lock id ([`WithParent`](#trait-interfaces)), so simultaneous
clicks are serialized.
3. Selecting a result can return `REMOVE_SELF_AND_CHILDREN` to tear down the whole interaction.
+45
View File
@@ -0,0 +1,45 @@
# Threading & Scheduling
**Package:** `me.bobert.netneutrality.platform.concurrency`
This subsystem centralizes asynchronous execution and recurring jobs.
## Classes
| Class | Responsibility |
|---|---|
| `ThreadManager` | Static registry of `ExecutorService` instances shared across the bot. |
| `BaseThreadFactory` | Custom `ThreadFactory` giving threads meaningful names and an uncaught-exception handler. |
| `ScheduledTaskManager` | Schedules and cancels recurring [scheduled tasks](tasks.md#34-scheduled-tasks). |
## `ThreadManager`
- `getScheduledExecutor()` — a `ScheduledThreadPool` sized to the number of CPU cores.
- `getExecutor(name)` — returns (and caches) a named thread pool for ad-hoc async work.
- `shutdownExecutor()` — shuts the executors down cleanly; called from
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
## `BaseThreadFactory`
Names created threads (e.g. `web-api-1`) so they are identifiable in logs and stack dumps, and installs
an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
`UncaughtExceptionHandler` so crashes on worker threads are still routed through
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used both by `ThreadManager` and by the
[Web API](web-api.md) server pool.
## `ScheduledTaskManager`
Coordinates [`ScheduledTask`](tasks.md#34-scheduled-tasks) instances (discovered reflectively at
startup by [`TaskManager.init()`](tasks.md#taskmanager)):
- `schedule(ScheduledTask task)` — schedules the task at a fixed delay/interval derived from its
`TimeFrame` (minutely / hourly / daily / custom), using the shared scheduled executor.
- `cancelAll()` — cancels every scheduled task; called first in
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect`, and
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
> threads created via `ThreadManager`, giving a single, uniform way to express recurring background
> work. It is still evolving (see [tasks.md §3.4](tasks.md#34-scheduled-tasks)).
+164
View File
@@ -0,0 +1,164 @@
# Utilities, Bootstrap, Logging & Exceptions
This document covers the cross-cutting glue of the bot: the entry points, configuration, logging /
error tracking, the shared utility helpers, and the custom exception hierarchy.
## Bootstrap
**Package:** `me.bobert.netneutrality` / `me.bobert.netneutrality.bootstrap`
| Class | Role |
|---|---|
| `App` | The application: holds the process instance and the `JDA` handle, builds the `ApplicationContext`, runs `context.start()` / `context.stop()`, and is the JAR main class (`main()`). See [architecture.md](architecture.md). |
| `bootstrap.ApplicationContext` | Hand-rolled container: `register(T)`, `get(Class<T>)`, ordered `start()` / `stop()` for `Lifecycle` components. |
| `bootstrap.Lifecycle` | Optional component contract: `start() throws Exception`, default `stop()`, default `priority()`. |
| `NetNeutralityBot` | Currently an **empty placeholder** class under `framework` (reserved name; no logic yet). |
| `core.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
The full startup and shutdown sequences are documented in
[architecture.md](architecture.md#startup-lifecycle).
### `ApplicationContext` + `Lifecycle`
Domain services are constructed once in `App.buildContext()`, registered with the context,
and resolved later by type:
- `register(component)` — indexes the concrete class; if the component implements `Lifecycle` it
participates in ordered start/stop.
- `get(Class<T>)` — returns the first registered instance assignable to `T` (interfaces work).
- `start()` — sorts `Lifecycle` components by ascending `priority()` and starts them.
- `stop()` — stops them in the exact reverse order; individual failures are logged and swallowed so
remaining components still tear down.
Constructor injection for reflectively loaded commands/tasks is handled by
[`ReflectionHelper`](#reflectionhelper): the richest public constructor whose parameter types all
resolve via `context.get(type)` is preferred; otherwise the no-arg constructor is used.
**Interfaces used for injection** (implemented by the concrete managers registered above):
| Interface | Typical implementor |
|---|---|
| `platform.config.SettingsProvider` | `utils.Settings` |
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
| `feature.music.MusicService` | `music.MusicServiceImpl` |
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
static access points left are:
- `Settings.getInstance()` — early bootstrap only (`main()`, Sentry, token validation) before the
context exists; the same instance is registered as `SettingsProvider` for everything else.
- `App.getInstance()` — application root used by a couple of shutdown entry points
(`ShutdownCommand`). Prefer constructor injection of the needed collaborators where possible.
- `JpaUtils.bind(SessionProvider)` / `LogHandler.bind(GuildServiceProvider)` /
`ChannelMessagePurger.bind(ExecutorProvider)` — single well-defined static bind points for deep
utility graphs that are not constructor-injected (repositories, static log helpers).
## Configuration
### `Settings` / `SettingsProvider`
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements
`me.bobert.netneutrality.platform.config.SettingsProvider`.
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties`
file and overlays **environment variables** on top (environment variables win).
- **Early bootstrap:** `Settings.getInstance()` (process-wide, created once before the context).
- **Runtime injection:** depend on `SettingsProvider` and let `ApplicationContext` supply the same
instance. Prefer this path in commands, tasks, handlers, and services.
- `Settings.getVersion()` / `Settings.getDiscordInvite()` — thin static helpers used by the banner /
invite strings; they delegate to the bootstrap instance.
An example configuration file is `bot.properties.example`. Key configuration values:
| Key | Purpose |
|---|---|
| `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. |
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). |
| `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). |
| `NETNEUTRALITY_BOT_OWNER` | Bot owner user id (used for owner-only commands / permissions). |
| `NETNEUTRALITY_BOT_INVITE` | Invite link surfaced by help/info. |
| `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). |
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). |
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube playback auth/cipher settings for the [music](music.md) source. |
## Utility helpers
**Package:** `me.bobert.netneutrality.shared.util` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.platform.*`
| Class | Purpose |
|---|---|
| `Settings` | Configuration (see above). |
| `ReflectionHelper` | Classpath scanning / reflective instantiation (see below). |
| `ContextUtils` | Boilerplate helpers for building/handling [contexts](contexts.md). |
| `GuildUtils` | Guild-related helpers (lookups, permission checks). |
| `JpaUtils` | Session/transaction helpers for the [persistence](persistence.md) layer. Bound once to a `SessionProvider` during bootstrap (`JpaUtils.bind`). |
| `MusicUtils` | Voice-channel connectivity and DJ/permission checks for [music](music.md). Callers pass the guild `MusicManager` (no static music lookup). |
| `MiscUtils` | Assorted small helpers. |
| `ChannelMessagePurger` | Bulk message deletion (used by the purge command). Bound once to an `ExecutorProvider` during bootstrap. |
| `MessageFunction` | Standardized message/embed formatting. Help text builders take a `CommandRegistry` rather than a static command manager. |
| `UserFunction` | Standardized user/string formatting. |
### `ReflectionHelper`
Built on the `org.reflections` library, `ReflectionHelper` discovers and instantiates implementation
classes in specific packages. It is the mechanism behind the bot's "drop a class in the right package
and it's picked up" model, used to auto-load:
- [`Command`](commands.md) implementations (`CommandManager` / `CommandRegistry.start()`),
- [`BuiltInTask`](tasks.md#32-built-in-tasks) and [`ScheduledTask`](tasks.md#34-scheduled-tasks)
implementations (`TaskManager.start()`).
When given an `ApplicationContext`, `newInstance(Class, ApplicationContext)` selects the public
constructor with the most parameters that can all be resolved via `context.get(parameterType)`. If
no such constructor exists it falls back to the no-arg constructor. That is how music/admin/react
commands receive `MusicService`, `GuildServiceProvider`, `TaskManager`, etc. without static
singletons.
## Logging & error tracking
### `LogHandler`
**Class:** `me.bobert.netneutrality.platform.logging.LogHandler`.
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
1. **Log4j2** (configured by `log4j2.properties`; SLF4J is bridged via `log4j-slf4j2-impl`),
2. **Sentry** (when `NETNEUTRALITY_SENTRY_DSN` is configured), and
3. optionally a per-guild **Discord debug channel** (e.g. `sendMessageToDebugChannel(guild, msg)`),
gated by [`GuildService.isDebugEnabled()`](persistence.md#service-layer) /
[`OLogging`](persistence.md#entity-catalog) configuration.
The Sentry **user** context is attached automatically when a
[`AbstractContext`](contexts.md#abstractcontext-abstract-class) is created, so captured errors are
attributed to the Discord user that triggered them.
## Exception hierarchy
**Package:** `me.bobert.netneutrality.exceptions`
```
RuntimeException
└── ContextException ── carries a Context (guild/channel/user)
├── CommandExecutionException ── thrown during command processing
└── TaskExecutionException ── thrown during task execution
GracefulContextError ── user-facing, non-logged failure → ephemeral reply
ExecutorException ── Thread.UncaughtExceptionHandler → routes crashes to LogHandler
```
- **`ContextException`** — base runtime exception that carries the [`Context`](contexts.md) in which
it occurred, so downstream handling can respond in the right channel and attribute the error.
- **`CommandExecutionException`** / **`TaskExecutionException`** — specializations thrown by the
[command](commands.md) and [task](tasks.md) flows respectively.
- **`GracefulContextError`** — signals an expected, user-facing problem (e.g. bad input); when caught
it produces an ephemeral message to the user instead of a full error log/Sentry event.
- **`ExecutorException`** — an `UncaughtExceptionHandler` installed by
[`BaseThreadFactory`](threading.md#basethreadfactory) so exceptions escaping worker threads are still
captured by [`LogHandler`](#loghandler).
+51
View File
@@ -0,0 +1,51 @@
# HTTP Web API
**Package:** `me.bobert.netneutrality.platform.web` (client module) and
`me.bobert.netneutrality.platform.web.server` (server).
**Server:** JDK built-in `com.sun.net.httpserver.HttpServer` (no external web framework).
The bot exposes a small internal HTTP API for health monitoring, control, and guild introspection —
intended for an external dashboard/companion, not for end users in Discord.
## Server startup
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
It receives `SettingsProvider`, `GuildServiceProvider`, and suppliers for the live `JDA` /
shutdown callback via its constructor. On `start()` it:
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
- creates an `HttpServer` bound to that port,
- installs an `Executor` backed by a fixed thread pool of 10 threads named via
[`BaseThreadFactory`](threading.md),
- registers the handler contexts (below), and
- starts the server.
On `stop()` the server and its executor are shut down gracefully.
## Handler model
`AbstractHandler` (implements `HttpHandler`) is the base for all handlers and provides helpers such as
`sendJsonResponse(...)` and `sendStringResponse(...)`. Concrete handlers extend it and are mounted at
a path prefix.
| Handler | Base path | Endpoints |
|---|---|---|
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown; `/actions/settings/current/{guildId}` — current guild settings; `/actions/settings/reload/{guildId}` — invalidate & reload the settings cache. |
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings
endpoints).
## HTTP client — `WebModule`
`WebModule` wraps an `OkHttpClient` for the bot's own outbound HTTP calls (e.g. checking for updates
via GitLab). It keeps a small `LinkedHashMap`-based response cache limited to 50 entries.
## Notes
- There is no authentication layer documented on these endpoints; the API is expected to be bound to a
trusted/internal network (e.g. within the Docker network). `/actions/shutdown` is destructive, so the
port should not be exposed publicly.
+1 -1
View File
@@ -12,7 +12,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<main.class>me.bobert.netneutrality.NetNeutrality</main.class> <main.class>me.bobert.netneutrality.App</main.class>
<java.version>25</java.version> <java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>25</maven.compiler.source> <maven.compiler.source>25</maven.compiler.source>
@@ -0,0 +1,233 @@
package me.bobert.netneutrality;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings;
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.bootstrap.BotState;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.db.SessionManager;
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
import me.bobert.netneutrality.core.thread.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
import me.bobert.netneutrality.db.service.GuildServiceProviderImpl;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.hooks.EventsHandler;
import me.bobert.netneutrality.http.server.WebApi;
import me.bobert.netneutrality.music.MusicServiceImpl;
import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
import me.bobert.netneutrality.db.JpaUtils;
import me.bobert.netneutrality.utils.Settings;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
public class App {
private static final Logger logger = LogManager.getLogger(App.class);
/**
* Version string resolved from the early-bootstrap {@link Settings} singleton (properties must be
* readable before the {@link ApplicationContext} exists).
*/
public static final String VERSION = Settings.getVersion();
private static App instance;
private JDA jda;
private final ApplicationContext context = new ApplicationContext();
static void main() {
System.out.println("Starting App v" + VERSION);
// Settings is intentionally a process-wide bootstrap singleton: Sentry/env/token must be
// readable before the ApplicationContext is constructed.
String env = Settings.getInstance().get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = Settings.getInstance().get("NETNEUTRALITY_SENTRY_DSN");
if (sentryDsn != null) {
logger.info("Found Sentry DSN in config, setting up the SDK..");
Sentry.init(options -> {
options.setDsn(sentryDsn);
options.setEnvironment(env);
options.setTracesSampleRate(1.0);
options.setDebug(false);
options.setRelease(VERSION);
});
}
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
}
instance = new App();
}
public App() {
startup();
}
private void startup() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new RuntimeException("No bot token provided, exiting.");
}
buildContext();
try {
context.start();
} catch (Exception e) {
LogHandler.log(e);
System.exit(1);
}
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
}
/**
* Registers domain services and remaining startup/shutdown adapters with the
* {@link ApplicationContext}. Ascending {@link Lifecycle#priority()} drives startup order; the
* context stops components in the exact reverse order.
*/
private void buildContext() {
SettingsProvider settings = Settings.getInstance();
// Config first so SettingsProvider is resolvable for constructor injection.
context.register(settings);
// Allow ApplicationContext itself to be injected into components that need it.
context.register(context);
// Persistence: build the SessionFactory + run migrations; released last on shutdown.
// SessionManager binds itself into JpaUtils for repository access.
context.register(new SessionManager(settings));
// Executors: no explicit start today; shut down after music players are disconnected.
ExecutorProvider executors = context.register(new ThreadManager());
ChannelMessagePurger.bind(executors);
// Scheduled tasks (priority 55): constructed early so TaskManager can resolve it; cancelled
// early on shutdown, before executors.
context.register(new ScheduledTaskManager(executors));
// Command registry (priority 30).
CommandRegistry commands = context.register(new CommandManager(context));
// Task registry (priority 30).
TaskManager tasks = context.register(new TaskManagerImpl(context));
// Per-guild service cache (not a Lifecycle component).
GuildServiceProvider guildServices = context.register(new GuildServiceProviderImpl());
LogHandler.bind(guildServices);
JpaUtils.bindGuildServiceLookup(guildServices::get);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks));
BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown.
context.register(lifecycle(60, this::initJDA, this::disconnect));
// Register slash commands, attach the event handler and set the presence once JDA is ready.
context.register(lifecycle(65, () -> {
commands.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
jda.getPresence().setActivity(Activity.watching("you"));
}, () -> { }));
// Web API (priority 70) — Lifecycle with graceful stop.
context.register(new WebApi(settings, guildServices, this::getJDA, this::shutdown));
}
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
return new Lifecycle() {
@Override
public void start() throws Exception {
start.run();
}
@Override
public void stop() {
stop.run();
}
@Override
public int priority() {
return priority;
}
};
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
public static App getInstance() {
return instance;
}
public void initJDA() {
String token = Settings.getInstance().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() {
System.out.println("[Net Neutrality] Shutting down...");
context.stop();
System.out.println("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -1,138 +0,0 @@
package me.bobert.netneutrality;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import io.sentry.Sentry;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.core.db.DatabaseMigration;
import me.bobert.netneutrality.core.db.SessionManager;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.thread.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
import me.bobert.netneutrality.hooks.EventsHandler;
import me.bobert.netneutrality.http.server.WebApi;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.Settings;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
public class NetNeutrality {
private static final Logger logger = LogManager.getLogger(NetNeutrality.class);
public static final String VERSION = Settings.getVersion();
private static NetNeutrality instance;
private static JDA jda;
static void main() {
System.out.println("Starting NetNeutrality v" + VERSION);
String env = Settings.getInstance().get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = Settings.getInstance().get("NETNEUTRALITY_SENTRY_DSN");
if (sentryDsn != null) {
logger.info("Found Sentry DSN in config, setting up the SDK..");
Sentry.init(options -> {
options.setDsn(sentryDsn);
options.setEnvironment(env);
options.setTracesSampleRate(1.0);
options.setDebug(false);
options.setRelease(VERSION);
});
}
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
}
instance = new NetNeutrality();
}
public NetNeutrality() {
startup();
}
private void startup() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new RuntimeException("No bot token provided, exiting.");
}
SessionManager.getSessionFactory();
DatabaseMigration.migrate();
CommandManager.init();
MusicManager.initPlayerManager();
TaskManager.getInstance().init();
initJDA();
CommandManager.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler());
new WebApi(Integer.parseInt(Settings.getInstance().get("NETNEUTRALITY_API_PORT", "9000")));
jda.getPresence().setActivity(Activity.watching("you"));
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
}
public static NetNeutrality getInstance() {
return instance;
}
public void initJDA() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager.shutdown();
throw new RuntimeException("No bot token provided, exiting.");
}
try {
jda = JDABuilder
.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
.setAudioModuleConfig(
new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
LogHandler.log(e);
System.exit(1);
}
}
public void shutdown() {
System.out.println("[Net Neutrality] Shutting down...");
ScheduledTaskManager.cancelAll();
MusicManager.disconnectPlayers();
ThreadManager.shutdownExecutor();
disconnect();
SessionManager.shutdown();
System.out.println("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -0,0 +1,99 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* A small hand-rolled application container that owns wired instances and drives ordered
* startup/shutdown.
*
* <p>Components implementing {@link Lifecycle} are started in ascending {@link Lifecycle#priority()}
* order and stopped in the exact reverse order, giving symmetric, deterministic startup and
* shutdown without any DI framework.
*/
public final class ApplicationContext {
private static final Logger logger = LogManager.getLogger(ApplicationContext.class);
private final List<Lifecycle> components = new ArrayList<>();
private final Map<Class<?>, Object> instances = new HashMap<>();
/**
* Registers a component with the context. If the component implements {@link Lifecycle} it will
* participate in ordered start/stop. The instance is also indexed by its concrete type and can
* be resolved later via {@link #get(Class)}.
*
* @param component the component to register
* @param <T> the component type
* @return the same component, for fluent wiring
*/
public <T> T register(T component) {
if (component instanceof Lifecycle lifecycle) {
components.add(lifecycle);
}
instances.put(component.getClass(), component);
return component;
}
/**
* Resolves a previously registered instance assignable to the given type.
*
* @param type the requested type
* @param <T> the requested type
* @return the matching instance, or {@code null} if none was registered
*/
public <T> T get(Class<T> type) {
Object direct = instances.get(type);
if (direct != null) {
return type.cast(direct);
}
for (Object instance : instances.values()) {
if (type.isInstance(instance)) {
return type.cast(instance);
}
}
return null;
}
/**
* Starts all registered {@link Lifecycle} components in ascending priority order.
*
* @throws Exception if any component fails to start
*/
public void start() throws Exception {
components.sort(Comparator.comparingInt(Lifecycle::priority));
for (Lifecycle component : components) {
logger.debug("Starting component {} (priority {})", component.name(), component.priority());
component.start();
}
}
/**
* Stops all registered {@link Lifecycle} components in the reverse of their start order. Failures
* are logged and do not prevent the remaining components from being stopped.
*/
public void stop() {
List<Lifecycle> reversed = new ArrayList<>(components);
reversed.sort(Comparator.comparingInt(Lifecycle::priority));
Collections.reverse(reversed);
for (Lifecycle component : reversed) {
try {
logger.debug("Stopping component {} (priority {})", component.name(), component.priority());
component.stop();
} catch (Exception e) {
logger.error("Error while stopping component {}", component.name(), e);
}
}
}
}
@@ -1,11 +1,13 @@
package me.bobert.netneutrality.core; package me.bobert.netneutrality.bootstrap;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.TrackScheduler; import me.bobert.netneutrality.music.TrackScheduler;
import org.json.JSONArray; import org.json.JSONArray;
@@ -19,14 +21,32 @@ import java.util.Map;
public class BotState { public class BotState {
public static boolean SAFE = false; public static boolean SAFE = false;
private static volatile GuildServiceProvider guildServices;
private static volatile MusicService musicService;
/**
* Binds providers used by the optional state dump. Called once during bootstrap.
*
* @param guildServices the guild service provider
* @param musicService the music service
*/
public static void bind(GuildServiceProvider guildServices, MusicService musicService) {
BotState.guildServices = guildServices;
BotState.musicService = musicService;
}
//TODO: is it needed? //TODO: is it needed?
private static void saveGuildsState() { private static void saveGuildsState() {
Map<String, GuildService> services = GuildService.getServices(); if (guildServices == null || musicService == null) {
return;
}
Map<String, GuildService> services = guildServices.getServices();
JSONObject main = new JSONObject(); JSONObject main = new JSONObject();
JSONArray guildsArray = new JSONArray(); JSONArray guildsArray = new JSONArray();
for (MusicManager manager : MusicManager.getManagers()) { for (MusicManager manager : musicService.getManagers()) {
TrackScheduler trackScheduler = manager.getScheduler(); TrackScheduler trackScheduler = manager.getScheduler();
} }
@@ -0,0 +1,56 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Contract for a component whose lifecycle is owned and driven by the {@link ApplicationContext}.
*
* <p>Components are {@linkplain #start() started} in ascending {@link #priority()} order and
* {@linkplain #stop() stopped} in the exact reverse order, giving symmetric, deterministic startup
* and shutdown without any DI framework. A lower {@code priority()} means the component is started
* earlier and, consequently, stopped later.
*/
public interface Lifecycle {
/**
* Brings the component up. Called exactly once, during {@link ApplicationContext#start()}.
*
* @throws Exception if the component cannot be started; startup is aborted
*/
void start() throws Exception;
/**
* Tears the component down, releasing whatever {@link #start()} acquired. Called at most once,
* during {@link ApplicationContext#stop()}. Implementations should be null-safe / idempotent so
* they can cope with a component that never fully started.
*
* <p>The default is a no-op for components that hold no resources.
*
* @throws Exception if teardown fails; the context logs it and continues stopping the rest
*/
default void stop() throws Exception {
}
/**
* Relative ordering weight. Lower values start earlier and stop later; equal values keep their
* registration order. Defaults to {@code 0}.
*
* @return the priority of this component
*/
default int priority() {
return 0;
}
/**
* Human-readable name used in lifecycle diagnostics/logging. Defaults to the simple class name.
*
* @return a short label identifying this component
*/
default String name() {
return getClass().getSimpleName();
}
}
@@ -0,0 +1,129 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.reflections.Reflections;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
public class ReflectionHelper {
private static final Logger logger = LogManager.getLogger(ReflectionHelper.class);
private static final BiConsumer<Class<?>, Exception> DEFAULT_FAILURE = (aClass, e) -> logger.error("Failed to load class: {}", aClass.getName(), e);
public static <E> void loadClassesFromPackage(String packageName, Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage(packageName, clazz, null, onInstanciated, onFailed);
}
/**
* Discovers concrete subtypes of {@code clazz} under {@code packageName} and instantiates each
* one. When a non-null {@link ApplicationContext} is provided, prefers the public constructor
* with the most parameters whose every parameter type is resolvable via
* {@link ApplicationContext#get(Class)}; otherwise (or when no such constructor exists) falls
* back to the no-arg constructor.
*
* @param packageName root package to scan
* @param clazz base type of classes to load
* @param context optional DI context used for constructor injection; {@code null} keeps
* legacy no-arg construction only
* @param onInstanciated callback invoked with each successfully created instance
* @param onFailed callback invoked when a class cannot be instantiated
* @param <E> the base type
*/
public static <E> void loadClassesFromPackage(String packageName, Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
Set<Class<? extends E>> classes = new Reflections(packageName).getSubTypesOf(clazz);
for (Class<? extends E> s : classes) {
try {
if (Modifier.isAbstract(s.getModifiers())) {
continue;
}
E instance = instantiate(s, context);
onInstanciated.accept(instance);
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException
| InvocationTargetException | NoSuchMethodException | SecurityException e) {
onFailed.accept(s, e);
}
}
}
/**
* Instantiates {@code type} using context-based constructor injection when possible.
*
* <p>Selection algorithm: among public constructors, pick the one with the most parameters for
* which every parameter type resolves to a non-null value via {@code context.get(paramType)}.
* If no such constructor exists (or {@code context} is null), fall back to the public no-arg
* constructor.
*/
@SuppressWarnings("unchecked")
public static <E> E instantiate(Class<? extends E> type, ApplicationContext context)
throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
if (context != null) {
Constructor<?> best = null;
Object[] bestArgs = null;
for (Constructor<?> ctor : type.getConstructors()) {
Class<?>[] paramTypes = ctor.getParameterTypes();
if (paramTypes.length == 0) {
continue;
}
Object[] args = new Object[paramTypes.length];
boolean resolvable = true;
for (int i = 0; i < paramTypes.length; i++) {
Object dependency = context.get(paramTypes[i]);
if (dependency == null) {
resolvable = false;
break;
}
args[i] = dependency;
}
if (resolvable && (best == null || paramTypes.length > best.getParameterCount())) {
best = ctor;
bestArgs = args;
}
}
if (best != null) {
return (E) best.newInstance(bestArgs);
}
}
return type.getConstructor().newInstance();
}
public static <E> void loadCommands(Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed);
}
public static <E> void loadCommands(Class<E> clazz, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadCommands(Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadTasks(Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed);
}
public static <E> void loadTasks(Class<E> clazz, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadTasks(Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE);
}
}
@@ -12,14 +12,23 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.entities.OBlacklist; import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.service.BlacklistService; import me.bobert.netneutrality.db.service.BlacklistService;
import me.bobert.netneutrality.db.service.GuildService;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import java.util.List; import java.util.List;
import java.util.Objects;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
public class BlacklistCommand extends AdminCommand { public class BlacklistCommand extends AdminCommand {
private final GuildServiceProvider guildServices;
public BlacklistCommand(GuildServiceProvider guildServices) {
Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null");
this.guildServices = guildServices;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -55,7 +64,7 @@ public class BlacklistCommand extends AdminCommand {
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand(); String sub = context.getSubcommand();
String word = context.getOption("word", "", CommandOption::getAsString); String word = context.getOption("word", "", CommandOption::getAsString);
BlacklistService service = GuildService.get(context.getGuild()).getBlacklistService(); BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService();
switch (sub) { switch (sub) {
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> { case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
@@ -0,0 +1,78 @@
package me.bobert.netneutrality.command.admin;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class PurgeCommand extends AbstractCommand {
@Override
public boolean needAdmin() {
return true;
}
@Override
public boolean isEnabled() {
return false;
}
@Override
public boolean isGuildOnly() {
return true;
}
@Override
public String getCommandName() {
return "purge";
}
@Override
public String getDescription() {
return "Delete messages from the channel you are currently on";
}
@Override
public void registerCommandOptions(CommandOptionsBuilder builder) {
builder.addOptions(
new OptionData(OptionType.INTEGER, "to-purge", "Number of messages to delete (max 2500)", true)
.setRequiredRange(1, 2500));
}
@Override
public CommandCategory getCategory() {
return CommandCategory.ADMINISTRATION;
}
@Override
public CommandExecutionResult execute(CommandContext context) {
int toDelete = context.getOption("to-purge", 0, CommandOption::getAsInt);
context.ephemeral();
if (toDelete > 0) {
GuildMessageChannel messageChannel = (GuildMessageChannel) context.getChannel();
ChannelMessagePurger channelMessagePurger = ChannelMessagePurger.getPurger(messageChannel);
boolean lock = channelMessagePurger.canLockBeAcquired();
channelMessagePurger.purgeMessages(context, toDelete);
context.reply("Processing purge command.. This can take some time.");
if (!lock) {
context.reply("Another purge is currently in progress on this channel, please wait before purging again");
}
}
return CommandExecutionResult.none();
}
}
@@ -10,8 +10,7 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AdminCommand; import me.bobert.netneutrality.core.command.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.core.tasks.TaskManager; import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -23,9 +22,18 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import me.bobert.netneutrality.core.tasks.TaskManager;
public class ReactionCommand extends AdminCommand { public class ReactionCommand extends AdminCommand {
private final TaskManager taskManager;
public ReactionCommand(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -93,11 +101,11 @@ public class ReactionCommand extends AdminCommand {
private CommandExecutionResult executeEmoji(CommandContext context, String guildId, String messageId, String sub) { private CommandExecutionResult executeEmoji(CommandContext context, String guildId, String messageId, String sub) {
String id = String.format("%s:%s", guildId, messageId); String id = String.format("%s:%s", guildId, messageId);
EmojiReactionRoleTask task = (EmojiReactionRoleTask) TaskManager.getInstance().getTask(id); EmojiReactionRoleTask task = (EmojiReactionRoleTask) taskManager.getTask(id);
switch (sub) { switch (sub) {
case "list" -> { case "list" -> {
List<EmojiReactionRoleTask> tasks = TaskManager.getInstance().findTasksByPartialId(EmojiReactionRoleTask.class, guildId); List<EmojiReactionRoleTask> tasks = taskManager.findTasksByPartialId(EmojiReactionRoleTask.class, guildId);
if (tasks.isEmpty()) { if (tasks.isEmpty()) {
return CommandExecutionResult.info("No tasks found for this guild"); return CommandExecutionResult.info("No tasks found for this guild");
} }
@@ -8,16 +8,17 @@ package me.bobert.netneutrality.command.basic;
import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandContainer; import me.bobert.netneutrality.core.command.CommandContainer;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AbstractCommand; import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.Task; import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.help.BotInfo; import me.bobert.netneutrality.tasks.help.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.utils.MessageFunction; import me.bobert.netneutrality.shared.util.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;
@@ -26,6 +27,18 @@ import java.util.List;
public class HelpCommand extends AbstractCommand { public class HelpCommand extends AbstractCommand {
private final CommandRegistry commands;
private final TaskManager taskManager;
public HelpCommand() {
this(null, null);
}
public HelpCommand(CommandRegistry commands, TaskManager taskManager) {
this.commands = commands;
this.taskManager = taskManager;
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return false; return false;
@@ -65,15 +78,15 @@ public class HelpCommand extends AbstractCommand {
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String command = context.getOption("command", null, CommandOption::getAsString); String command = context.getOption("command", null, CommandOption::getAsString);
if (command == null) { if (command == null) {
Button commands = Task.createButton(CommandHelp.class); Button commandsBtn = createTaskButton(CommandHelp.class);
Button info = Task.createButton(BotInfo.class); Button info = createTaskButton(BotInfo.class);
Button reportbug = Task.createButton(ReportBug.class); Button reportbug = createTaskButton(ReportBug.class);
ActionRow row = ActionRow.of(commands, info, reportbug); ActionRow row = ActionRow.of(commandsBtn, info, reportbug);
context.replyEphemeral("Thanks for using the Net Neutrality Bot! What do you need help with?", List.of(row)); context.replyEphemeral("Thanks for using the Net Neutrality Bot! What do you need help with?", List.of(row));
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
CommandContainer container = CommandManager.getCommandContainer(command); CommandContainer container = commands.getCommandContainer(command);
if (container.getCommand() == null) { if (container.getCommand() == null) {
return CommandExecutionResult.warn("Command does not exist"); return CommandExecutionResult.warn("Command does not exist");
} }
@@ -81,4 +94,12 @@ public class HelpCommand extends AbstractCommand {
context.replyEphemeral(MessageFunction.buildCommandHelpMessage(container)); context.replyEphemeral(MessageFunction.buildCommandHelpMessage(container));
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
private Button createTaskButton(Class<? extends WithButton> clazz) {
WithButton withButton = taskManager.findButtonTaskByClass(clazz);
if (withButton == null) {
throw new IllegalArgumentException("Task " + clazz.getName() + " was not registered by the task manager.");
}
return withButton.createButton();
}
} }
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.command.bot; package me.bobert.netneutrality.command.basic;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
@@ -12,7 +12,6 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicManager;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
@@ -22,9 +21,24 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import java.util.Map; import java.util.Map;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
public class ConfigureCommand extends AdminCommand { public class ConfigureCommand extends AdminCommand {
private final GuildServiceProvider guildServices;
private final MusicService musicService;
public ConfigureCommand() {
this(null, null);
}
public ConfigureCommand(GuildServiceProvider guildServices, MusicService musicService) {
this.guildServices = guildServices;
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -63,7 +77,7 @@ public class ConfigureCommand extends AdminCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand(); String sub = context.getSubcommand();
GuildService guildService = GuildService.get(context.getGuild().getId()); GuildService guildService = guildServices.get(context.getGuild().getId());
context.ephemeral(); context.ephemeral();
if ("music-role".equals(sub)) { if ("music-role".equals(sub)) {
@@ -105,7 +119,7 @@ public class ConfigureCommand extends AdminCommand {
if ("music-volume".equals(sub)) { if ("music-volume".equals(sub)) {
int volume = context.getOption("volume", 30, CommandOption::getAsInt); int volume = context.getOption("volume", 30, CommandOption::getAsInt);
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume)); guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume));
MusicManager.get(context.getGuild()).setVolume(volume); musicService.get(context.getGuild()).setVolume(volume);
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume)); return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
} }
@@ -1 +1 @@
*.java **/*.java
@@ -12,11 +12,24 @@ import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.http.WebModule;
import me.bobert.netneutrality.tasks.git.ReportIssueTask; import me.bobert.netneutrality.tasks.git.ReportIssueTask;
import me.bobert.netneutrality.utils.Settings;
import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.SettingsProvider;
public class ReportIssueCommand extends AbstractCommand { public class ReportIssueCommand extends AbstractCommand {
private final SettingsProvider settings;
private final TaskManager taskManager;
public ReportIssueCommand(SettingsProvider settings, TaskManager taskManager) {
Objects.requireNonNull(settings, "SettingsProvider must not be null");
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.settings = settings;
this.taskManager = taskManager;
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return false; return false;
@@ -59,16 +72,16 @@ public class ReportIssueCommand extends AbstractCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String gitlabHost = Settings.getInstance().get("GITLAB_HOST"); String gitlabHost = settings.get("GITLAB_HOST");
String gitlabToken = Settings.getInstance().get("GITLAB_TOKEN"); String gitlabToken = settings.get("GITLAB_TOKEN");
String projectId = Settings.getInstance().get("GITLAB_PROJECT_ID"); String projectId = settings.get("GITLAB_PROJECT_ID");
if (gitlabHost == null || gitlabToken == null || projectId == null) { if (gitlabHost == null || gitlabToken == null || projectId == null) {
return CommandExecutionResult.error("Command is not configured, failed to create issue."); return CommandExecutionResult.error("Command is not configured, failed to create issue.");
} }
if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) { if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) {
return CommandExecutionResult.task(new ReportIssueTask(context)); return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings));
} }
return CommandExecutionResult.error("Unable to create bug reports at this time, please try again later."); return CommandExecutionResult.error("Unable to create bug reports at this time, please try again later.");
@@ -10,11 +10,28 @@ import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
public class BitconnectCommand extends MusicCommand { public class BitconnectCommand extends MusicCommand {
private final MusicService musicService;
public BitconnectCommand() {
this(null, null);
}
public BitconnectCommand(MusicService musicService) {
this(musicService, null);
}
public BitconnectCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -42,7 +59,7 @@ public class BitconnectCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
context.ephemeral(); context.ephemeral();
context.deleteMessage(); context.deleteMessage();
@@ -9,12 +9,29 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ClearQueueCommand extends MusicCommand { public class ClearQueueCommand extends MusicCommand {
private final MusicService musicService;
public ClearQueueCommand() {
this(null, null);
}
public ClearQueueCommand(MusicService musicService) {
this(musicService, null);
}
public ClearQueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return true; return true;
@@ -42,10 +59,10 @@ public class ClearQueueCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.getQueueManager().clearQueue(); return manager.getQueueManager().clearQueue();
} }
@@ -9,13 +9,30 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MessageFunction; import me.bobert.netneutrality.shared.util.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class CurrentlyPlayingCommand extends MusicCommand { public class CurrentlyPlayingCommand extends MusicCommand {
private final MusicService musicService;
public CurrentlyPlayingCommand() {
this(null, null);
}
public CurrentlyPlayingCommand(MusicService musicService) {
this(musicService, null);
}
public CurrentlyPlayingCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -38,10 +55,10 @@ public class CurrentlyPlayingCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
if (!manager.isCurrentlyPlaying()) { if (!manager.isCurrentlyPlaying()) {
return CommandExecutionResult.info("Bot isn't playing any tracks right now"); return CommandExecutionResult.info("Bot isn't playing any tracks right now");
} }
@@ -9,11 +9,29 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class JoinCommand extends MusicCommand { public class JoinCommand extends MusicCommand {
private final MusicService musicService;
public JoinCommand() {
this(null, null);
}
public JoinCommand(MusicService musicService) {
this(musicService, null);
}
public JoinCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -36,7 +54,9 @@ public class JoinCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
if (MusicUtils.connect(context)) { MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.connect(context, manager)) {
context.reply("Joining current voice channel"); context.reply("Joining current voice channel");
} }
@@ -10,11 +10,35 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
/**
* Leaves the voice channel for the current guild.
*
* <p>Representative command migrated to constructor injection of {@link MusicService} (and optional
* {@link GuildServiceProvider} for the {@link MusicCommand} base). The no-arg constructor remains as
* a fallback for non-DI loaders.
*/
public class LeaveCommand extends MusicCommand { public class LeaveCommand extends MusicCommand {
private final MusicService musicService;
public LeaveCommand() {
this(null, null);
}
public LeaveCommand(MusicService musicService) {
this(musicService, null);
}
public LeaveCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -37,9 +61,9 @@ public class LeaveCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
context.reply("Leaving voice channel and clearing queue"); context.reply("Leaving voice channel and clearing queue");
manager.disconnect(); manager.disconnect();
} }
@@ -9,6 +9,8 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
@@ -16,6 +18,21 @@ import net.dv8tion.jda.api.interactions.commands.OptionType;
public class PlayCommand extends MusicCommand { public class PlayCommand extends MusicCommand {
private final MusicService musicService;
public PlayCommand() {
this(null, null);
}
public PlayCommand(MusicService musicService) {
this(musicService, null);
}
public PlayCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -38,7 +55,7 @@ public class PlayCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
String query = context.getOption("query", CommandOption::getAsString); String query = context.getOption("query", CommandOption::getAsString);
context.ack(); context.ack();
@@ -8,10 +8,12 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MessageFunction; import me.bobert.netneutrality.shared.util.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;
@@ -20,6 +22,21 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
public class QueueCommand extends MusicCommand { public class QueueCommand extends MusicCommand {
private final MusicService musicService;
public QueueCommand() {
this(null, null);
}
public QueueCommand(MusicService musicService) {
this(musicService, null);
}
public QueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -58,11 +75,11 @@ public class QueueCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
String sub = context.getSubcommand(); String sub = context.getSubcommand();
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
switch (sub) { switch (sub) {
case "clear-all" -> { case "clear-all" -> {
@@ -4,14 +4,37 @@ package me.bobert.netneutrality.command.music;/*
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ResumeCommand extends MusicCommand { public class ResumeCommand extends MusicCommand {
private final MusicService musicService;
public ResumeCommand() {
this(null, null);
}
public ResumeCommand(MusicService musicService) {
this(musicService, null);
}
public ResumeCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -34,9 +57,9 @@ public class ResumeCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.resume(); return manager.resume();
} }
@@ -9,11 +9,28 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ShuffleCommand extends MusicCommand { public class ShuffleCommand extends MusicCommand {
private final MusicService musicService;
public ShuffleCommand() {
this(null, null);
}
public ShuffleCommand(MusicService musicService) {
this(musicService, null);
}
public ShuffleCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -36,10 +53,10 @@ public class ShuffleCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.getQueueManager().shuffle(); return manager.getQueueManager().shuffle();
} }
@@ -9,16 +9,33 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.utils.UserFunction; import me.bobert.netneutrality.shared.util.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class SkipCommand extends MusicCommand { public class SkipCommand extends MusicCommand {
private final MusicService musicService;
public SkipCommand() {
this(null, null);
}
public SkipCommand(MusicService musicService) {
this(musicService, null);
}
public SkipCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -42,12 +59,12 @@ public class SkipCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context)) { 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)) { if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
return manager.getQueueManager().skip(skips); return manager.getQueueManager().skip(skips);
} else { } else {
return manager.voteSkip(context); return manager.voteSkip(context);
@@ -9,13 +9,30 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.utils.UserFunction; import me.bobert.netneutrality.shared.util.UserFunction;
public class StopCommand extends MusicCommand { public class StopCommand extends MusicCommand {
private final MusicService musicService;
public StopCommand() {
this(null, null);
}
public StopCommand(MusicService musicService) {
this(musicService, null);
}
public StopCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -38,10 +55,10 @@ public class StopCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
if (UserFunction.isAdmin(context)) { if (UserFunction.isAdmin(context)) {
manager.stopTrack(); manager.stopTrack();
context.reply("Stopping current track"); context.reply("Stopping current track");
@@ -8,13 +8,30 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MessageFunction; import me.bobert.netneutrality.shared.util.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ViewQueueCommand extends MusicCommand { public class ViewQueueCommand extends MusicCommand {
private final MusicService musicService;
public ViewQueueCommand() {
this(null, null);
}
public ViewQueueCommand(MusicService musicService) {
this(musicService, null);
}
public ViewQueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -37,10 +54,10 @@ public class ViewQueueCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
int queueSize = manager.getQueueSize(); int queueSize = manager.getQueueSize();
if (queueSize == 0) { if (queueSize == 0) {
return CommandExecutionResult.warn("Queue is empty!"); return CommandExecutionResult.warn("Queue is empty!");
@@ -9,16 +9,33 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.utils.UserFunction; import me.bobert.netneutrality.shared.util.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class VolumeCommand extends MusicCommand { public class VolumeCommand extends MusicCommand {
private final MusicService musicService;
public VolumeCommand() {
this(null, null);
}
public VolumeCommand(MusicService musicService) {
this(musicService, null);
}
public VolumeCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -42,11 +59,11 @@ public class VolumeCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
int volume = context.getOption("volume", 30, CommandOption::getAsInt); int volume = context.getOption("volume", 30, CommandOption::getAsInt);
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) { if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
manager.setVolume(volume); manager.setVolume(volume);
return CommandExecutionResult.success(String.format("Volume set to %s", volume)); return CommandExecutionResult.success(String.format("Volume set to %s", volume));
} }
@@ -8,12 +8,29 @@ package me.bobert.netneutrality.command.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class VoteSkipCommand extends MusicCommand { public class VoteSkipCommand extends MusicCommand {
private final MusicService musicService;
public VoteSkipCommand() {
this(null, null);
}
public VoteSkipCommand(MusicService musicService) {
this(musicService, null);
}
public VoteSkipCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return true;
@@ -36,9 +53,9 @@ public class VoteSkipCommand extends MusicCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.voteSkip(context); return manager.voteSkip(context);
} }
@@ -9,22 +9,32 @@ package me.bobert.netneutrality.command.react;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand; import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiRoleFirstStep; import me.bobert.netneutrality.tasks.reaction.EmojiRoleFirstStep;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.Objects;
public class CreateEmojiReaction extends MessageMenuCommand { public class CreateEmojiReaction extends MessageMenuCommand {
private final TaskManager taskManager;
public CreateEmojiReaction(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
@Override @Override
public CommandExecutionResult execute(Message target, CommandContext context) { public CommandExecutionResult execute(Message target, CommandContext context) {
EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
if (task != null) { if (task != null) {
context.replyEphemeral("This message is already registered with the bot, please use the modify command instead"); context.replyEphemeral("This message is already registered with the bot, please use the modify command instead");
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
return CommandExecutionResult.task(new EmojiRoleFirstStep(target)); return CommandExecutionResult.task(new EmojiRoleFirstStep(taskManager, target));
} }
@Override @Override
@@ -9,22 +9,32 @@ package me.bobert.netneutrality.command.react;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand; import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiRoleModify; import me.bobert.netneutrality.tasks.reaction.EmojiRoleModify;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.Objects;
public class ModifyEmojiReaction extends MessageMenuCommand { public class ModifyEmojiReaction extends MessageMenuCommand {
private final TaskManager taskManager;
public ModifyEmojiReaction(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
@Override @Override
public CommandExecutionResult execute(Message target, CommandContext context) { public CommandExecutionResult execute(Message target, CommandContext context) {
EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
if (task == null) { if (task == null) {
context.replyEphemeral("This message is not registered with the bot, please use the create command instead"); context.replyEphemeral("This message is not registered with the bot, please use the create command instead");
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
return CommandExecutionResult.task(new EmojiRoleModify(target, task)); return CommandExecutionResult.task(new EmojiRoleModify(taskManager, target, task));
} }
@Override @Override
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.context;
*/ */
import me.bobert.netneutrality.core.context.AbstractContext; import me.bobert.netneutrality.core.context.AbstractContext;
import me.bobert.netneutrality.core.context.AsyncContext;
import me.bobert.netneutrality.core.context.NoMessageContext; import me.bobert.netneutrality.core.context.NoMessageContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
@@ -9,7 +9,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.MessageCommandOption; import me.bobert.netneutrality.core.command.options.MessageCommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.MessageContext; import me.bobert.netneutrality.core.context.MessageContext;
import me.bobert.netneutrality.utils.Settings; import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Mentions; import net.dv8tion.jda.api.entities.Mentions;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
@@ -30,8 +30,11 @@ public class MessageCommandContext extends CommandContext implements MessageCont
private final MessageReceivedEvent event; private final MessageReceivedEvent event;
private final Message message; private final Message message;
public MessageCommandContext(MessageReceivedEvent event) { private final SettingsProvider settings;
public MessageCommandContext(MessageReceivedEvent event, SettingsProvider settings) {
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor()); super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor());
this.settings = settings;
this.event = event; this.event = event;
this.message = event.getMessage(); this.message = event.getMessage();
} }
@@ -320,7 +323,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override @Override
public String parseCommandName() { public String parseCommandName() {
String rawMessage = message.getContentRaw().trim(); String rawMessage = message.getContentRaw().trim();
String prefix = Settings.getInstance().getPrefix(); String prefix = settings.getPrefix();
if (rawMessage.startsWith(prefix)) { if (rawMessage.startsWith(prefix)) {
return getFullCommand().split(" ", 2)[0]; return getFullCommand().split(" ", 2)[0];
@@ -332,7 +335,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override @Override
public String getFullCommand() { public String getFullCommand() {
String rawMessage = message.getContentRaw().trim(); String rawMessage = message.getContentRaw().trim();
String prefix = Settings.getInstance().getPrefix(); String prefix = settings.getPrefix();
return rawMessage.replaceFirst("^[" + prefix + "\\s]+", "").trim(); return rawMessage.replaceFirst("^[" + prefix + "\\s]+", "").trim();
} }
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.context.tasks;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext; import me.bobert.netneutrality.core.context.AsyncContext;
import me.bobert.netneutrality.core.context.NoMessageContext; import me.bobert.netneutrality.core.context.NoMessageContext;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
@@ -9,9 +9,9 @@ import io.sentry.Sentry;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.utils.MessageFunction; import me.bobert.netneutrality.shared.util.MessageFunction;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -20,6 +20,17 @@ import org.apache.logging.log4j.Logger;
public class LogHandler { public class LogHandler {
private static final Logger logger = LogManager.getLogger(LogHandler.class); private static final Logger logger = LogManager.getLogger(LogHandler.class);
private static volatile GuildServiceProvider guildServices;
/**
* Binds the guild-service provider used for debug-channel logging. Called once during bootstrap.
*
* @param provider the live guild service provider
*/
public static void bind(GuildServiceProvider provider) {
guildServices = provider;
}
/** /**
* Log an exception accordingly * Log an exception accordingly
@@ -80,7 +91,8 @@ public class LogHandler {
Context context = exception.getContext(); Context context = exception.getContext();
if (context.getGuild() != null) { if (context.getGuild() != null) {
if (GuildService.get(context.getGuild()).isDebugEnabled()) { GuildServiceProvider provider = guildServices;
if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) {
context.getGuild() context.getGuild()
.getTextChannels() .getTextChannels()
.stream() .stream()
@@ -0,0 +1,20 @@
package me.bobert.netneutrality.core;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.Lifecycle;
public class NetNeutralityBot implements Lifecycle {
@Override
public void start() throws Exception {
}
@Override
public int priority() {
return 0;
}
}
@@ -5,7 +5,8 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.utils.ReflectionHelper; import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.InteractionContextType;
import net.dv8tion.jda.api.interactions.commands.build.CommandData; import net.dv8tion.jda.api.interactions.commands.build.CommandData;
@@ -17,15 +18,43 @@ import org.apache.logging.log4j.Logger;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
public class CommandManager { /**
* Instance-based {@link CommandRegistry} owning the discovered command map.
*/
public class CommandManager implements CommandRegistry {
private static final Logger logger = LogManager.getLogger(CommandManager.class); private static final Logger logger = LogManager.getLogger(CommandManager.class);
public static final Map<String, CommandContainer> commands = new ConcurrentHashMap<>();
public static void init() { private final Map<String, CommandContainer> commands = new ConcurrentHashMap<>();
private final ApplicationContext context;
/**
* Creates a command manager that can inject context-registered dependencies into reflectively
* loaded commands.
*
* @param context the application context; must not be {@code null}
*/
public CommandManager(ApplicationContext context) {
Objects.requireNonNull(context, "ApplicationContext must not be null");
this.context = context;
}
@Override
public int priority() {
return 30;
}
@Override
public void start() {
init();
}
public void init() {
logger.info("Loading commands.."); logger.info("Loading commands..");
initCommands(); initCommands();
@@ -33,12 +62,30 @@ public class CommandManager {
logger.info("Loaded {} commands!", commands.size()); logger.info("Loaded {} commands!", commands.size());
} }
public static CommandContainer getCommandContainer(String commandString) { @Override
return commands.getOrDefault(commandString, CommandContainer.NULL_COMMAND); public CommandContainer getCommandContainer(String name) {
return commands.getOrDefault(name, CommandContainer.NULL_COMMAND);
} }
public static void initCommands() { @Override
ReflectionHelper.loadCommands(Command.class, command -> { public boolean hasCommand(String name) {
return commands.containsKey(name);
}
/**
* Exposes the live command map for call sites (and tests) that still iterate or mutate it
* directly. Prefer {@link #hasCommand(String)} / {@link #getCommandContainer(String)} where
* possible.
*
* @return the registered commands keyed by name
*/
@Override
public Map<String, CommandContainer> getCommands() {
return commands;
}
public void initCommands() {
Consumer<Command> onLoaded = command -> {
if (!command.isEnabled()) { 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;
@@ -58,10 +105,13 @@ public class CommandManager {
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);
} }
public static void registerSlashCommands(JDA jda) { @Override
public void registerSlashCommands(JDA jda) {
CommandListUpdateAction commandListUpdateAction = jda.updateCommands(); CommandListUpdateAction commandListUpdateAction = jda.updateCommands();
List<CommandData> slashCommands = new ArrayList<>(); List<CommandData> slashCommands = new ArrayList<>();
@@ -10,10 +10,10 @@ import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext; import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.exceptions.CommandExecutionException; import me.bobert.netneutrality.exceptions.CommandExecutionException;
import me.bobert.netneutrality.exceptions.GracefulContextError; import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.utils.UserFunction; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.shared.util.UserFunction;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
@@ -24,6 +24,7 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import static java.util.function.Predicate.not; import static java.util.function.Predicate.not;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.ERROR; import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.ERROR;
@@ -35,17 +36,21 @@ public class CommandProcessor {
private static final Logger logger = LogManager.getLogger(CommandProcessor.class); private static final Logger logger = LogManager.getLogger(CommandProcessor.class);
private final CommandContext context; private final CommandContext context;
private final CommandRegistry commands;
private final TaskManager taskManager;
public CommandProcessor(CommandContext context) { public CommandProcessor(CommandContext context, CommandRegistry commands, TaskManager taskManager) {
this.context = context; this.context = context;
this.commands = Objects.requireNonNull(commands, "commands");
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
} }
public void dispatch() { public void dispatch() {
if (!CommandManager.commands.containsKey(context.parseCommandName())) { if (!commands.hasCommand(context.parseCommandName())) {
return; return;
} }
CommandContainer container = CommandManager.getCommandContainer(context.parseCommandName()); CommandContainer container = commands.getCommandContainer(context.parseCommandName());
Command command = container.getCommand(); Command command = container.getCommand();
if (command == null) { if (command == null) {
throw new IllegalArgumentException("Context has no command"); throw new IllegalArgumentException("Context has no command");
@@ -98,7 +103,7 @@ public class CommandProcessor {
} }
if (result == TASK) { if (result == TASK) {
TaskManager.getInstance().insert(context, commandExecutionResult.getTask()); taskManager.insert(context, commandExecutionResult.getTask());
} }
String message = commandExecutionResult.getMessage(); String message = commandExecutionResult.getMessage();
@@ -0,0 +1,51 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.Lifecycle;
import net.dv8tion.jda.api.JDA;
import java.util.Map;
/**
* Owns the bot command registry and slash-command registration against Discord.
*
* <p>As a {@link Lifecycle} component it discovers and loads commands on {@link #start()}.
*/
public interface CommandRegistry extends Lifecycle {
/**
* Returns the container for the named command, or a null-object container if unknown.
*
* @param name the command name
* @return the command container
*/
CommandContainer getCommandContainer(String name);
/**
* Whether a command with the given name is registered.
*
* @param name the command name
* @return {@code true} if registered
*/
boolean hasCommand(String name);
/**
* Pushes every enabled slash command up to Discord via the given JDA instance.
*
* @param jda the connected JDA client
*/
void registerSlashCommands(JDA jda);
/**
* Returns the live command map keyed by command name.
*
* @return the registered commands
*/
Map<String, CommandContainer> getCommands();
}
@@ -6,9 +6,10 @@ package me.bobert.netneutrality.core.command.impl;
*/ */
import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.utils.UserFunction; import me.bobert.netneutrality.shared.util.UserFunction;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
@@ -16,8 +17,45 @@ import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import java.util.Objects;
public abstract class MusicCommand extends AbstractCommand { public abstract class MusicCommand extends AbstractCommand {
/**
* Injected guild-service provider when constructed via DI.
*/
private final GuildServiceProvider guildServices;
/**
* Preferred constructor for DI.
*
* @param guildServices the shared guild service provider, must not be {@code null}
*/
public MusicCommand(GuildServiceProvider guildServices) {
Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null");
this.guildServices = guildServices;
}
/**
* Resolves the {@link GuildService} for a guild via the injected provider.
*
* @param guild the guild
* @return the guild service
*/
protected final GuildService guildService(Guild guild) {
return guildServices.get(guild);
}
/**
* Resolves the {@link GuildService} for a guild id via the injected provider.
*
* @param guildId the guild id
* @return the guild service
*/
protected final GuildService guildService(String guildId) {
return guildServices.get(guildId);
}
@Override @Override
public boolean needAdmin() { public boolean needAdmin() {
return false; return false;
@@ -41,7 +79,7 @@ public abstract class MusicCommand extends AbstractCommand {
Guild guild = member.getGuild(); Guild guild = member.getGuild();
String roleDb = GuildService.get(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE); String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
if (roleDb != null && !roleDb.isEmpty()) { if (roleDb != null && !roleDb.isEmpty()) {
if (UserFunction.isAdmin(member)) { if (UserFunction.isAdmin(member)) {
@@ -51,7 +89,7 @@ public abstract class MusicCommand extends AbstractCommand {
Role neededRole = guild.getRoleById(roleDb); Role neededRole = guild.getRoleById(roleDb);
if (neededRole == null) { if (neededRole == null) {
// Role doesn't exist on guild anymore, remove from db // Role doesn't exist on guild anymore, remove from db
GuildService.get(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null); guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
return true; return true;
} }
@@ -68,7 +106,7 @@ public abstract class MusicCommand extends AbstractCommand {
return false; return false;
} }
String channelDb = GuildService.get(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL); String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL);
if (channelDb != null && !channelDb.isEmpty()) { if (channelDb != null && !channelDb.isEmpty()) {
return channelDb.equals(channel.getId()); return channelDb.equals(channel.getId());
} }
@@ -1,11 +1,10 @@
package me.bobert.netneutrality.context; package me.bobert.netneutrality.core.context;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.context.AbstractContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext; 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;
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.interactions.Interaction; import net.dv8tion.jda.api.interactions.Interaction;
import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.InteractionHook;
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.modals.Modal; import net.dv8tion.jda.api.modals.Modal;
@@ -1,69 +0,0 @@
package me.bobert.netneutrality.core.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.sentry.Sentry;
import me.bobert.netneutrality.utils.Settings;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.Environment;
import java.util.HashMap;
import java.util.Map;
public class SessionManager {
private static final Logger logger = LogManager.getLogger(SessionManager.class);
private static StandardServiceRegistry registry;
private static SessionFactory sessionFactory;
public static SessionFactory getSessionFactory() {
if (sessionFactory == null) {
try {
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure();
Map<String, Object> settings = new HashMap<>();
String dbName = Settings.getInstance().get("MYSQL_DATABASE", "netneutrality");
String dbUser = Settings.getInstance().get("MYSQL_USER", "root");
String dbPass = Settings.getInstance().get("MYSQL_PASSWORD", "");
String dbHost = Settings.getInstance().get("MYSQL_HOST", "localhost");
String dbPort = Settings.getInstance().get("MYSQL_PORT", "3306");
logger.info("Connecting to database '{}' at {}:{} with user '{}'", dbName, dbHost, dbPort, dbUser);
settings.put(Environment.JAKARTA_JDBC_URL, String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
settings.put(Environment.JAKARTA_JDBC_USER, dbUser);
settings.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
registryBuilder.applySettings(settings);
registry = registryBuilder.build();
MetadataSources sources = new MetadataSources(registry);
Metadata metadata = sources.getMetadataBuilder().build();
sessionFactory = metadata.getSessionFactoryBuilder().build();
} catch (Exception e) {
String env = Settings.getInstance().get("NETNEUTRALITY_BOT_ENV", "dev");
if (!"dev".equals(env)) {
Sentry.captureException(e);
}
shutdown();
logger.error("Error connecting to database. Shutting down..");
System.exit(1);
}
}
return sessionFactory;
}
public static void shutdown() {
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -79,14 +79,4 @@ public interface Task {
Lifecycle execute(TaskContext context); Lifecycle execute(TaskContext context);
void insert(CommandContext context); void insert(CommandContext context);
static Button createButton(Class<? extends WithButton> clazz) {
WithButton withButton = TaskManager.getInstance().findButtonTaskByClass(clazz);
if (withButton == null) {
throw new IllegalArgumentException("Task " + clazz.getName() + " was not registered by the task manager.");
}
return withButton.createButton();
}
} }
@@ -1,22 +1,31 @@
package me.bobert.netneutrality.tasks.scheduled;/* package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.tasks.BuiltInTask;
import me.bobert.netneutrality.core.tasks.PersistableTask;
import me.bobert.netneutrality.core.tasks.ScheduledTask;
import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.core.tasks.TaskManager;
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.List; import java.util.List;
import java.util.Objects;
/**
* Periodically removes expired in-memory tasks.
*
* <p>Scheduled task migrated to constructor injection of {@link TaskManager}.
*/
public class TaskCleanup implements ScheduledTask { public class TaskCleanup implements ScheduledTask {
private static final Logger logger = LogManager.getLogger(TaskCleanup.class); private static final Logger logger = LogManager.getLogger(TaskCleanup.class);
private final TaskManager taskManager;
public TaskCleanup(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
@Override @Override
public String getId() { public String getId() {
return "TaskCleanup"; return "TaskCleanup";
@@ -29,9 +38,7 @@ public class TaskCleanup implements ScheduledTask {
@Override @Override
public void run() { public void run() {
TaskManager manager = TaskManager.getInstance(); List<Task> expiredTasks = taskManager.getTasks()
List<Task> expiredTasks = manager.getTasks()
.stream() .stream()
.filter(task -> !(task instanceof PersistableTask)) .filter(task -> !(task instanceof PersistableTask))
.filter(task -> !(task instanceof BuiltInTask)) .filter(task -> !(task instanceof BuiltInTask))
@@ -40,7 +47,7 @@ public class TaskCleanup implements ScheduledTask {
if (!expiredTasks.isEmpty()) { if (!expiredTasks.isEmpty()) {
logger.info("Cleaning up {} expired tasks..", expiredTasks.size()); logger.info("Cleaning up {} expired tasks..", expiredTasks.size());
expiredTasks.forEach(manager::removeTask); expiredTasks.forEach(taskManager::removeTask);
} }
} }
} }
@@ -1,321 +1,120 @@
package me.bobert.netneutrality.core.tasks; package me.bobert.netneutrality.core.tasks;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.trait.Deletable;
import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.core.thread.ScheduledTaskManager;
import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException;
import me.bobert.netneutrality.utils.ReflectionHelper;
import net.dv8tion.jda.api.interactions.InteractionHook;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import me.bobert.netneutrality.bootstrap.Lifecycle;
import java.util.concurrent.CopyOnWriteArrayList; import me.bobert.netneutrality.core.context.CommandContext;
import java.util.concurrent.locks.ReentrantLock; import me.bobert.netneutrality.core.context.TaskContext;
import java.util.stream.Collectors; import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.db.service.TaskService;
public class TaskManager { /**
private static final Logger logger = LogManager.getLogger(TaskManager.class); * Owns the in-memory task registry, button/interaction dispatch, and persistable-task loading.
private static TaskManager instance; *
private final TaskService taskService; * <p>As a {@link Lifecycle} component it loads built-in, scheduled and persisted tasks on
private final Map<String, Task> tasks; * {@link #start()}.
private final List<Task> pendingTasks; */
private final Map<String, CommandContext> deletableTasksContext; public interface TaskManager extends Lifecycle {
private final Map<String, ReentrantLock> tasksLock;
private TaskManager() { /**
this.tasks = new ConcurrentHashMap<>(); * Inserts a newly created task into the registry (and persists it when applicable).
this.pendingTasks = new CopyOnWriteArrayList<>(); *
this.deletableTasksContext = new ConcurrentHashMap<>(); * @param ctx the command context that produced the task
this.tasksLock = new ConcurrentHashMap<>(); * @param task the task to insert
this.taskService = new TaskService(); */
} void insert(CommandContext ctx, Task task);
public static TaskManager getInstance() { /**
if (instance == null) { * Looks up a task by its full id.
instance = new TaskManager(); *
} * @param id the task id
* @return the task, or {@code null} if unknown
*/
Task getTask(String id);
return instance; /**
} * Whether a task with the given id is registered or pending activation.
*
* @param id the task id
* @return {@code true} if present
*/
boolean hasTask(String id);
public void init() { /**
logger.info("Initializing task manager..."); * Dispatches an interaction/context event to the matching task.
*
* @param ctx the task context
*/
void handle(TaskContext ctx);
loadBuiltInTasks(); /**
loadScheduledTasks(); * Promotes a pending step-task into the active registry under its (possibly new) id.
taskService.loadTasks(); *
} * @param task the task to activate
* @param oldId the previous id used while the task was pending
*/
void activateTask(Task task, String oldId);
public TaskService getTaskService() { /**
return taskService; * Removes a task from the active registry.
} *
* @param task the task to remove
*/
void removeTask(Task task);
public Task getTask(String id) { /**
return tasks.get(id); * Inserts a previously persisted task back into the active registry.
} *
* @param task the persistable task
*/
void insertPersistableTask(PersistableTask task);
public boolean hasTask(String id) { /**
return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id)); * Returns every currently active task.
} *
* @return the active tasks
*/
Collection<Task> getTasks();
public synchronized Collection<Task> getTasks() { /**
return tasks.values(); * Finds the first task of the given type whose id contains {@code partialId}.
} *
* @param clazz the expected task type
* @param partialId a fragment of the task id
* @param <E> the task type
* @return the matching task, if any
*/
<E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId);
public void insert(CommandContext context, Task task) { /**
String id = task.getId(); * Finds every task of the given type whose id contains {@code partialId}.
*
* @param clazz the expected task type
* @param partialId a fragment of the task id
* @param <E> the task type
* @return the matching tasks
*/
<E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId);
if (hasTask(id)) { /**
context.reply("A task with the same ID already exists!"); * Finds the first button-bearing task of the given class.
return; *
} * @param clazz the expected button-task type
* @return the matching task, or {@code null}
*/
WithButton findButtonTaskByClass(Class<? extends WithButton> clazz);
task.replyToInteraction(context); /**
* Returns the persistence service used for loadable tasks.
if (task.isInsertable()) { *
logger.info("Inserting task: {}", task); * @return the task service
*/
if (task instanceof PersistableTask persistableTask) { TaskService getTaskService();
taskService.persistTask(persistableTask);
}
if (task instanceof StepTask) {
pendingTasks.add(task);
} else {
tasks.put(id, task);
}
}
if (task instanceof Deletable) {
deletableTasksContext.put(task.getId(), context);
}
if (task instanceof WithChildren taskWithChildren) {
List<Task> childrens = taskWithChildren.getChildTasks();
if (childrens == null || childrens.isEmpty()) {
logger.warn("Parent task '{}' doesn't create any childs", task.getName());
return;
}
for (Task child : childrens) {
insert(context, child);
}
}
}
public void activateTask(Task task, String oldId) {
if (pendingTasks.remove(task)) {
tasks.put(task.getId(), task);
logger.info("Updating task {} with new id: {} (was: {})",
task.getName(), task.getId(), oldId);
}
}
public void removeTask(Task task) {
String id = task.getId();
if (tasks.remove(id) != null) {
logger.debug("Removing task: {}", id);
ReentrantLock lock = tasksLock.remove(task.getLockId());
if (lock != null) {
logger.debug("Cleaning up lock for removed task: {}", task.getId());
}
}
}
public void handle(TaskContext context) {
String taskId = context.getId();
Task task = getTask(taskId);
if (task == null && context instanceof InteractionContext) {
context.replyEphemeral("This task is no longer available, someone might have already used it or it might have expired");
}
if (task != null) {
String lockId = task.getLockId();
ReentrantLock lock = tasksLock.computeIfAbsent(lockId, k -> {
if (task instanceof PersistableTask || task instanceof BuiltInTask) {
return null;
}
return new ReentrantLock();
});
try {
if (lock != null) {
logger.debug("Aquiring lock for task: {}", taskId);
lock.lock();
if (!tasks.containsKey(taskId)) {
logger.debug("Task {} is no longer available after aquiring lock", taskId);
tasksLock.remove(lockId);
return;
}
}
logger.info("Executing task: {}", taskId);
Task.Lifecycle status = task.execute(context);
handleTaskLifecycle(task, status);
deleteInteraction(task);
// If interaction is still not acknowledged after execution, send a placeholder response.
if (!context.isAcknowledged() && !(task instanceof NotAcknowledgeable)) {
logger.warn("Interaction not acknowledged");
context.replyEphemeral("Task executed successfully but interaction wasn't acknowledged, report this bug by using the command /report-bug");
}
} catch (GracefulContextError exception) {
logger.error(exception.getMessage());
context.replyEphemeral(exception.getMessage());
} catch (TaskExecutionException exception) {
LogHandler.log(exception);
} catch (Exception exception) {
LogHandler.log(new TaskExecutionException("Error during task execution", context, exception));
} finally {
if (lock != null) {
logger.debug("Releasing lock for task: {}", taskId);
lock.unlock();
}
}
}
}
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
List<E> tasks = findTasksByPartialId(clazz, partialId);
return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst());
}
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
return tasks.values().stream()
.filter(clazz::isInstance)
.map(clazz::cast)
.filter(task -> task.getId().contains(partialId))
.collect(Collectors.toList());
}
public WithButton findButtonTaskByClass(Class<? extends WithButton> clazz) {
return tasks.values().stream()
.filter(clazz::isInstance)
.map(task -> (WithButton) task)
.findFirst()
.orElse(null);
}
public void insertPersistableTask(PersistableTask task) {
if (tasks.containsKey(task.getId())) {
logger.error("Trying to load persistable task but ID already exists: {}", task.getId());
return;
}
logger.info("Loaded persistable task: {}", task);
tasks.put(task.getId(), task);
}
private void loadBuiltInTasks() {
logger.info("Loading built-in tasks...");
ReflectionHelper.loadTasks(BuiltInTask.class, builtInTask -> {
if (builtInTask.isInsertable()) {
logger.info("Loaded task: {}", builtInTask.getClass().getName());
tasks.put(builtInTask.getId(), builtInTask);
}
});
}
private void loadScheduledTasks() {
logger.info("Loading scheduled tasks...");
ReflectionHelper.loadTasks(ScheduledTask.class, scheduledTask -> {
String taskId = scheduledTask.getId();
if (taskId == null || taskId.trim().isEmpty()) {
logger.error("Cannot load scheduled task {}: task ID is null or empty", scheduledTask.getClass().getName());
return;
}
ScheduledTaskManager.schedule(scheduledTask);
});
}
private void deleteInteraction(Task task) {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent instanceof Deletable deletable && task.isInsertable()) {
deleteInteraction(parent.getId(), deletable);
return;
}
}
if (task instanceof Deletable deletable && task.isInsertable()) {
deleteInteraction(task.getId(), deletable);
}
}
private void deleteInteraction(String taskId, Deletable deletable) {
logger.debug("Deleting interaction: {}", taskId);
CommandContext context = deletableTasksContext.get(taskId);
if (context != null) {
deletableTasksContext.remove(taskId);
InteractionHook hook = context.getInteractionHook();
if (hook != null) {
if (hook.isExpired()) {
context.replyEphemeral("This interaction has expired, can't remove it");
}
if (!hook.isExpired()) {
hook.deleteOriginal().queue();
}
}
}
deletable.onInteractionDelete();
}
private void handleTaskLifecycle(Task task, Task.Lifecycle status) {
if (status == null) {
throw new IllegalArgumentException("Task lifecycle status cannot be null");
}
switch (status) {
case REMOVE_SELF:
removeTask(task);
break;
case REMOVE_SELF_AND_CHILDREN:
if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
removeTask(child);
}
}
removeTask(task);
break;
case KEEP:
default:
break;
}
}
} }
@@ -0,0 +1,359 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.trait.Deletable;
import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.core.thread.ScheduledTaskManager;
import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException;
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import net.dv8tion.jda.api.interactions.InteractionHook;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Instance-based {@link TaskManager} owning the in-memory task registry.
*/
public class TaskManagerImpl implements TaskManager {
private static final Logger logger = LogManager.getLogger(TaskManagerImpl.class);
private final TaskService taskService;
private final Map<String, Task> tasks;
private final List<Task> pendingTasks;
private final Map<String, CommandContext> deletableTasksContext;
private final Map<String, ReentrantLock> tasksLock;
private final ApplicationContext context;
/**
* Creates a task manager that can inject context-registered dependencies into reflectively
* loaded tasks.
*
* @param context the application context; must not be {@code null}
*/
public TaskManagerImpl(ApplicationContext context) {
Objects.requireNonNull(context, "ApplicationContext must not be null");
this.tasks = new ConcurrentHashMap<>();
this.pendingTasks = new CopyOnWriteArrayList<>();
this.deletableTasksContext = new ConcurrentHashMap<>();
this.tasksLock = new ConcurrentHashMap<>();
this.taskService = new TaskService(this);
this.context = context;
}
@Override
public int priority() {
return 30;
}
@Override
public void start() {
init();
}
public void init() {
logger.info("Initializing task manager...");
loadBuiltInTasks();
loadScheduledTasks();
taskService.loadTasks();
}
@Override
public TaskService getTaskService() {
return taskService;
}
@Override
public Task getTask(String id) {
return tasks.get(id);
}
@Override
public boolean hasTask(String id) {
return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id));
}
@Override
public synchronized Collection<Task> getTasks() {
return tasks.values();
}
@Override
public void insert(CommandContext context, Task task) {
String id = task.getId();
if (hasTask(id)) {
context.reply("A task with the same ID already exists!");
return;
}
task.replyToInteraction(context);
if (task.isInsertable()) {
logger.info("Inserting task: {}", task);
if (task instanceof PersistableTask persistableTask) {
taskService.persistTask(persistableTask);
}
if (task instanceof StepTask) {
pendingTasks.add(task);
} else {
tasks.put(id, task);
}
}
if (task instanceof Deletable) {
deletableTasksContext.put(task.getId(), context);
}
if (task instanceof WithChildren taskWithChildren) {
List<Task> childrens = taskWithChildren.getChildTasks();
if (childrens == null || childrens.isEmpty()) {
logger.warn("Parent task '{}' doesn't create any childs", task.getName());
return;
}
for (Task child : childrens) {
insert(context, child);
}
}
}
@Override
public void activateTask(Task task, String oldId) {
if (pendingTasks.remove(task)) {
tasks.put(task.getId(), task);
logger.info("Updating task {} with new id: {} (was: {})",
task.getName(), task.getId(), oldId);
}
}
@Override
public void removeTask(Task task) {
String id = task.getId();
if (tasks.remove(id) != null) {
logger.debug("Removing task: {}", id);
ReentrantLock lock = tasksLock.remove(task.getLockId());
if (lock != null) {
logger.debug("Cleaning up lock for removed task: {}", task.getId());
}
}
}
@Override
public void handle(TaskContext context) {
String taskId = context.getId();
Task task = getTask(taskId);
if (task == null && context instanceof InteractionContext) {
context.replyEphemeral("This task is no longer available, someone might have already used it or it might have expired");
}
if (task != null) {
String lockId = task.getLockId();
ReentrantLock lock = tasksLock.computeIfAbsent(lockId, k -> {
if (task instanceof PersistableTask || task instanceof BuiltInTask) {
return null;
}
return new ReentrantLock();
});
try {
if (lock != null) {
logger.debug("Aquiring lock for task: {}", taskId);
lock.lock();
if (!tasks.containsKey(taskId)) {
logger.debug("Task {} is no longer available after aquiring lock", taskId);
tasksLock.remove(lockId);
return;
}
}
logger.info("Executing task: {}", taskId);
Task.Lifecycle status = task.execute(context);
handleTaskLifecycle(task, status);
deleteInteraction(task);
// If interaction is still not acknowledged after execution, send a placeholder response.
if (!context.isAcknowledged() && !(task instanceof NotAcknowledgeable)) {
logger.warn("Interaction not acknowledged");
context.replyEphemeral("Task executed successfully but interaction wasn't acknowledged, report this bug by using the command /report-bug");
}
} catch (GracefulContextError exception) {
logger.error(exception.getMessage());
context.replyEphemeral(exception.getMessage());
} catch (TaskExecutionException exception) {
LogHandler.log(exception);
} catch (Exception exception) {
LogHandler.log(new TaskExecutionException("Error during task execution", context, exception));
} finally {
if (lock != null) {
logger.debug("Releasing lock for task: {}", taskId);
lock.unlock();
}
}
}
}
@Override
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
List<E> tasks = findTasksByPartialId(clazz, partialId);
return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst());
}
@Override
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
return tasks.values().stream()
.filter(clazz::isInstance)
.map(clazz::cast)
.filter(task -> task.getId().contains(partialId))
.collect(Collectors.toList());
}
@Override
public WithButton findButtonTaskByClass(Class<? extends WithButton> clazz) {
return tasks.values().stream()
.filter(clazz::isInstance)
.map(task -> (WithButton) task)
.findFirst()
.orElse(null);
}
@Override
public void insertPersistableTask(PersistableTask task) {
if (tasks.containsKey(task.getId())) {
logger.error("Trying to load persistable task but ID already exists: {}", task.getId());
return;
}
logger.info("Loaded persistable task: {}", task);
tasks.put(task.getId(), task);
}
private void loadBuiltInTasks() {
logger.info("Loading built-in tasks...");
Consumer<BuiltInTask> onLoaded = builtInTask -> {
if (builtInTask.isInsertable()) {
logger.info("Loaded task: {}", builtInTask.getClass().getName());
tasks.put(builtInTask.getId(), builtInTask);
}
};
ReflectionHelper.loadTasks(BuiltInTask.class, context, onLoaded);
}
private void loadScheduledTasks() {
logger.info("Loading scheduled tasks...");
ScheduledTaskManager scheduledTaskManager = context.get(ScheduledTaskManager.class);
if (scheduledTaskManager == null) {
logger.error("ScheduledTaskManager is not available; cannot load scheduled tasks");
return;
}
Consumer<ScheduledTask> onLoaded = scheduledTask -> {
String taskId = scheduledTask.getId();
if (taskId == null || taskId.trim().isEmpty()) {
logger.error("Cannot load scheduled task {}: task ID is null or empty", scheduledTask.getClass().getName());
return;
}
scheduledTaskManager.schedule(scheduledTask);
};
ReflectionHelper.loadTasks(ScheduledTask.class, context, onLoaded);
}
private void deleteInteraction(Task task) {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent instanceof Deletable deletable && task.isInsertable()) {
deleteInteraction(parent.getId(), deletable);
return;
}
}
if (task instanceof Deletable deletable && task.isInsertable()) {
deleteInteraction(task.getId(), deletable);
}
}
private void deleteInteraction(String taskId, Deletable deletable) {
logger.debug("Deleting interaction: {}", taskId);
CommandContext context = deletableTasksContext.get(taskId);
if (context != null) {
deletableTasksContext.remove(taskId);
InteractionHook hook = context.getInteractionHook();
if (hook != null) {
if (hook.isExpired()) {
context.replyEphemeral("This interaction has expired, can't remove it");
}
if (!hook.isExpired()) {
hook.deleteOriginal().queue();
}
}
}
deletable.onInteractionDelete();
}
private void handleTaskLifecycle(Task task, Task.Lifecycle status) {
if (status == null) {
throw new IllegalArgumentException("Task lifecycle status cannot be null");
}
switch (status) {
case REMOVE_SELF:
removeTask(task);
break;
case REMOVE_SELF_AND_CHILDREN:
if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
removeTask(child);
}
}
removeTask(task);
break;
case KEEP:
default:
break;
}
}
}
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.tasks.generic; package me.bobert.netneutrality.core.tasks.generic;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.tasks.generic;
*/ */
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.impl.ButtonTask; import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable; import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.core.tasks.trait.WithChildren;
@@ -24,26 +25,26 @@ public class AcceptButton<P extends WithChildren> extends ButtonTask implements
private final P parentTask; private final P parentTask;
private final Function<TaskContext, Lifecycle> function; private final Function<TaskContext, Lifecycle> function;
protected AcceptButton(P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) { protected AcceptButton(TaskManager taskManager, P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) {
super(buttonStyle, buttonLabel); super(taskManager, buttonStyle, buttonLabel);
this.parentTask = parentTask; this.parentTask = parentTask;
this.function = function; this.function = function;
} }
public static <P extends WithChildren> AcceptButton<P> createPrimaryButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) { public static <P extends WithChildren> AcceptButton<P> createPrimaryButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
return new AcceptButton<>(parentTask, ButtonStyle.PRIMARY, buttonLabel, function); return new AcceptButton<>(taskManager, parentTask, ButtonStyle.PRIMARY, buttonLabel, function);
} }
public static <P extends WithChildren> AcceptButton<P> createSecondaryButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) { public static <P extends WithChildren> AcceptButton<P> createSecondaryButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
return new AcceptButton<>(parentTask, ButtonStyle.SECONDARY, buttonLabel, function); return new AcceptButton<>(taskManager, parentTask, ButtonStyle.SECONDARY, buttonLabel, function);
} }
public static <P extends WithChildren> AcceptButton<P> createSuccessButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) { public static <P extends WithChildren> AcceptButton<P> createSuccessButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
return new AcceptButton<>(parentTask, ButtonStyle.SUCCESS, buttonLabel, function); return new AcceptButton<>(taskManager, parentTask, ButtonStyle.SUCCESS, buttonLabel, function);
} }
public static <P extends WithChildren> AcceptButton<P> createDangerButton(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) { public static <P extends WithChildren> AcceptButton<P> createDangerButton(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
return new AcceptButton<>(parentTask, ButtonStyle.DANGER, buttonLabel, function); return new AcceptButton<>(taskManager, parentTask, ButtonStyle.DANGER, buttonLabel, function);
} }
@Override @Override
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.tasks.generic; package me.bobert.netneutrality.core.tasks.generic;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.tasks.generic;
*/ */
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.impl.ButtonTask; import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable; import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.core.tasks.trait.WithChildren;
@@ -19,14 +20,14 @@ public class CancelButton<P extends WithChildren> extends ButtonTask implements
private final P parentTask; private final P parentTask;
private final Function<TaskContext, Lifecycle> function; private final Function<TaskContext, Lifecycle> function;
protected CancelButton(P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) { protected CancelButton(TaskManager taskManager, P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function<TaskContext, Lifecycle> function) {
super(buttonStyle, buttonLabel); super(taskManager, buttonStyle, buttonLabel);
this.parentTask = parentTask; this.parentTask = parentTask;
this.function = function; this.function = function;
} }
public static <P extends WithChildren> CancelButton<P> create(P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) { public static <P extends WithChildren> CancelButton<P> create(TaskManager taskManager, P parentTask, String buttonLabel, Function<TaskContext, Lifecycle> function) {
return new CancelButton<>(parentTask, ButtonStyle.DANGER, buttonLabel, function); return new CancelButton<>(taskManager, parentTask, ButtonStyle.DANGER, buttonLabel, function);
} }
@Override @Override
@@ -8,9 +8,14 @@ package me.bobert.netneutrality.core.tasks.impl;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.BuiltInTask; import me.bobert.netneutrality.core.tasks.BuiltInTask;
import me.bobert.netneutrality.core.tasks.TaskManager;
public abstract class AbstractBuiltInTask extends AbstractTask implements BuiltInTask { public abstract class AbstractBuiltInTask extends AbstractTask implements BuiltInTask {
public AbstractBuiltInTask(TaskManager taskManager) {
super(taskManager);
}
@Override @Override
public String getName() { public String getName() {
return getId(); return getId();
@@ -12,6 +12,15 @@ import org.json.JSONObject;
public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask { public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask {
/**
* Preferred constructor for DI.
*
* @param taskManager the application task manager, must not be {@code null}
*/
public AbstractPersistableTask(TaskManager taskManager) {
super(taskManager);
}
@Override @Override
public boolean isInsertable() { public boolean isInsertable() {
return true; return true;
@@ -29,26 +38,26 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe
@Override @Override
public void insert(CommandContext context) { public void insert(CommandContext context) {
if (TaskManager.getInstance().hasTask(getId())) { if (taskManager().hasTask(getId())) {
throw new RuntimeException("Cannot insert a task that already exists"); throw new RuntimeException("Cannot insert a task that already exists");
} }
TaskManager.getInstance().insert(context, this); taskManager().insert(context, this);
} }
@Override @Override
public void save() { public void save() {
if (!TaskManager.getInstance().hasTask(getId())) { if (!taskManager().hasTask(getId())) {
throw new RuntimeException("Cannot save a task that doesn't exist yet"); throw new RuntimeException("Cannot save a task that doesn't exist yet");
} }
TaskManager.getInstance().getTaskService().persistTask(this); taskManager().getTaskService().persistTask(this);
} }
@Override @Override
public void delete() { public void delete() {
TaskManager.getInstance().getTaskService().unpersistTask(this); taskManager().getTaskService().unpersistTask(this);
TaskManager.getInstance().removeTask(this); taskManager().removeTask(this);
} }
@Override @Override
@@ -31,6 +31,15 @@ public abstract class AbstractStepTask extends AbstractTask implements StepTask
private Guild guild; private Guild guild;
private String id = UUID.randomUUID().toString(); private String id = UUID.randomUUID().toString();
/**
* Preferred constructor for DI.
*
* @param taskManager the application task manager, must not be {@code null}
*/
public AbstractStepTask(TaskManager taskManager) {
super(taskManager);
}
@Override @Override
public String getId() { public String getId() {
return id; return id;
@@ -156,7 +165,7 @@ public abstract class AbstractStepTask extends AbstractTask implements StepTask
String oldId = id; String oldId = id;
synchronized (this) { synchronized (this) {
this.id = newId; this.id = newId;
TaskManager.getInstance().activateTask(this, oldId); taskManager().activateTask(this, oldId);
} }
} }
} }
@@ -14,9 +14,29 @@ import java.util.Objects;
public abstract class AbstractTask implements Task { public abstract class AbstractTask implements Task {
private final OffsetDateTime creationTime; private final OffsetDateTime creationTime;
/**
* Injected task manager when constructed via DI.
*/
private final TaskManager taskManager;
public AbstractTask() { /**
creationTime = OffsetDateTime.now(); * Preferred constructor for DI: stores the shared {@link TaskManager} for insert/activate/remove.
*
* @param taskManager the application task manager, must not be {@code null}
*/
public AbstractTask(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.creationTime = OffsetDateTime.now();
this.taskManager = taskManager;
}
/**
* Returns the injected task manager.
*
* @return the task manager to use
*/
protected final TaskManager taskManager() {
return taskManager;
} }
@Override @Override
@@ -26,7 +46,7 @@ public abstract class AbstractTask implements Task {
@Override @Override
public void insert(CommandContext context) { public void insert(CommandContext context) {
TaskManager.getInstance().insert(context, this); taskManager().insert(context, this);
} }
@Override @Override
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.core.tasks.impl;
import me.bobert.netneutrality.core.tasks.Task; import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.core.tasks.trait.WithButton; import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.TaskManager;
import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.components.buttons.ButtonStyle; import net.dv8tion.jda.api.components.buttons.ButtonStyle;
import net.dv8tion.jda.api.entities.emoji.Emoji; import net.dv8tion.jda.api.entities.emoji.Emoji;
@@ -18,7 +19,8 @@ public abstract class ButtonTask extends GenericRandomIdTask implements WithButt
private final String buttonLabel; private final String buttonLabel;
private Emoji emoji; private Emoji emoji;
public ButtonTask(ButtonStyle buttonStyle, String buttonLabel) { public ButtonTask(TaskManager taskManager, ButtonStyle buttonStyle, String buttonLabel) {
super(taskManager);
this.buttonStyle = buttonStyle; this.buttonStyle = buttonStyle;
this.buttonLabel = buttonLabel; this.buttonLabel = buttonLabel;
} }
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.tasks.impl;
*/ */
import me.bobert.netneutrality.context.tasks.EmojiTaskContext; import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -16,7 +17,8 @@ public abstract class EmojiTask extends AbstractPersistableTask {
private final String channelId; private final String channelId;
private final String messageId; private final String messageId;
public EmojiTask(String guildId, String channelId, String messageId) { public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) {
super(taskManager);
this.guildId = guildId; this.guildId = guildId;
this.channelId = channelId; this.channelId = channelId;
this.messageId = messageId; this.messageId = messageId;
@@ -1,20 +1,22 @@
package me.bobert.netneutrality.core.tasks.impl; package me.bobert.netneutrality.core.tasks.impl;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
public abstract class GenericCustomIdTask extends AbstractTask { import me.bobert.netneutrality.core.tasks.TaskManager;
private final String customId;
public GenericCustomIdTask(String id) { public abstract class GenericCustomIdTask extends AbstractTask {
this.customId = id; private final String id;
public GenericCustomIdTask(TaskManager taskManager, String id) {
super(taskManager);
this.id = id;
} }
@Override @Override
public String getId() { public String getId() {
return customId; return id;
} }
} }
@@ -1,15 +1,16 @@
package me.bobert.netneutrality.core.tasks.impl; package me.bobert.netneutrality.core.tasks.impl;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.UUID; import java.util.UUID;
public abstract class GenericRandomIdTask extends GenericCustomIdTask { public abstract class GenericRandomIdTask extends GenericCustomIdTask {
public GenericRandomIdTask() { public GenericRandomIdTask(TaskManager taskManager) {
super(UUID.randomUUID().toString()); super(taskManager, UUID.randomUUID().toString());
} }
} }
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.tasks.impl;
*/ */
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import net.dv8tion.jda.api.interactions.modals.ModalMapping; import net.dv8tion.jda.api.interactions.modals.ModalMapping;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@@ -14,6 +15,10 @@ import java.util.Optional;
public abstract class ModalTask extends GenericRandomIdTask { public abstract class ModalTask extends GenericRandomIdTask {
public ModalTask(TaskManager taskManager) {
super(taskManager);
}
@Override @Override
public boolean isExpired() { public boolean isExpired() {
return getCreationTime().plusMinutes(5).isBefore(OffsetDateTime.now()); return getCreationTime().plusMinutes(5).isBefore(OffsetDateTime.now());
@@ -0,0 +1,41 @@
package me.bobert.netneutrality.core.thread;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import me.bobert.netneutrality.bootstrap.Lifecycle;
/**
* Owns the shared executor services used across the bot.
*
* <p>As a {@link Lifecycle} component it shuts every pool down on {@link #stop()}.
*/
public interface ExecutorProvider extends Lifecycle {
/**
* Returns (creating on first use) the named cached-thread-pool executor.
*
* @param name the executor name
* @return the executor service
*/
ExecutorService getExecutor(String name);
/**
* Returns the general-purpose executor.
*
* @return the general executor service
*/
ExecutorService getExecutor();
/**
* Returns the shared scheduled executor.
*
* @return the scheduled executor service
*/
ScheduledExecutorService getScheduledExecutor();
}
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.thread;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.tasks.ScheduledTask; import me.bobert.netneutrality.core.tasks.ScheduledTask;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -16,14 +17,36 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
public class ScheduledTaskManager { /**
* Instance-based {@link Lifecycle} component that owns scheduled fixed-delay tasks.
*/
public class ScheduledTaskManager implements Lifecycle {
private static final Logger logger = LogManager.getLogger(ScheduledTaskManager.class); private static final Logger logger = LogManager.getLogger(ScheduledTaskManager.class);
private static final ScheduledExecutorService executorService = ThreadManager.getScheduledExecutor();
private static final Map<String, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
private ScheduledTaskManager() {} private final Map<String, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
private final ScheduledExecutorService executorService;
public static void schedule(ScheduledTask task) { public ScheduledTaskManager(ExecutorProvider executors) {
this.executorService = executors.getScheduledExecutor();
}
@Override
public int priority() {
// High priority number => stopped early on shutdown, before executors (priority 20).
return 55;
}
@Override
public void start() {
// Tasks are scheduled by TaskManagerImpl during its own start; nothing to eagerly start.
}
@Override
public void stop() {
cancelAll();
}
public void schedule(ScheduledTask task) {
String taskId = task.getId(); String taskId = task.getId();
if (scheduledTasks.containsKey(taskId)) { if (scheduledTasks.containsKey(taskId)) {
@@ -56,7 +79,7 @@ public class ScheduledTaskManager {
taskId, timeFrame.getInterval(), timeFrame.getUnit()); taskId, timeFrame.getInterval(), timeFrame.getUnit());
} }
public static void cancelAll() { public void cancelAll() {
logger.info("Cancelling all {} scheduled tasks", scheduledTasks.size()); logger.info("Cancelling all {} scheduled tasks", scheduledTasks.size());
scheduledTasks.forEach((id, future) -> future.cancel(false)); scheduledTasks.forEach((id, future) -> future.cancel(false));
scheduledTasks.clear(); scheduledTasks.clear();
@@ -12,11 +12,29 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
public class ThreadManager { /**
* Instance-based {@link ExecutorProvider} owning the named cached-thread pools and the shared
* scheduled executor.
*/
public class ThreadManager implements ExecutorProvider {
private static final Map<String, ExecutorService> executors = new ConcurrentHashMap<>(); private final Map<String, ExecutorService> executors = new ConcurrentHashMap<>();
public static ScheduledExecutorService getScheduledExecutor() { public ThreadManager() {
}
@Override
public int priority() {
return 20;
}
@Override
public void start() {
// Executors are created lazily on first use; nothing to eagerly start.
}
@Override
public ScheduledExecutorService getScheduledExecutor() {
return (ScheduledExecutorService) executors.computeIfAbsent( return (ScheduledExecutorService) executors.computeIfAbsent(
"scheduled-executor", "scheduled-executor",
s -> Executors.newScheduledThreadPool( s -> Executors.newScheduledThreadPool(
@@ -26,15 +44,18 @@ public class ThreadManager {
); );
} }
public static ExecutorService getExecutor(String name) { @Override
public ExecutorService getExecutor(String name) {
return executors.computeIfAbsent(name, s -> Executors.newCachedThreadPool(new BaseThreadFactory(s))); return executors.computeIfAbsent(name, s -> Executors.newCachedThreadPool(new BaseThreadFactory(s)));
} }
public static ExecutorService getExecutor() { @Override
public ExecutorService getExecutor() {
return getExecutor("general-executor"); return getExecutor("general-executor");
} }
public static void shutdownExecutor() { @Override
public void stop() {
System.out.println("[Executor] Shutting down..."); System.out.println("[Executor] Shutting down...");
try { try {
@@ -56,6 +77,4 @@ public class ThreadManager {
System.out.println("[Executor] Shutdown finished!"); System.out.println("[Executor] Shutdown finished!");
} }
} }
@@ -1,11 +1,11 @@
package me.bobert.netneutrality.core.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.Settings; 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;
@@ -14,12 +14,12 @@ import org.flywaydb.core.api.output.MigrateResult;
public class DatabaseMigration { public class DatabaseMigration {
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class); private final static Logger logger = LogManager.getLogger(DatabaseMigration.class);
public static void migrate() { public static void migrate(SettingsProvider settings) {
String dbName = Settings.getInstance().get("MYSQL_DATABASE", "netneutrality"); String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
String dbUser = Settings.getInstance().get("MYSQL_USER", "root"); String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = Settings.getInstance().get("MYSQL_PASSWORD", ""); String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = Settings.getInstance().get("MYSQL_HOST", "localhost"); String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = Settings.getInstance().get("MYSQL_PORT", "3306"); String dbPort = settings.get("MYSQL_PORT", "3306");
String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName); String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName);
try { try {
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.utils; package me.bobert.netneutrality.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.utils;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.db.SessionManager;
import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -14,15 +13,59 @@ import org.apache.logging.log4j.Logger;
import org.hibernate.HibernateException; import org.hibernate.HibernateException;
import org.hibernate.ObjectNotFoundException; import org.hibernate.ObjectNotFoundException;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction; import org.hibernate.Transaction;
import org.hibernate.exception.ConstraintViolationException; import org.hibernate.exception.ConstraintViolationException;
import java.io.Serializable; import java.io.Serializable;
import java.util.Objects;
import java.util.function.Consumer; 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 { public class JpaUtils {
private static final Logger logger = LogManager.getLogger(JpaUtils.class); 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. * Tries to read the persistent state into the object.
@@ -44,7 +87,12 @@ public class JpaUtils {
String cachedId = oGuild.getGuildId(); String cachedId = oGuild.getGuildId();
logger.error("Trying to load Guild[{}] while entity doesn't exist in database, reloading..", cachedId); logger.error("Trying to load Guild[{}] while entity doesn't exist in database, reloading..", cachedId);
GuildService service = GuildService.get(cachedId); Function<String, GuildService> lookup = guildServiceLookup;
if (lookup == null) {
session.close();
throw exception;
}
GuildService service = lookup.apply(cachedId);
service.reloadEntity(); service.reloadEntity();
service.getBlacklistService().removeGuildBlacklist(); service.getBlacklistService().removeGuildBlacklist();
logger.info("Guild[{}] reloaded", cachedId); logger.info("Guild[{}] reloaded", cachedId);
@@ -56,14 +104,18 @@ public class JpaUtils {
} }
} }
public static Session openSession() {
return factory().openSession();
}
public static void runWithSession(Consumer<Session> consumer) { public static void runWithSession(Consumer<Session> consumer) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = factory().openSession()) {
consumer.accept(session); consumer.accept(session);
} }
} }
public static void runWithTransaction(Consumer<Session> consumer) { public static void runWithTransaction(Consumer<Session> consumer) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = factory().openSession()) {
session.beginTransaction(); session.beginTransaction();
consumer.accept(session); consumer.accept(session);
session.getTransaction().commit(); session.getTransaction().commit();
@@ -71,7 +123,7 @@ public class JpaUtils {
} }
public static void updateWithTransaction(Object persistantEntity, Runnable runnable) { public static void updateWithTransaction(Object persistantEntity, Runnable runnable) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = factory().openSession()) {
Transaction transaction = session.beginTransaction(); Transaction transaction = session.beginTransaction();
try { try {
@@ -98,7 +150,7 @@ public class JpaUtils {
* @param consumer The comsumer, which exposes the session * @param consumer The comsumer, which exposes the session
*/ */
public static void getWithSession(Object persistantEntity, Serializable id, Consumer<Session> consumer) { public static void getWithSession(Object persistantEntity, Serializable id, Consumer<Session> consumer) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = factory().openSession()) {
tryLoad(session, persistantEntity, id); tryLoad(session, persistantEntity, id);
consumer.accept(session); consumer.accept(session);
} }
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.db;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.db.SessionManager;
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.Session; import org.hibernate.Session;
@@ -24,7 +23,7 @@ public interface Repository<O> {
default void save(O entity) { default void save(O entity) {
Transaction transaction = null; Transaction transaction = null;
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = JpaUtils.openSession()) {
transaction = session.beginTransaction(); transaction = session.beginTransaction();
session.merge(entity); session.merge(entity);
transaction.commit(); transaction.commit();
@@ -0,0 +1,98 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.sentry.Sentry;
import me.bobert.netneutrality.utils.SettingsProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.Environment;
import java.util.HashMap;
import java.util.Map;
/**
* Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its
* service registry.
*
* <p>On construction the instance is bound into {@link JpaUtils} so repositories and other static
* persistence helpers can open sessions without a global {@code SessionManager} singleton.
*/
public class SessionManager implements SessionProvider {
private static final Logger logger = LogManager.getLogger(SessionManager.class);
private final SettingsProvider settings;
private StandardServiceRegistry registry;
private SessionFactory sessionFactory;
public SessionManager(SettingsProvider settings) {
this.settings = settings;
JpaUtils.bind(this);
}
@Override
public int priority() {
return 10;
}
@Override
public void start() {
getSessionFactory();
DatabaseMigration.migrate(settings);
}
@Override
public void stop() {
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
registry = null;
sessionFactory = null;
}
}
@Override
public SessionFactory getSessionFactory() {
if (sessionFactory == null) {
try {
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure();
Map<String, Object> config = new HashMap<>();
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306");
logger.info("Connecting to database '{}' at {}:{} with user '{}'", dbName, dbHost, dbPort, dbUser);
config.put(Environment.JAKARTA_JDBC_URL, String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
config.put(Environment.JAKARTA_JDBC_USER, dbUser);
config.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
registryBuilder.applySettings(config);
registry = registryBuilder.build();
MetadataSources sources = new MetadataSources(registry);
Metadata metadata = sources.getMetadataBuilder().build();
sessionFactory = metadata.getSessionFactoryBuilder().build();
} catch (Exception e) {
String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev");
if (!"dev".equals(env)) {
Sentry.captureException(e);
}
stop();
logger.error("Error connecting to database. Shutting down..");
System.exit(1);
}
}
return sessionFactory;
}
}
@@ -0,0 +1,26 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.Lifecycle;
import org.hibernate.SessionFactory;
/**
* Owns the Hibernate {@link SessionFactory} and its underlying service registry.
*
* <p>As a {@link Lifecycle} component it builds the factory (and runs migrations) on
* {@link #start()} and releases the registry on {@link #stop()}.
*/
public interface SessionProvider extends Lifecycle {
/**
* Returns the shared Hibernate {@link SessionFactory}, building it lazily if necessary.
*
* @return the session factory
*/
SessionFactory getSessionFactory();
}
@@ -5,11 +5,10 @@ package me.bobert.netneutrality.db.repository;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.db.SessionManager;
import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.Repository;
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.utils.JpaUtils; import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.query.Query; import org.hibernate.query.Query;
@@ -92,7 +91,7 @@ public class BlacklistRepository implements Repository<OBlacklist> {
} }
private OBlacklist getWordFromDb(String guildId, String word) { private OBlacklist getWordFromDb(String guildId, String word) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = JpaUtils.openSession()) {
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class); 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("guildId", guildId);
query.setParameter("word", word); query.setParameter("word", word);
@@ -4,10 +4,15 @@ package me.bobert.netneutrality.db.repository;/*
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.db.SessionManager; /*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.utils.JpaUtils; import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.query.Query; import org.hibernate.query.Query;
@@ -30,7 +35,7 @@ public class GuildRepository implements Repository<OGuild> {
@Override @Override
public OGuild findById(String id) { public OGuild findById(String id) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = JpaUtils.openSession()) {
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class); Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class);
query.setParameter("guildId", id); query.setParameter("guildId", id);
@@ -4,12 +4,17 @@ package me.bobert.netneutrality.db.repository;/*
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.db.SessionManager; /*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.entities.OGuildSetting; import me.bobert.netneutrality.db.entities.OGuildSetting;
import me.bobert.netneutrality.utils.JpaUtils; import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.query.Query; import org.hibernate.query.Query;
@@ -32,7 +37,7 @@ public class GuildSettingRepository implements Repository<OGuildSetting> {
@Override @Override
public OGuildSetting findById(String id) { public OGuildSetting findById(String id) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = JpaUtils.openSession()) {
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class); Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class);
query.setParameter("id", id); query.setParameter("id", id);
@@ -5,11 +5,10 @@ package me.bobert.netneutrality.db.repository;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.core.db.SessionManager;
import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.entities.OLogMessage; import me.bobert.netneutrality.db.entities.OLogMessage;
import me.bobert.netneutrality.utils.JpaUtils; import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -35,7 +34,7 @@ public class MessageRepository implements Repository<OLogMessage> {
@Override @Override
public OLogMessage findById(String id) { public OLogMessage findById(String id) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = JpaUtils.openSession()) {
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class); Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class);
query.setParameter("messageId", id); query.setParameter("messageId", id);
@@ -6,10 +6,9 @@ package me.bobert.netneutrality.db.repository;
*/ */
import me.bobert.netneutrality.core.db.SessionManager;
import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.entities.OTask; import me.bobert.netneutrality.db.entities.OTask;
import me.bobert.netneutrality.utils.JpaUtils; import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.query.Query; import org.hibernate.query.Query;
@@ -33,7 +32,7 @@ public class TaskRepository implements Repository<OTask> {
@Override @Override
public OTask findById(String id) { public OTask findById(String id) {
try (Session session = SessionManager.getSessionFactory().openSession()) { try (Session session = JpaUtils.openSession()) {
Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class); Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class);
query.setParameter("taskId", id); query.setParameter("taskId", id);
@@ -10,7 +10,7 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult;
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.repository.BlacklistRepository; import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.utils.JpaUtils; import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -8,40 +8,29 @@ package me.bobert.netneutrality.db.service;
import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.repository.GuildRepository; import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.utils.JpaUtils; import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Guild;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.Map; /**
import java.util.concurrent.ConcurrentHashMap; * Per-guild domain service (blacklist, settings, entity access).
*
* <p>Instances are owned by {@link GuildServiceProviderImpl}; obtain them via
* {@link me.bobert.netneutrality.db.service.GuildServiceProvider}.
*/
public class GuildService { public class GuildService {
private static final Map<String, GuildService> services = new ConcurrentHashMap<>();
private static final Logger logger = LoggerFactory.getLogger(GuildService.class); private static final Logger logger = LoggerFactory.getLogger(GuildService.class);
private final GuildRepository guildRepository = new GuildRepository(); private final GuildRepository guildRepository = new GuildRepository();
private final BlacklistService blacklistService; private final BlacklistService blacklistService;
private final GuildSettingService guildSettingService; private final GuildSettingService guildSettingService;
private OGuild oGuild; private OGuild oGuild;
private GuildService(String guildId) { GuildService(String guildId) {
this.oGuild = findOrCreateGuild(guildId); this.oGuild = findOrCreateGuild(guildId);
this.blacklistService = new BlacklistService(this); this.blacklistService = new BlacklistService(this);
this.guildSettingService = new GuildSettingService(this); this.guildSettingService = new GuildSettingService(this);
} }
public static GuildService get(String guildId) {
return services.computeIfAbsent(guildId, GuildService::new);
}
public static GuildService get(Guild guild) {
return get(guild.getId());
}
public static Map<String, GuildService> getServices() {
return services;
}
public OGuild getEntity() { public OGuild getEntity() {
return oGuild; return oGuild;
} }
@@ -0,0 +1,40 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.Guild;
import java.util.Map;
/**
* Owns the per-guild {@link GuildService} cache.
*/
public interface GuildServiceProvider {
/**
* Returns (creating on first use) the service for the given guild id.
*
* @param guildId the Discord guild id
* @return the guild service
*/
GuildService get(String guildId);
/**
* Returns (creating on first use) the service for the given guild.
*
* @param guild the Discord guild
* @return the guild service
*/
GuildService get(Guild guild);
/**
* Returns the live cache of guild services.
*
* @return the services keyed by guild id
*/
Map<String, GuildService> getServices();
}
@@ -0,0 +1,38 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.Guild;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Instance-based {@link GuildServiceProvider} owning the per-guild {@link GuildService} cache.
*/
public class GuildServiceProviderImpl implements GuildServiceProvider {
private final Map<String, GuildService> services = new ConcurrentHashMap<>();
public GuildServiceProviderImpl() {
}
@Override
public GuildService get(String guildId) {
return services.computeIfAbsent(guildId, GuildService::new);
}
@Override
public GuildService get(Guild guild) {
return get(guild.getId());
}
@Override
public Map<String, GuildService> getServices() {
return services;
}
}
@@ -18,10 +18,12 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
public class TaskService { public class TaskService {
private final TaskManager taskManager;
private static final Logger logger = LogManager.getLogger(TaskService.class); private static final Logger logger = LogManager.getLogger(TaskService.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
public TaskService() { public TaskService(TaskManager taskManager) {
this.taskManager = taskManager;
this.taskRepository = new TaskRepository(); this.taskRepository = new TaskRepository();
} }
@@ -51,7 +53,7 @@ public class TaskService {
} }
validateTask(task, oTask); validateTask(task, oTask);
TaskManager.getInstance().insertPersistableTask(task); taskManager.insertPersistableTask(task);
} catch (Exception e) { } catch (Exception e) {
logger.error("Error loading task with ID {}: {}", taskId, e.getMessage()); logger.error("Error loading task with ID {}: {}", taskId, e.getMessage());
} }
@@ -19,9 +19,11 @@ import me.bobert.netneutrality.core.command.CommandProcessor;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager; 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.utils.Settings; import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.events.Event; import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.events.GenericEvent; import net.dv8tion.jda.api.events.GenericEvent;
@@ -40,15 +42,34 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
public class EventsHandler implements EventListener { public class EventsHandler implements EventListener {
private static final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners = new ConcurrentHashMap<>();
private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class); private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class);
static { private final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners = new ConcurrentHashMap<>();
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final CommandRegistry commands;
private final TaskManager taskManager;
public EventsHandler(
SettingsProvider settings,
GuildServiceProvider guildServices,
CommandRegistry commands,
TaskManager taskManager
) {
this.settings = Objects.requireNonNull(settings, "settings");
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
this.commands = Objects.requireNonNull(commands, "commands");
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
registerListeners();
}
private void registerListeners() {
addListener(MessageReceivedEvent.class, event -> { addListener(MessageReceivedEvent.class, event -> {
if (event.getAuthor().isBot()) { if (event.getAuthor().isBot()) {
return null; return null;
@@ -59,9 +80,9 @@ public class EventsHandler implements EventListener {
Message message = event.getMessage(); Message message = event.getMessage();
String rawMessage = message.getContentDisplay().trim(); String rawMessage = message.getContentDisplay().trim();
String prefix = Settings.getInstance().get("NETNEUTRALITY_BOT_PREFIX", "$"); String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
if (rawMessage.startsWith(prefix)) { if (rawMessage.startsWith(prefix)) {
return new MessageCommandContext(event); return new MessageCommandContext(event, settings);
} }
return new MessageTaskContext(event); return new MessageTaskContext(event);
@@ -74,13 +95,13 @@ public class EventsHandler implements EventListener {
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new); addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new); addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
addGenericListener(GuildJoinEvent.class, event -> { addGenericListener(GuildJoinEvent.class, event -> {
GuildService service = GuildService.get(event.getGuild()); GuildService service = guildServices.get(event.getGuild());
if (service.isDebugEnabled()) { if (service.isDebugEnabled()) {
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel"); LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
} }
}); });
addGenericListener(GuildLeaveEvent.class, event -> GuildService.get(event.getGuild()).deleteGuild()); addGenericListener(GuildLeaveEvent.class, event -> guildServices.get(event.getGuild()).deleteGuild());
} }
/** /**
@@ -91,7 +112,7 @@ public class EventsHandler implements EventListener {
* @param <T> the type of the event that extends the {@link Event} class * @param <T> the type of the event that extends the {@link Event} class
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public static <T extends Event> void addListener(Class<T> eventClass, Function<? super T, Context> function) { public <T extends Event> void addListener(Class<T> eventClass, Function<? super T, Context> function) {
logger.info("Registering listener for {}", eventClass.getSimpleName()); logger.info("Registering listener for {}", eventClass.getSimpleName());
listeners.put(eventClass, (Function<? extends Event, Context>) function); listeners.put(eventClass, (Function<? extends Event, Context>) function);
} }
@@ -104,7 +125,7 @@ public class EventsHandler implements EventListener {
* @param consumer the consumer to handle the event, which takes an event of the specified type and performs an action * @param consumer the consumer to handle the event, which takes an event of the specified type and performs an action
* @param <T> the type of the event that extends the {@link Event} class * @param <T> the type of the event that extends the {@link Event} class
*/ */
public static <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) { public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) {
addListener(eventClass, t -> { addListener(eventClass, t -> {
consumer.accept(t); consumer.accept(t);
return null; return null;
@@ -141,11 +162,11 @@ public class EventsHandler implements EventListener {
} }
private void handleCommand(CommandContext context) { private void handleCommand(CommandContext context) {
CommandProcessor processor = new CommandProcessor(context); CommandProcessor processor = new CommandProcessor(context, commands, taskManager);
processor.dispatch(); processor.dispatch();
} }
private void handleTask(TaskContext context) { private void handleTask(TaskContext context) {
TaskManager.getInstance().handle(context); taskManager.handle(context);
} }
} }
@@ -8,25 +8,38 @@ package me.bobert.netneutrality.http.server;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import me.bobert.netneutrality.NetNeutrality; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.function.Supplier;
public class ActionHandler extends AbstractHandler { public class ActionHandler extends AbstractHandler {
private final Supplier<JDA> jdaSupplier;
private final GuildServiceProvider guildServices;
private final Runnable shutdownHook;
public ActionHandler(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Runnable shutdownHook) {
this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier");
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
this.shutdownHook = Objects.requireNonNull(shutdownHook, "shutdownHook");
}
@Override @Override
public void handle(HttpExchange exchange) throws IOException { public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getRawPath(); String path = exchange.getRequestURI().getRawPath();
if (path.equals("/actions/shutdown")) { if (path.equals("/actions/shutdown")) {
LogManager.getLogger(NetNeutrality.class).info("Request to shutdown received"); LogManager.getLogger(ActionHandler.class).info("Request to shutdown received");
NetNeutrality.getInstance().shutdown(); shutdownHook.run();
sendStringResponse(exchange, "Shutting down.."); sendStringResponse(exchange, "Shutting down..");
System.exit(0); System.exit(0);
@@ -35,8 +48,9 @@ public class ActionHandler extends AbstractHandler {
if (path.equals("/actions/status")) { if (path.equals("/actions/status")) {
TreeMap<String, String> json = new TreeMap<>(); TreeMap<String, String> json = new TreeMap<>();
json.put("health", String.valueOf(NetNeutrality.getInstance().getJDA().getStatus())); JDA jda = jdaSupplier.get();
json.put("ping", String.valueOf(NetNeutrality.getInstance().getJDA().getGatewayPing())); json.put("health", String.valueOf(jda.getStatus()));
json.put("ping", String.valueOf(jda.getGatewayPing()));
sendJsonResponse(exchange, new Gson().toJson(json)); sendJsonResponse(exchange, new Gson().toJson(json));
return; return;
@@ -47,9 +61,9 @@ public class ActionHandler extends AbstractHandler {
String[] args = exchange.getRequestURI().toString().split("/"); String[] args = exchange.getRequestURI().toString().split("/");
String guildId = args[args.length - 1]; String guildId = args[args.length - 1];
Guild guild = NetNeutrality.getInstance().getJDA().getGuildById(guildId); Guild guild = jdaSupplier.get().getGuildById(guildId);
if (guild != null) { if (guild != null) {
GuildService service = GuildService.get(guild); GuildService service = guildServices.get(guild);
Map<String, String> settings = service.getGuildSettingService().getSettings(); Map<String, String> settings = service.getGuildSettingService().getSettings();
JSONObject setting = new JSONObject(settings); JSONObject setting = new JSONObject(settings);
@@ -69,7 +83,7 @@ public class ActionHandler extends AbstractHandler {
String guildId = args[args.length - 1]; String guildId = args[args.length - 1];
boolean ok = true; //TODO: add bool boolean ok = true; //TODO: add bool
GuildService.get(guildId).getGuildSettingService().reloadSettingsCache(); guildServices.get(guildId).getGuildSettingService().reloadSettingsCache();
json.put("status", String.valueOf(ok)); json.put("status", String.valueOf(ok));
sendJsonResponse(exchange, new Gson().toJson(json)); sendJsonResponse(exchange, new Gson().toJson(json));
@@ -7,14 +7,20 @@ package me.bobert.netneutrality.http.server;
*/ */
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import me.bobert.netneutrality.NetNeutrality;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException; import java.io.IOException;
import java.util.Objects;
import java.util.function.Supplier;
public class GuildHandler extends AbstractHandler { public class GuildHandler extends AbstractHandler {
private final Supplier<JDA> jdaSupplier;
public GuildHandler(Supplier<JDA> jdaSupplier) {
this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier");
}
@Override @Override
public void handle(HttpExchange exchange) throws IOException { public void handle(HttpExchange exchange) throws IOException {
@@ -25,7 +31,7 @@ public class GuildHandler extends AbstractHandler {
JSONArray array = new JSONArray(); JSONArray array = new JSONArray();
JDA jda = NetNeutrality.getInstance().getJDA(); JDA jda = jdaSupplier.get();
jda.getGuilds().forEach(guild -> { jda.getGuilds().forEach(guild -> {
JSONObject obj = new JSONObject(); JSONObject obj = new JSONObject();
@@ -45,7 +51,7 @@ public class GuildHandler extends AbstractHandler {
String guild = args[args.length - 1]; String guild = args[args.length - 1];
JSONArray array = new JSONArray(); JSONArray array = new JSONArray();
JDA jda = NetNeutrality.getInstance().getJDA(); JDA jda = jdaSupplier.get();
if (jda.getGuildById(guild) == null) { if (jda.getGuildById(guild) == null) {
sendStringResponse(exchange, path); sendStringResponse(exchange, path);
@@ -69,7 +75,7 @@ public class GuildHandler extends AbstractHandler {
String[] args = exchange.getRequestURI().toString().split("/"); String[] args = exchange.getRequestURI().toString().split("/");
String guild = args[args.length - 1]; String guild = args[args.length - 1];
JSONArray array = new JSONArray(); JSONArray array = new JSONArray();
JDA jda = NetNeutrality.getInstance().getJDA(); JDA jda = jdaSupplier.get();
if (jda.getGuildById(guild) == null) { if (jda.getGuildById(guild) == null) {
sendStringResponse(exchange, path); sendStringResponse(exchange, path);
@@ -7,7 +7,11 @@ package me.bobert.netneutrality.http.server;
*/ */
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.core.thread.BaseThreadFactory; import me.bobert.netneutrality.core.thread.BaseThreadFactory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -15,32 +19,73 @@ import java.io.IOException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.function.IntSupplier;
import java.util.function.Supplier;
public class WebApi { /**
* Embedded HTTP API exposed as a {@link Lifecycle} component (priority 70, after JDA).
*/
public class WebApi implements Lifecycle {
private static final Logger logger = LogManager.getLogger(WebApi.class); private static final Logger logger = LogManager.getLogger(WebApi.class);
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final Supplier<JDA> jdaSupplier;
private final Runnable shutdownHook;
private HttpServer server; private HttpServer server;
private ExecutorService executor;
public WebApi(int port) { public WebApi(
SettingsProvider settings,
GuildServiceProvider guildServices,
Supplier<JDA> jdaSupplier,
Runnable shutdownHook
) {
this.settings = settings;
this.guildServices = guildServices;
this.jdaSupplier = jdaSupplier;
this.shutdownHook = shutdownHook;
}
@Override
public int priority() {
// After JDA (60) so handlers can resolve a ready client.
return 70;
}
@Override
public void start() {
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
logger.info("Starting web server..."); logger.info("Starting web server...");
try { try {
InetSocketAddress sockAddr = new InetSocketAddress(port); InetSocketAddress sockAddr = new InetSocketAddress(port);
server = HttpServer.create(sockAddr, 0); server = HttpServer.create(sockAddr, 0);
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); logger.error("Failed to create HTTP server", e);
return;
} }
server.createContext("/", new RootHandler()); server.createContext("/", new RootHandler());
server.createContext("/actions", new ActionHandler()); server.createContext("/actions", new ActionHandler(jdaSupplier, guildServices, shutdownHook));
server.createContext("/guilds", new GuildHandler()); server.createContext("/guilds", new GuildHandler(jdaSupplier));
ExecutorService executor = Executors.newFixedThreadPool(10, new BaseThreadFactory("web-api")); executor = Executors.newFixedThreadPool(10, new BaseThreadFactory("web-api"));
server.setExecutor(executor); server.setExecutor(executor);
server.start(); server.start();
logger.info("Web server started at [{}]", server.getAddress()); logger.info("Web server started at [{}]", server.getAddress());
} }
@Override
public void stop() {
if (server != null) {
logger.info("Stopping web server...");
server.stop(0);
server = null;
}
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
} }
@@ -6,89 +6,56 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.utils.MusicUtils;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager;
import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.YoutubeSourceOptions;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.source.UdioAudioSourceManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.utils.Settings;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
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.Collection; /**
import java.util.Map; * Per-guild music player and queue owner.
import java.util.concurrent.ConcurrentHashMap; *
* <p>The shared {@link AudioPlayerManager} and the manager cache live on {@link MusicServiceImpl}.
* Obtain instances via {@link me.bobert.netneutrality.music.MusicService#get(Guild)}.
*/
public class MusicManager { public class MusicManager {
private static final Logger logger = LogManager.getLogger(MusicManager.class); private static final Logger logger = LogManager.getLogger(MusicManager.class);
private static final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
private static final AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
private final Guild guild; private final Guild guild;
private final AudioPlayerManager playerManager;
private final AudioPlayer player; private final AudioPlayer player;
private final TrackScheduler scheduler; private final TrackScheduler scheduler;
private final int baseVolume; private final int baseVolume;
private final GuildService guildService;
private final TaskManager taskManager;
private MusicManager(Guild guild) { MusicManager(Guild guild, AudioPlayerManager playerManager, GuildService guildService, TaskManager taskManager) {
this.guild = guild; this.guild = guild;
this.playerManager = playerManager;
this.player = playerManager.createPlayer(); this.player = playerManager.createPlayer();
this.scheduler = new TrackScheduler(this); this.scheduler = new TrackScheduler(this);
this.baseVolume = Integer.parseInt(GuildService.get(guild).getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME)); this.guildService = guildService;
this.taskManager = taskManager;
this.baseVolume = Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player)); guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player));
player.addListener(scheduler); player.addListener(scheduler);
managers.put(guild.getId(), this);
} }
public static MusicManager get(Guild guild) { public GuildService getGuildService() {
if (managers.containsKey(guild.getId())) { return guildService;
return managers.get(guild.getId());
} else {
return new MusicManager(guild);
}
} }
public static void initPlayerManager() { public TaskManager getTaskManager() {
logger.debug("Initializing music player sources.."); return taskManager;
String cipherUrl = Settings.getInstance().get("NETNEUTRALITY_CIPHER_URL");
String cipherPassword = Settings.getInstance().get("NETNEUTRALITY_CIPHER_PASSWORD");
YoutubeSourceOptions options = new YoutubeSourceOptions()
.setAllowSearch(true)
.setAllowDirectVideoIds(true)
.setAllowDirectPlaylistIds(true)
.setRemoteCipher(cipherUrl, cipherPassword, "");
playerManager.registerSourceManager(new YoutubeAudioSourceManager(options, YoutubeAudioSourceManager.DEFAULT_CLIENTS));
playerManager.registerSourceManager(SoundCloudAudioSourceManager.createDefault());
playerManager.registerSourceManager(new UdioAudioSourceManager());
playerManager.registerSourceManager(new HttpAudioSourceManager());
String po = Settings.getInstance().get("NETNEUTRALITY_PO_TOKEN");
String visitor = Settings.getInstance().get("NETNEUTRALITY_VISITOR_DATA");
dev.lavalink.youtube.clients.Web.setPoTokenAndVisitorData(po, visitor);
}
public static void disconnectPlayers() {
for (MusicManager mm : managers.values()) {
mm.getQueueManager().clearQueue();
mm.player.destroy();
}
}
public static Collection<MusicManager> getManagers() {
return managers.values();
} }
public TrackScheduler getScheduler() { public TrackScheduler getScheduler() {
@@ -152,7 +119,7 @@ public class MusicManager {
public synchronized void play(String query, CommandContext context, boolean silent) { public synchronized void play(String query, CommandContext context, boolean silent) {
logger.debug("Guild {}: request to play '{}' by user {}", guild.getId(), query, context.getGuildUser().getId()); logger.debug("Guild {}: request to play '{}' by user {}", guild.getId(), query, context.getGuildUser().getId());
if (MusicUtils.canConnect(context)) { if (MusicUtils.canConnect(context, this)) {
SourceFinder.AudioSourceType type = SourceFinder.getSource(query); SourceFinder.AudioSourceType type = SourceFinder.getSource(query);
String identifier = SourceFinder.getIdentifier(query); String identifier = SourceFinder.getIdentifier(query);
if (identifier == null) { if (identifier == null) {
@@ -0,0 +1,35 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.Collection;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import net.dv8tion.jda.api.entities.Guild;
/**
* Owns the shared audio player manager and the per-guild {@link MusicManager} cache.
*
* <p>As a {@link Lifecycle} component it registers audio sources on {@link #start()} and
* disconnects every player on {@link #stop()}.
*/
public interface MusicService extends Lifecycle {
/**
* Returns (creating on first use) the music manager for the given guild.
*
* @param guild the guild
* @return the guild's music manager
*/
MusicManager get(Guild guild);
/**
* Returns every currently cached per-guild music manager.
*
* @return the managers
*/
Collection<MusicManager> getManagers();
}
@@ -0,0 +1,103 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager;
import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.YoutubeSourceOptions;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.music.source.UdioAudioSourceManager;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Instance-based {@link MusicService} owning the shared {@link AudioPlayerManager} and the
* per-guild {@link MusicManager} cache.
*/
public class MusicServiceImpl implements MusicService {
private static final Logger logger = LogManager.getLogger(MusicServiceImpl.class);
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final TaskManager taskManager;
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
private final AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager) {
this.settings = settings;
this.guildServices = guildServices;
this.taskManager = taskManager;
}
@Override
public int priority() {
return 40;
}
@Override
public void start() {
logger.debug("Initializing music player sources..");
String cipherUrl = settings.get("NETNEUTRALITY_CIPHER_URL");
String cipherPassword = settings.get("NETNEUTRALITY_CIPHER_PASSWORD");
YoutubeSourceOptions options = new YoutubeSourceOptions()
.setAllowSearch(true)
.setAllowDirectVideoIds(true)
.setAllowDirectPlaylistIds(true)
.setRemoteCipher(cipherUrl, cipherPassword, "");
playerManager.registerSourceManager(new YoutubeAudioSourceManager(options, YoutubeAudioSourceManager.DEFAULT_CLIENTS));
playerManager.registerSourceManager(SoundCloudAudioSourceManager.createDefault());
playerManager.registerSourceManager(new UdioAudioSourceManager());
playerManager.registerSourceManager(new HttpAudioSourceManager());
String po = settings.get("NETNEUTRALITY_PO_TOKEN");
String visitor = settings.get("NETNEUTRALITY_VISITOR_DATA");
dev.lavalink.youtube.clients.Web.setPoTokenAndVisitorData(po, visitor);
}
@Override
public void stop() {
for (MusicManager mm : managers.values()) {
mm.getQueueManager().clearQueue();
mm.getPlayer().destroy();
}
}
@Override
public MusicManager get(Guild guild) {
if (managers.containsKey(guild.getId())) {
return managers.get(guild.getId());
}
MusicManager manager = new MusicManager(guild, playerManager, guildServices.get(guild), taskManager);
managers.put(guild.getId(), manager);
return manager;
}
@Override
public Collection<MusicManager> getManagers() {
return managers.values();
}
/**
* Returns the shared Lavaplayer {@link AudioPlayerManager}.
*
* @return the player manager
*/
public AudioPlayerManager getPlayerManager() {
return playerManager;
}
}
@@ -4,6 +4,12 @@ package me.bobert.netneutrality.music;/*
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more