diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b9db6e..3854d04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,15 @@ on: branches: - "main" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: package: name: Package - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -32,7 +37,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: junit-reports path: | @@ -40,7 +45,7 @@ jobs: if-no-files-found: ignore - name: Upload Maven artifacts - uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: maven-artifacts path: | @@ -48,11 +53,40 @@ jobs: startup.sh if-no-files-found: error - docker-main: - name: Docker Build and Push (main) - runs-on: ubuntu-latest + maven-publish: + name: Publish Maven Package + runs-on: ubuntu-24.04 + timeout-minutes: 15 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: contents: read packages: write @@ -61,51 +95,25 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download build artifacts - uses: actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: maven-artifacts path: . - - name: Log in to registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 - with: - registry: ${{ secrets.REGISTRY_URL }} - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} - - - name: Build and push (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 + - name: Compute Docker tags + id: meta shell: bash run: | - # Strip leading 'v' if present (e.g., v1.2.3 -> 1.2.3) - ver="${GITHUB_REF_NAME#v}" - echo "version=$ver" >> "$GITHUB_OUTPUT" + REGISTRY="${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + ver="${GITHUB_REF_NAME#v}" + echo "tags=${REGISTRY}:${ver},${REGISTRY}:latest" >> "$GITHUB_OUTPUT" + else + echo "tags=${REGISTRY}:main" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - name: Log in to registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 @@ -114,12 +122,12 @@ jobs: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - - name: Build and push (release + latest) + - name: Build and push uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 with: context: . file: ./Dockerfile push: true - tags: | - ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:${{ steps.rel.outputs.version }} - ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality:latest + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.junie/plans/di-lifecycle-refactor.md b/.junie/plans/di-lifecycle-refactor.md new file mode 100644 index 0000000..c7c95b1 --- /dev/null +++ b/.junie/plans/di-lifecycle-refactor.md @@ -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 components = new ArrayList<>(); + public 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. \ No newline at end of file diff --git a/.junie/plans/revert-package-structure.md b/.junie/plans/revert-package-structure.md new file mode 100644 index 0000000..a39ac20 --- /dev/null +++ b/.junie/plans/revert-package-structure.md @@ -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//`, `tasks//`, `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 \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e531917 --- /dev/null +++ b/docs/README.md @@ -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). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4cc6dcf --- /dev/null +++ b/docs/architecture.md @@ -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 | diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..14e6e00 --- /dev/null +++ b/docs/commands.md @@ -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` 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). diff --git a/docs/contexts.md b/docs/contexts.md new file mode 100644 index 0000000..042b117 --- /dev/null +++ b/docs/contexts.md @@ -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` | diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..90a2853 --- /dev/null +++ b/docs/events.md @@ -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, Function> 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, Function)` — registers a factory that produces a + `Context` to be routed onward (command or task flow). +- `addGenericListener(Class, Consumer)` — 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. diff --git a/docs/music.md b/docs/music.md new file mode 100644 index 0000000..f0d064f --- /dev/null +++ b/docs/music.md @@ -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`); 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` 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). diff --git a/docs/persistence.md b/docs/persistence.md new file mode 100644 index 0000000..eb4a698 --- /dev/null +++ b/docs/persistence.md @@ -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`** (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. diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 0000000..01d1dd5 --- /dev/null +++ b/docs/tasks.md @@ -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. diff --git a/docs/threading.md b/docs/threading.md new file mode 100644 index 0000000..1b7b1d0 --- /dev/null +++ b/docs/threading.md @@ -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)). diff --git a/docs/utilities-and-bootstrap.md b/docs/utilities-and-bootstrap.md new file mode 100644 index 0000000..8c61f9f --- /dev/null +++ b/docs/utilities-and-bootstrap.md @@ -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)`, 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)` — 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). diff --git a/docs/web-api.md b/docs/web-api.md new file mode 100644 index 0000000..24bd5c0 --- /dev/null +++ b/docs/web-api.md @@ -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. diff --git a/pom.xml b/pom.xml index 0b7ca0b..423a07a 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ jar - me.bobert.netneutrality.NetNeutrality + me.bobert.netneutrality.App 25 UTF-8 25 diff --git a/src/main/java/me/bobert/netneutrality/App.java b/src/main/java/me/bobert/netneutrality/App.java new file mode 100644 index 0000000..36487a4 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/App.java @@ -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(); + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/NetNeutrality.java b/src/main/java/me/bobert/netneutrality/NetNeutrality.java deleted file mode 100644 index 04b82f9..0000000 --- a/src/main/java/me/bobert/netneutrality/NetNeutrality.java +++ /dev/null @@ -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(); - } - } -} diff --git a/src/main/java/me/bobert/netneutrality/bootstrap/ApplicationContext.java b/src/main/java/me/bobert/netneutrality/bootstrap/ApplicationContext.java new file mode 100644 index 0000000..c73c97a --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/bootstrap/ApplicationContext.java @@ -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. + * + *

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 components = new ArrayList<>(); + private final Map, 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 the component type + * @return the same component, for fluent wiring + */ + public 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 the requested type + * @return the matching instance, or {@code null} if none was registered + */ + public T get(Class 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 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); + } + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/core/BotState.java b/src/main/java/me/bobert/netneutrality/bootstrap/BotState.java similarity index 66% rename from src/main/java/me/bobert/netneutrality/core/BotState.java rename to src/main/java/me/bobert/netneutrality/bootstrap/BotState.java index fbd0793..873d59d 100644 --- a/src/main/java/me/bobert/netneutrality/core/BotState.java +++ b/src/main/java/me/bobert/netneutrality/bootstrap/BotState.java @@ -1,11 +1,13 @@ -package me.bobert.netneutrality.core; +package me.bobert.netneutrality.bootstrap; /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildService; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.TrackScheduler; import org.json.JSONArray; @@ -19,14 +21,32 @@ import java.util.Map; public class BotState { public static boolean SAFE = false; + private static volatile GuildServiceProvider guildServices; + private static volatile MusicService musicService; + + /** + * Binds providers used by the optional state dump. Called once during bootstrap. + * + * @param guildServices the guild service provider + * @param musicService the music service + */ + public static void bind(GuildServiceProvider guildServices, MusicService musicService) { + BotState.guildServices = guildServices; + BotState.musicService = musicService; + } + //TODO: is it needed? private static void saveGuildsState() { - Map services = GuildService.getServices(); + if (guildServices == null || musicService == null) { + return; + } + + Map services = guildServices.getServices(); JSONObject main = new JSONObject(); JSONArray guildsArray = new JSONArray(); - for (MusicManager manager : MusicManager.getManagers()) { + for (MusicManager manager : musicService.getManagers()) { TrackScheduler trackScheduler = manager.getScheduler(); } diff --git a/src/main/java/me/bobert/netneutrality/bootstrap/Lifecycle.java b/src/main/java/me/bobert/netneutrality/bootstrap/Lifecycle.java new file mode 100644 index 0000000..13f3f8c --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/bootstrap/Lifecycle.java @@ -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}. + * + *

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. + * + *

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(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/bootstrap/ReflectionHelper.java b/src/main/java/me/bobert/netneutrality/bootstrap/ReflectionHelper.java new file mode 100644 index 0000000..027c312 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/bootstrap/ReflectionHelper.java @@ -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, Exception> DEFAULT_FAILURE = (aClass, e) -> logger.error("Failed to load class: {}", aClass.getName(), e); + + public static void loadClassesFromPackage(String packageName, Class clazz, Consumer onInstanciated, BiConsumer, 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 the base type + */ + public static void loadClassesFromPackage(String packageName, Class clazz, ApplicationContext context, Consumer onInstanciated, BiConsumer, Exception> onFailed) { + Set> classes = new Reflections(packageName).getSubTypesOf(clazz); + for (Class 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. + * + *

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 instantiate(Class 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 void loadCommands(Class clazz, Consumer onInstanciated, BiConsumer, Exception> onFailed) { + loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed); + } + + public static void loadCommands(Class clazz, Consumer onInstanciated) { + loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE); + } + + public static void loadCommands(Class clazz, ApplicationContext context, Consumer onInstanciated) { + loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE); + } + + public static void loadTasks(Class clazz, Consumer onInstanciated, BiConsumer, Exception> onFailed) { + loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed); + } + + public static void loadTasks(Class clazz, Consumer onInstanciated) { + loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE); + } + + public static void loadTasks(Class clazz, ApplicationContext context, Consumer onInstanciated) { + loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE); + } +} diff --git a/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java b/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java index 39ed23b..af232bb 100644 --- a/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java @@ -12,14 +12,23 @@ import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.db.entities.OBlacklist; import me.bobert.netneutrality.db.service.BlacklistService; -import me.bobert.netneutrality.db.service.GuildService; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import java.util.List; +import java.util.Objects; +import me.bobert.netneutrality.db.service.GuildServiceProvider; public class BlacklistCommand extends AdminCommand { + private final GuildServiceProvider guildServices; + + public BlacklistCommand(GuildServiceProvider guildServices) { + Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null"); + this.guildServices = guildServices; + } + + @Override public boolean isEnabled() { return true; @@ -55,7 +64,7 @@ public class BlacklistCommand extends AdminCommand { public CommandExecutionResult execute(CommandContext context) { String sub = context.getSubcommand(); String word = context.getOption("word", "", CommandOption::getAsString); - BlacklistService service = GuildService.get(context.getGuild()).getBlacklistService(); + BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService(); switch (sub) { case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> { diff --git a/src/main/java/me/bobert/netneutrality/command/admin/PurgeCommand.java b/src/main/java/me/bobert/netneutrality/command/admin/PurgeCommand.java new file mode 100644 index 0000000..44b6c48 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/command/admin/PurgeCommand.java @@ -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(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/command/admin/ReactionCommand.java b/src/main/java/me/bobert/netneutrality/command/admin/ReactionCommand.java index 3f36c70..9695bdb 100644 --- a/src/main/java/me/bobert/netneutrality/command/admin/ReactionCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/admin/ReactionCommand.java @@ -10,8 +10,7 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.AdminCommand; import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.core.tasks.TaskManager; -import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask; +import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.interactions.commands.OptionType; @@ -23,9 +22,18 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +import me.bobert.netneutrality.core.tasks.TaskManager; public class ReactionCommand extends AdminCommand { + private final TaskManager taskManager; + + public ReactionCommand(TaskManager taskManager) { + Objects.requireNonNull(taskManager, "TaskManager must not be null"); + this.taskManager = taskManager; + } + + @Override public boolean isEnabled() { return true; @@ -93,11 +101,11 @@ public class ReactionCommand extends AdminCommand { private CommandExecutionResult executeEmoji(CommandContext context, String guildId, String messageId, String sub) { String id = String.format("%s:%s", guildId, messageId); - EmojiReactionRoleTask task = (EmojiReactionRoleTask) TaskManager.getInstance().getTask(id); + EmojiReactionRoleTask task = (EmojiReactionRoleTask) taskManager.getTask(id); switch (sub) { case "list" -> { - List tasks = TaskManager.getInstance().findTasksByPartialId(EmojiReactionRoleTask.class, guildId); + List tasks = taskManager.findTasksByPartialId(EmojiReactionRoleTask.class, guildId); if (tasks.isEmpty()) { return CommandExecutionResult.info("No tasks found for this guild"); } diff --git a/src/main/java/me/bobert/netneutrality/command/basic/HelpCommand.java b/src/main/java/me/bobert/netneutrality/command/basic/HelpCommand.java index 56a4a60..c286403 100644 --- a/src/main/java/me/bobert/netneutrality/command/basic/HelpCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/basic/HelpCommand.java @@ -8,16 +8,17 @@ package me.bobert.netneutrality.command.basic; import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandContainer; import me.bobert.netneutrality.core.command.CommandExecutionResult; -import me.bobert.netneutrality.core.command.CommandManager; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.AbstractCommand; import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.core.tasks.Task; +import me.bobert.netneutrality.core.tasks.trait.WithButton; +import me.bobert.netneutrality.core.command.CommandRegistry; +import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.tasks.help.BotInfo; import me.bobert.netneutrality.tasks.help.CommandHelp; import me.bobert.netneutrality.tasks.help.ReportBug; -import me.bobert.netneutrality.utils.MessageFunction; +import me.bobert.netneutrality.shared.util.MessageFunction; import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.interactions.commands.OptionType; @@ -26,6 +27,18 @@ import java.util.List; public class HelpCommand extends AbstractCommand { + private final CommandRegistry commands; + private final TaskManager taskManager; + + public HelpCommand() { + this(null, null); + } + + public HelpCommand(CommandRegistry commands, TaskManager taskManager) { + this.commands = commands; + this.taskManager = taskManager; + } + @Override public boolean needAdmin() { return false; @@ -65,15 +78,15 @@ public class HelpCommand extends AbstractCommand { public CommandExecutionResult execute(CommandContext context) { String command = context.getOption("command", null, CommandOption::getAsString); if (command == null) { - Button commands = Task.createButton(CommandHelp.class); - Button info = Task.createButton(BotInfo.class); - Button reportbug = Task.createButton(ReportBug.class); - ActionRow row = ActionRow.of(commands, info, reportbug); + Button commandsBtn = createTaskButton(CommandHelp.class); + Button info = createTaskButton(BotInfo.class); + Button reportbug = createTaskButton(ReportBug.class); + ActionRow row = ActionRow.of(commandsBtn, info, reportbug); context.replyEphemeral("Thanks for using the Net Neutrality Bot! What do you need help with?", List.of(row)); return CommandExecutionResult.none(); } - CommandContainer container = CommandManager.getCommandContainer(command); + CommandContainer container = commands.getCommandContainer(command); if (container.getCommand() == null) { return CommandExecutionResult.warn("Command does not exist"); } @@ -81,4 +94,12 @@ public class HelpCommand extends AbstractCommand { context.replyEphemeral(MessageFunction.buildCommandHelpMessage(container)); return CommandExecutionResult.none(); } + + private Button createTaskButton(Class 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(); + } } diff --git a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java index 10f3b24..32219c5 100644 --- a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.command.bot; +package me.bobert.netneutrality.command.basic; /* * 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.db.GSettings; import me.bobert.netneutrality.db.service.GuildService; -import me.bobert.netneutrality.music.MusicManager; import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; @@ -22,9 +21,24 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import java.util.Map; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; public class ConfigureCommand extends AdminCommand { + private final GuildServiceProvider guildServices; + private final MusicService musicService; + + public ConfigureCommand() { + this(null, null); + } + + public ConfigureCommand(GuildServiceProvider guildServices, MusicService musicService) { + this.guildServices = guildServices; + this.musicService = musicService; + } + + @Override public boolean isEnabled() { return true; @@ -63,7 +77,7 @@ public class ConfigureCommand extends AdminCommand { @Override public CommandExecutionResult execute(CommandContext context) { String sub = context.getSubcommand(); - GuildService guildService = GuildService.get(context.getGuild().getId()); + GuildService guildService = guildServices.get(context.getGuild().getId()); context.ephemeral(); if ("music-role".equals(sub)) { @@ -105,7 +119,7 @@ public class ConfigureCommand extends AdminCommand { if ("music-volume".equals(sub)) { int volume = context.getOption("volume", 30, CommandOption::getAsInt); guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume)); - MusicManager.get(context.getGuild()).setVolume(volume); + musicService.get(context.getGuild()).setVolume(volume); return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume)); } diff --git a/src/main/java/me/bobert/netneutrality/command/custom/.gitignore b/src/main/java/me/bobert/netneutrality/command/custom/.gitignore index 2a9bbbd..1d504a9 100644 --- a/src/main/java/me/bobert/netneutrality/command/custom/.gitignore +++ b/src/main/java/me/bobert/netneutrality/command/custom/.gitignore @@ -1 +1 @@ -*.java \ No newline at end of file +**/*.java \ No newline at end of file diff --git a/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java b/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java index 9492d98..8f9b426 100644 --- a/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java @@ -12,11 +12,24 @@ import me.bobert.netneutrality.core.command.impl.AbstractCommand; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.tasks.git.ReportIssueTask; -import me.bobert.netneutrality.utils.Settings; +import java.util.Objects; import java.util.concurrent.TimeUnit; +import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.utils.SettingsProvider; public class ReportIssueCommand extends AbstractCommand { + + private final SettingsProvider settings; + private final TaskManager taskManager; + + public ReportIssueCommand(SettingsProvider settings, TaskManager taskManager) { + Objects.requireNonNull(settings, "SettingsProvider must not be null"); + Objects.requireNonNull(taskManager, "TaskManager must not be null"); + this.settings = settings; + this.taskManager = taskManager; + } + @Override public boolean needAdmin() { return false; @@ -59,16 +72,16 @@ public class ReportIssueCommand extends AbstractCommand { @Override public CommandExecutionResult execute(CommandContext context) { - String gitlabHost = Settings.getInstance().get("GITLAB_HOST"); - String gitlabToken = Settings.getInstance().get("GITLAB_TOKEN"); - String projectId = Settings.getInstance().get("GITLAB_PROJECT_ID"); + String gitlabHost = settings.get("GITLAB_HOST"); + String gitlabToken = settings.get("GITLAB_TOKEN"); + String projectId = settings.get("GITLAB_PROJECT_ID"); if (gitlabHost == null || gitlabToken == null || projectId == null) { return CommandExecutionResult.error("Command is not configured, failed to create issue."); } if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) { - return CommandExecutionResult.task(new ReportIssueTask(context)); + return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings)); } return CommandExecutionResult.error("Unable to create bug reports at this time, please try again later."); diff --git a/src/main/java/me/bobert/netneutrality/command/music/BitconnectCommand.java b/src/main/java/me/bobert/netneutrality/command/music/BitconnectCommand.java index 82e38da..e98153a 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/BitconnectCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/BitconnectCommand.java @@ -10,11 +10,28 @@ import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; public class BitconnectCommand extends MusicCommand { + private final MusicService musicService; + + public BitconnectCommand() { + this(null, null); + } + + public BitconnectCommand(MusicService musicService) { + this(musicService, null); + } + + public BitconnectCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -42,7 +59,7 @@ public class BitconnectCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); context.ephemeral(); context.deleteMessage(); diff --git a/src/main/java/me/bobert/netneutrality/command/music/ClearQueueCommand.java b/src/main/java/me/bobert/netneutrality/command/music/ClearQueueCommand.java index 1bf96b1..444e6bd 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/ClearQueueCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/ClearQueueCommand.java @@ -9,12 +9,29 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; public class ClearQueueCommand extends MusicCommand { + private final MusicService musicService; + + public ClearQueueCommand() { + this(null, null); + } + + public ClearQueueCommand(MusicService musicService) { + this(musicService, null); + } + + public ClearQueueCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean needAdmin() { return true; @@ -42,10 +59,10 @@ public class ClearQueueCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); context.ack(); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { return manager.getQueueManager().clearQueue(); } diff --git a/src/main/java/me/bobert/netneutrality/command/music/CurrentlyPlayingCommand.java b/src/main/java/me/bobert/netneutrality/command/music/CurrentlyPlayingCommand.java index c60b45f..b080f73 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/CurrentlyPlayingCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/CurrentlyPlayingCommand.java @@ -9,13 +9,30 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; -import me.bobert.netneutrality.utils.MessageFunction; +import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MusicUtils; public class CurrentlyPlayingCommand extends MusicCommand { + private final MusicService musicService; + + public CurrentlyPlayingCommand() { + this(null, null); + } + + public CurrentlyPlayingCommand(MusicService musicService) { + this(musicService, null); + } + + public CurrentlyPlayingCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -38,10 +55,10 @@ public class CurrentlyPlayingCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); context.ack(); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { if (!manager.isCurrentlyPlaying()) { return CommandExecutionResult.info("Bot isn't playing any tracks right now"); } diff --git a/src/main/java/me/bobert/netneutrality/command/music/JoinCommand.java b/src/main/java/me/bobert/netneutrality/command/music/JoinCommand.java index 36a6efd..c372fe3 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/JoinCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/JoinCommand.java @@ -9,11 +9,29 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; +import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.utils.MusicUtils; public class JoinCommand extends MusicCommand { + private final MusicService musicService; + + public JoinCommand() { + this(null, null); + } + + public JoinCommand(MusicService musicService) { + this(musicService, null); + } + + public JoinCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -36,7 +54,9 @@ public class JoinCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - if (MusicUtils.connect(context)) { + MusicManager manager = musicService.get(context.getGuild()); + + if (MusicUtils.connect(context, manager)) { context.reply("Joining current voice channel"); } diff --git a/src/main/java/me/bobert/netneutrality/command/music/LeaveCommand.java b/src/main/java/me/bobert/netneutrality/command/music/LeaveCommand.java index 567d3fc..e0944b2 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/LeaveCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/LeaveCommand.java @@ -10,11 +10,35 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; +/** + * Leaves the voice channel for the current guild. + * + *

Representative command migrated to constructor injection of {@link MusicService} (and optional + * {@link GuildServiceProvider} for the {@link MusicCommand} base). The no-arg constructor remains as + * a fallback for non-DI loaders. + */ public class LeaveCommand extends MusicCommand { + private final MusicService musicService; + + public LeaveCommand() { + this(null, null); + } + + public LeaveCommand(MusicService musicService) { + this(musicService, null); + } + + public LeaveCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -37,9 +61,9 @@ public class LeaveCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { context.reply("Leaving voice channel and clearing queue"); manager.disconnect(); } diff --git a/src/main/java/me/bobert/netneutrality/command/music/PlayCommand.java b/src/main/java/me/bobert/netneutrality/command/music/PlayCommand.java index 8b02d7f..b7d8f59 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/PlayCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/PlayCommand.java @@ -9,6 +9,8 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; @@ -16,6 +18,21 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; public class PlayCommand extends MusicCommand { + private final MusicService musicService; + + public PlayCommand() { + this(null, null); + } + + public PlayCommand(MusicService musicService) { + this(musicService, null); + } + + public PlayCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -38,7 +55,7 @@ public class PlayCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); String query = context.getOption("query", CommandOption::getAsString); context.ack(); diff --git a/src/main/java/me/bobert/netneutrality/command/music/QueueCommand.java b/src/main/java/me/bobert/netneutrality/command/music/QueueCommand.java index 17698a7..f291ce5 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/QueueCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/QueueCommand.java @@ -8,10 +8,12 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; -import me.bobert.netneutrality.utils.MessageFunction; +import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MusicUtils; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.interactions.commands.OptionType; @@ -20,6 +22,21 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; public class QueueCommand extends MusicCommand { + private final MusicService musicService; + + public QueueCommand() { + this(null, null); + } + + public QueueCommand(MusicService musicService) { + this(musicService, null); + } + + public QueueCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -58,11 +75,11 @@ public class QueueCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); String sub = context.getSubcommand(); context.ack(); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { switch (sub) { case "clear-all" -> { diff --git a/src/main/java/me/bobert/netneutrality/command/music/ResumeCommand.java b/src/main/java/me/bobert/netneutrality/command/music/ResumeCommand.java index e0d699b..bf9f9b0 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/ResumeCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/ResumeCommand.java @@ -4,14 +4,37 @@ package me.bobert.netneutrality.command.music;/* * https://discord.gg/qUa6zNFZsd */ +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; public class ResumeCommand extends MusicCommand { + + private final MusicService musicService; + + public ResumeCommand() { + this(null, null); + } + + public ResumeCommand(MusicService musicService) { + this(musicService, null); + } + + public ResumeCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } @Override public boolean isEnabled() { return true; @@ -34,9 +57,9 @@ public class ResumeCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { return manager.resume(); } diff --git a/src/main/java/me/bobert/netneutrality/command/music/ShuffleCommand.java b/src/main/java/me/bobert/netneutrality/command/music/ShuffleCommand.java index caeb025..6fa082a 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/ShuffleCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/ShuffleCommand.java @@ -9,11 +9,28 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; public class ShuffleCommand extends MusicCommand { + + private final MusicService musicService; + + public ShuffleCommand() { + this(null, null); + } + + public ShuffleCommand(MusicService musicService) { + this(musicService, null); + } + + public ShuffleCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } @Override public boolean isEnabled() { return true; @@ -36,10 +53,10 @@ public class ShuffleCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); context.ack(); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { return manager.getQueueManager().shuffle(); } diff --git a/src/main/java/me/bobert/netneutrality/command/music/SkipCommand.java b/src/main/java/me/bobert/netneutrality/command/music/SkipCommand.java index b96c3a4..369cb18 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/SkipCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/SkipCommand.java @@ -9,16 +9,33 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; -import me.bobert.netneutrality.utils.UserFunction; +import me.bobert.netneutrality.shared.util.UserFunction; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; public class SkipCommand extends MusicCommand { + private final MusicService musicService; + + public SkipCommand() { + this(null, null); + } + + public SkipCommand(MusicService musicService) { + this(musicService, null); + } + + public SkipCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -42,12 +59,12 @@ public class SkipCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); context.ack(); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { int skips = context.getOption("skip", 1, CommandOption::getAsInt); - if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) { + if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) { return manager.getQueueManager().skip(skips); } else { return manager.voteSkip(context); diff --git a/src/main/java/me/bobert/netneutrality/command/music/StopCommand.java b/src/main/java/me/bobert/netneutrality/command/music/StopCommand.java index 9c71355..d28b1b6 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/StopCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/StopCommand.java @@ -9,13 +9,30 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; -import me.bobert.netneutrality.utils.UserFunction; +import me.bobert.netneutrality.shared.util.UserFunction; public class StopCommand extends MusicCommand { + private final MusicService musicService; + + public StopCommand() { + this(null, null); + } + + public StopCommand(MusicService musicService) { + this(musicService, null); + } + + public StopCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -38,10 +55,10 @@ public class StopCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); context.ack(); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { if (UserFunction.isAdmin(context)) { manager.stopTrack(); context.reply("Stopping current track"); diff --git a/src/main/java/me/bobert/netneutrality/command/music/ViewQueueCommand.java b/src/main/java/me/bobert/netneutrality/command/music/ViewQueueCommand.java index 48141d4..eb70809 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/ViewQueueCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/ViewQueueCommand.java @@ -8,13 +8,30 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; -import me.bobert.netneutrality.utils.MessageFunction; +import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MusicUtils; public class ViewQueueCommand extends MusicCommand { + private final MusicService musicService; + + public ViewQueueCommand() { + this(null, null); + } + + public ViewQueueCommand(MusicService musicService) { + this(musicService, null); + } + + public ViewQueueCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -37,10 +54,10 @@ public class ViewQueueCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); context.ack(); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { int queueSize = manager.getQueueSize(); if (queueSize == 0) { return CommandExecutionResult.warn("Queue is empty!"); diff --git a/src/main/java/me/bobert/netneutrality/command/music/VolumeCommand.java b/src/main/java/me/bobert/netneutrality/command/music/VolumeCommand.java index cf21b6e..378c334 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/VolumeCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/VolumeCommand.java @@ -9,16 +9,33 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; -import me.bobert.netneutrality.utils.UserFunction; +import me.bobert.netneutrality.shared.util.UserFunction; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; public class VolumeCommand extends MusicCommand { + private final MusicService musicService; + + public VolumeCommand() { + this(null, null); + } + + public VolumeCommand(MusicService musicService) { + this(musicService, null); + } + + public VolumeCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -42,11 +59,11 @@ public class VolumeCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); int volume = context.getOption("volume", 30, CommandOption::getAsInt); - if (MusicUtils.checkIfConnected(context)) { - if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { + if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) { manager.setVolume(volume); return CommandExecutionResult.success(String.format("Volume set to %s", volume)); } diff --git a/src/main/java/me/bobert/netneutrality/command/music/VoteSkipCommand.java b/src/main/java/me/bobert/netneutrality/command/music/VoteSkipCommand.java index 65f7488..b90340e 100644 --- a/src/main/java/me/bobert/netneutrality/command/music/VoteSkipCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/music/VoteSkipCommand.java @@ -8,12 +8,29 @@ package me.bobert.netneutrality.command.music; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.utils.MusicUtils; public class VoteSkipCommand extends MusicCommand { + private final MusicService musicService; + + public VoteSkipCommand() { + this(null, null); + } + + public VoteSkipCommand(MusicService musicService) { + this(musicService, null); + } + + public VoteSkipCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + @Override public boolean isEnabled() { return true; @@ -36,9 +53,9 @@ public class VoteSkipCommand extends MusicCommand { @Override public CommandExecutionResult execute(CommandContext context) { - MusicManager manager = MusicManager.get(context.getGuild()); + MusicManager manager = musicService.get(context.getGuild()); - if (MusicUtils.checkIfConnected(context)) { + if (MusicUtils.checkIfConnected(context, manager)) { return manager.voteSkip(context); } diff --git a/src/main/java/me/bobert/netneutrality/command/react/CreateEmojiReaction.java b/src/main/java/me/bobert/netneutrality/command/react/CreateEmojiReaction.java index ccced89..131255f 100644 --- a/src/main/java/me/bobert/netneutrality/command/react/CreateEmojiReaction.java +++ b/src/main/java/me/bobert/netneutrality/command/react/CreateEmojiReaction.java @@ -9,22 +9,32 @@ package me.bobert.netneutrality.command.react; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.impl.MessageMenuCommand; import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.core.tasks.TaskManager; -import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask; +import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask; import me.bobert.netneutrality.tasks.reaction.EmojiRoleFirstStep; import net.dv8tion.jda.api.entities.Message; +import me.bobert.netneutrality.core.tasks.TaskManager; + +import java.util.Objects; public class CreateEmojiReaction extends MessageMenuCommand { + + private final TaskManager taskManager; + + public CreateEmojiReaction(TaskManager taskManager) { + Objects.requireNonNull(taskManager, "TaskManager must not be null"); + this.taskManager = taskManager; + } + @Override public CommandExecutionResult execute(Message target, CommandContext context) { - EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); + EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); if (task != null) { context.replyEphemeral("This message is already registered with the bot, please use the modify command instead"); return CommandExecutionResult.none(); } - return CommandExecutionResult.task(new EmojiRoleFirstStep(target)); + return CommandExecutionResult.task(new EmojiRoleFirstStep(taskManager, target)); } @Override diff --git a/src/main/java/me/bobert/netneutrality/command/react/ModifyEmojiReaction.java b/src/main/java/me/bobert/netneutrality/command/react/ModifyEmojiReaction.java index 0f66051..6d381b1 100644 --- a/src/main/java/me/bobert/netneutrality/command/react/ModifyEmojiReaction.java +++ b/src/main/java/me/bobert/netneutrality/command/react/ModifyEmojiReaction.java @@ -9,22 +9,32 @@ package me.bobert.netneutrality.command.react; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.impl.MessageMenuCommand; import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.core.tasks.TaskManager; -import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask; +import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask; import me.bobert.netneutrality.tasks.reaction.EmojiRoleModify; import net.dv8tion.jda.api.entities.Message; +import me.bobert.netneutrality.core.tasks.TaskManager; + +import java.util.Objects; public class ModifyEmojiReaction extends MessageMenuCommand { + private final TaskManager taskManager; + + public ModifyEmojiReaction(TaskManager taskManager) { + Objects.requireNonNull(taskManager, "TaskManager must not be null"); + this.taskManager = taskManager; + } + + @Override public CommandExecutionResult execute(Message target, CommandContext context) { - EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); + EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null); if (task == null) { context.replyEphemeral("This message is not registered with the bot, please use the create command instead"); return CommandExecutionResult.none(); } - return CommandExecutionResult.task(new EmojiRoleModify(target, task)); + return CommandExecutionResult.task(new EmojiRoleModify(taskManager, target, task)); } @Override diff --git a/src/main/java/me/bobert/netneutrality/context/MusicChannelContext.java b/src/main/java/me/bobert/netneutrality/context/MusicChannelContext.java index c5236f8..d64e66e 100644 --- a/src/main/java/me/bobert/netneutrality/context/MusicChannelContext.java +++ b/src/main/java/me/bobert/netneutrality/context/MusicChannelContext.java @@ -6,6 +6,7 @@ package me.bobert.netneutrality.context; */ import me.bobert.netneutrality.core.context.AbstractContext; +import me.bobert.netneutrality.core.context.AsyncContext; import me.bobert.netneutrality.core.context.NoMessageContext; import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.entities.Member; diff --git a/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java b/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java index 5c247aa..80a4081 100644 --- a/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java +++ b/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java @@ -9,7 +9,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.MessageCommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.MessageContext; -import me.bobert.netneutrality.utils.Settings; +import me.bobert.netneutrality.utils.SettingsProvider; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Mentions; import net.dv8tion.jda.api.entities.Message; @@ -30,8 +30,11 @@ public class MessageCommandContext extends CommandContext implements MessageCont private final MessageReceivedEvent event; private final Message message; - public MessageCommandContext(MessageReceivedEvent event) { + private final SettingsProvider settings; + + public MessageCommandContext(MessageReceivedEvent event, SettingsProvider settings) { super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor()); + this.settings = settings; this.event = event; this.message = event.getMessage(); } @@ -320,7 +323,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont @Override public String parseCommandName() { String rawMessage = message.getContentRaw().trim(); - String prefix = Settings.getInstance().getPrefix(); + String prefix = settings.getPrefix(); if (rawMessage.startsWith(prefix)) { return getFullCommand().split(" ", 2)[0]; @@ -332,7 +335,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont @Override public String getFullCommand() { String rawMessage = message.getContentRaw().trim(); - String prefix = Settings.getInstance().getPrefix(); + String prefix = settings.getPrefix(); return rawMessage.replaceFirst("^[" + prefix + "\\s]+", "").trim(); } diff --git a/src/main/java/me/bobert/netneutrality/context/tasks/EmojiTaskContext.java b/src/main/java/me/bobert/netneutrality/context/tasks/EmojiTaskContext.java index 5bea3b7..c249982 100644 --- a/src/main/java/me/bobert/netneutrality/context/tasks/EmojiTaskContext.java +++ b/src/main/java/me/bobert/netneutrality/context/tasks/EmojiTaskContext.java @@ -5,7 +5,7 @@ package me.bobert.netneutrality.context.tasks; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.context.AsyncContext; +import me.bobert.netneutrality.core.context.AsyncContext; import me.bobert.netneutrality.core.context.NoMessageContext; import me.bobert.netneutrality.core.context.TaskContext; import net.dv8tion.jda.api.components.MessageTopLevelComponent; diff --git a/src/main/java/me/bobert/netneutrality/core/LogHandler.java b/src/main/java/me/bobert/netneutrality/core/LogHandler.java index 2efc046..4aefae3 100644 --- a/src/main/java/me/bobert/netneutrality/core/LogHandler.java +++ b/src/main/java/me/bobert/netneutrality/core/LogHandler.java @@ -9,9 +9,9 @@ import io.sentry.Sentry; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.TaskContext; -import me.bobert.netneutrality.db.service.GuildService; +import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.exceptions.ContextException; -import me.bobert.netneutrality.utils.MessageFunction; +import me.bobert.netneutrality.shared.util.MessageFunction; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import org.apache.logging.log4j.LogManager; @@ -20,6 +20,17 @@ import org.apache.logging.log4j.Logger; public class LogHandler { private static final Logger logger = LogManager.getLogger(LogHandler.class); + private static volatile GuildServiceProvider guildServices; + + /** + * Binds the guild-service provider used for debug-channel logging. Called once during bootstrap. + * + * @param provider the live guild service provider + */ + public static void bind(GuildServiceProvider provider) { + guildServices = provider; + } + /** * Log an exception accordingly @@ -80,7 +91,8 @@ public class LogHandler { Context context = exception.getContext(); if (context.getGuild() != null) { - if (GuildService.get(context.getGuild()).isDebugEnabled()) { + GuildServiceProvider provider = guildServices; + if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) { context.getGuild() .getTextChannels() .stream() diff --git a/src/main/java/me/bobert/netneutrality/core/NetNeutralityBot.java b/src/main/java/me/bobert/netneutrality/core/NetNeutralityBot.java new file mode 100644 index 0000000..9babc94 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/NetNeutralityBot.java @@ -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; + } +} diff --git a/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java b/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java index a33f22c..201e07d 100644 --- a/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java +++ b/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java @@ -5,7 +5,8 @@ package me.bobert.netneutrality.core.command; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.utils.ReflectionHelper; +import me.bobert.netneutrality.bootstrap.ApplicationContext; +import me.bobert.netneutrality.bootstrap.ReflectionHelper; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.commands.build.CommandData; @@ -17,15 +18,43 @@ import org.apache.logging.log4j.Logger; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; -public class CommandManager { +/** + * Instance-based {@link CommandRegistry} owning the discovered command map. + */ +public class CommandManager implements CommandRegistry { private static final Logger logger = LogManager.getLogger(CommandManager.class); - public static final Map commands = new ConcurrentHashMap<>(); - public static void init() { + private final Map commands = new ConcurrentHashMap<>(); + private final ApplicationContext context; + + /** + * Creates a command manager that can inject context-registered dependencies into reflectively + * loaded commands. + * + * @param context the application context; must not be {@code null} + */ + public CommandManager(ApplicationContext context) { + Objects.requireNonNull(context, "ApplicationContext must not be null"); + this.context = context; + } + + @Override + public int priority() { + return 30; + } + + @Override + public void start() { + init(); + } + + public void init() { logger.info("Loading commands.."); initCommands(); @@ -33,12 +62,30 @@ public class CommandManager { logger.info("Loaded {} commands!", commands.size()); } - public static CommandContainer getCommandContainer(String commandString) { - return commands.getOrDefault(commandString, CommandContainer.NULL_COMMAND); + @Override + public CommandContainer getCommandContainer(String name) { + return commands.getOrDefault(name, CommandContainer.NULL_COMMAND); } - public static void initCommands() { - ReflectionHelper.loadCommands(Command.class, command -> { + @Override + public boolean hasCommand(String name) { + return commands.containsKey(name); + } + + /** + * Exposes the live command map for call sites (and tests) that still iterate or mutate it + * directly. Prefer {@link #hasCommand(String)} / {@link #getCommandContainer(String)} where + * possible. + * + * @return the registered commands keyed by name + */ + @Override + public Map getCommands() { + return commands; + } + + public void initCommands() { + Consumer onLoaded = command -> { if (!command.isEnabled()) { logger.info("Skipping command {} as it is disabled", command.getClass().getName()); return; @@ -58,10 +105,13 @@ public class CommandManager { if (!commands.containsKey(command.getCommandName())) { commands.put(command.getCommandName(), container); } - }); + }; + + ReflectionHelper.loadCommands(Command.class, context, onLoaded); } - public static void registerSlashCommands(JDA jda) { + @Override + public void registerSlashCommands(JDA jda) { CommandListUpdateAction commandListUpdateAction = jda.updateCommands(); List slashCommands = new ArrayList<>(); diff --git a/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java b/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java index a44d05b..ee96122 100644 --- a/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java +++ b/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java @@ -10,10 +10,10 @@ import me.bobert.netneutrality.context.command.SlashCommandContext; 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.tasks.TaskManager; import me.bobert.netneutrality.exceptions.CommandExecutionException; import me.bobert.netneutrality.exceptions.GracefulContextError; -import me.bobert.netneutrality.utils.UserFunction; +import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.shared.util.UserFunction; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Objects; import static java.util.function.Predicate.not; import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.ERROR; @@ -35,17 +36,21 @@ public class CommandProcessor { private static final Logger logger = LogManager.getLogger(CommandProcessor.class); 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.commands = Objects.requireNonNull(commands, "commands"); + this.taskManager = Objects.requireNonNull(taskManager, "taskManager"); } public void dispatch() { - if (!CommandManager.commands.containsKey(context.parseCommandName())) { + if (!commands.hasCommand(context.parseCommandName())) { return; } - CommandContainer container = CommandManager.getCommandContainer(context.parseCommandName()); + CommandContainer container = commands.getCommandContainer(context.parseCommandName()); Command command = container.getCommand(); if (command == null) { throw new IllegalArgumentException("Context has no command"); @@ -98,7 +103,7 @@ public class CommandProcessor { } if (result == TASK) { - TaskManager.getInstance().insert(context, commandExecutionResult.getTask()); + taskManager.insert(context, commandExecutionResult.getTask()); } String message = commandExecutionResult.getMessage(); diff --git a/src/main/java/me/bobert/netneutrality/core/command/CommandRegistry.java b/src/main/java/me/bobert/netneutrality/core/command/CommandRegistry.java new file mode 100644 index 0000000..7e7b2ab --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/command/CommandRegistry.java @@ -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. + * + *

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 getCommands(); +} + diff --git a/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java b/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java index 953e678..426eb0d 100644 --- a/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java +++ b/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java @@ -6,9 +6,10 @@ package me.bobert.netneutrality.core.command.impl; */ import me.bobert.netneutrality.core.command.CommandCategory; +import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.service.GuildService; -import me.bobert.netneutrality.utils.UserFunction; +import me.bobert.netneutrality.shared.util.UserFunction; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Role; @@ -16,8 +17,45 @@ import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import java.util.Objects; + public abstract class MusicCommand extends AbstractCommand { + /** + * Injected guild-service provider when constructed via DI. + */ + private final GuildServiceProvider guildServices; + + /** + * Preferred constructor for DI. + * + * @param guildServices the shared guild service provider, must not be {@code null} + */ + public MusicCommand(GuildServiceProvider guildServices) { + Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null"); + this.guildServices = guildServices; + } + + /** + * Resolves the {@link GuildService} for a guild via the injected provider. + * + * @param guild the guild + * @return the guild service + */ + protected final GuildService guildService(Guild guild) { + return guildServices.get(guild); + } + + /** + * Resolves the {@link GuildService} for a guild id via the injected provider. + * + * @param guildId the guild id + * @return the guild service + */ + protected final GuildService guildService(String guildId) { + return guildServices.get(guildId); + } + @Override public boolean needAdmin() { return false; @@ -41,7 +79,7 @@ public abstract class MusicCommand extends AbstractCommand { Guild guild = member.getGuild(); - String roleDb = GuildService.get(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE); + String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE); if (roleDb != null && !roleDb.isEmpty()) { if (UserFunction.isAdmin(member)) { @@ -51,7 +89,7 @@ public abstract class MusicCommand extends AbstractCommand { Role neededRole = guild.getRoleById(roleDb); if (neededRole == null) { // Role doesn't exist on guild anymore, remove from db - GuildService.get(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null); + guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null); return true; } @@ -68,7 +106,7 @@ public abstract class MusicCommand extends AbstractCommand { return false; } - String channelDb = GuildService.get(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL); + String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL); if (channelDb != null && !channelDb.isEmpty()) { return channelDb.equals(channel.getId()); } diff --git a/src/main/java/me/bobert/netneutrality/context/AsyncContext.java b/src/main/java/me/bobert/netneutrality/core/context/AsyncContext.java similarity index 97% rename from src/main/java/me/bobert/netneutrality/context/AsyncContext.java rename to src/main/java/me/bobert/netneutrality/core/context/AsyncContext.java index b322acd..6accebd 100644 --- a/src/main/java/me/bobert/netneutrality/context/AsyncContext.java +++ b/src/main/java/me/bobert/netneutrality/core/context/AsyncContext.java @@ -1,11 +1,10 @@ -package me.bobert.netneutrality.context; +package me.bobert.netneutrality.core.context; /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.core.context.AbstractContext; import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.channel.ChannelType; diff --git a/src/main/java/me/bobert/netneutrality/core/context/Context.java b/src/main/java/me/bobert/netneutrality/core/context/Context.java index 82e6462..81af47d 100644 --- a/src/main/java/me/bobert/netneutrality/core/context/Context.java +++ b/src/main/java/me/bobert/netneutrality/core/context/Context.java @@ -5,8 +5,7 @@ package me.bobert.netneutrality.core.context; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.context.AsyncContext; -import me.bobert.netneutrality.utils.ContextUtils; +import me.bobert.netneutrality.shared.util.ContextUtils; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.entities.Guild; diff --git a/src/main/java/me/bobert/netneutrality/core/context/InteractionContext.java b/src/main/java/me/bobert/netneutrality/core/context/InteractionContext.java index f2db948..a40f5f8 100644 --- a/src/main/java/me/bobert/netneutrality/core/context/InteractionContext.java +++ b/src/main/java/me/bobert/netneutrality/core/context/InteractionContext.java @@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.context; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.context.AsyncContext; import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.interactions.Interaction; import net.dv8tion.jda.api.interactions.InteractionHook; diff --git a/src/main/java/me/bobert/netneutrality/core/context/MessageContext.java b/src/main/java/me/bobert/netneutrality/core/context/MessageContext.java index c0266d5..db0b153 100644 --- a/src/main/java/me/bobert/netneutrality/core/context/MessageContext.java +++ b/src/main/java/me/bobert/netneutrality/core/context/MessageContext.java @@ -6,7 +6,6 @@ package me.bobert.netneutrality.core.context; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.context.AsyncContext; import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.channel.ChannelType; diff --git a/src/main/java/me/bobert/netneutrality/core/context/NoMessageContext.java b/src/main/java/me/bobert/netneutrality/core/context/NoMessageContext.java index fe0b90f..a9658ed 100644 --- a/src/main/java/me/bobert/netneutrality/core/context/NoMessageContext.java +++ b/src/main/java/me/bobert/netneutrality/core/context/NoMessageContext.java @@ -6,7 +6,6 @@ package me.bobert.netneutrality.core.context; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.context.AsyncContext; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.modals.Modal; diff --git a/src/main/java/me/bobert/netneutrality/core/db/SessionManager.java b/src/main/java/me/bobert/netneutrality/core/db/SessionManager.java deleted file mode 100644 index 9e214b1..0000000 --- a/src/main/java/me/bobert/netneutrality/core/db/SessionManager.java +++ /dev/null @@ -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 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); - } - } -} diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/Task.java b/src/main/java/me/bobert/netneutrality/core/tasks/Task.java index 1707e71..b20c2fb 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/Task.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/Task.java @@ -79,14 +79,4 @@ public interface Task { Lifecycle execute(TaskContext context); void insert(CommandContext context); - - static Button createButton(Class 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(); - } } diff --git a/src/main/java/me/bobert/netneutrality/tasks/scheduled/TaskCleanup.java b/src/main/java/me/bobert/netneutrality/core/tasks/TaskCleanup.java similarity index 61% rename from src/main/java/me/bobert/netneutrality/tasks/scheduled/TaskCleanup.java rename to src/main/java/me/bobert/netneutrality/core/tasks/TaskCleanup.java index 00260e2..1bd962f 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/scheduled/TaskCleanup.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/TaskCleanup.java @@ -1,22 +1,31 @@ -package me.bobert.netneutrality.tasks.scheduled;/* +package me.bobert.netneutrality.core.tasks; +/* * Copyright (c) anthorob * * 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.Logger; import java.util.List; +import java.util.Objects; +/** + * Periodically removes expired in-memory tasks. + * + *

Scheduled task migrated to constructor injection of {@link TaskManager}. + */ public class TaskCleanup implements ScheduledTask { 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 public String getId() { return "TaskCleanup"; @@ -29,9 +38,7 @@ public class TaskCleanup implements ScheduledTask { @Override public void run() { - TaskManager manager = TaskManager.getInstance(); - - List expiredTasks = manager.getTasks() + List expiredTasks = taskManager.getTasks() .stream() .filter(task -> !(task instanceof PersistableTask)) .filter(task -> !(task instanceof BuiltInTask)) @@ -40,7 +47,7 @@ public class TaskCleanup implements ScheduledTask { if (!expiredTasks.isEmpty()) { logger.info("Cleaning up {} expired tasks..", expiredTasks.size()); - expiredTasks.forEach(manager::removeTask); + expiredTasks.forEach(taskManager::removeTask); } } } diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/TaskManager.java b/src/main/java/me/bobert/netneutrality/core/tasks/TaskManager.java index f3c9f3d..6a90076 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/TaskManager.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/TaskManager.java @@ -1,321 +1,120 @@ package me.bobert.netneutrality.core.tasks; + /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.core.LogHandler; -import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.core.context.InteractionContext; -import me.bobert.netneutrality.core.context.TaskContext; -import me.bobert.netneutrality.core.tasks.trait.Deletable; -import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable; -import me.bobert.netneutrality.core.tasks.trait.WithButton; -import me.bobert.netneutrality.core.tasks.trait.WithChildren; -import me.bobert.netneutrality.core.tasks.trait.WithParent; -import me.bobert.netneutrality.core.thread.ScheduledTaskManager; -import me.bobert.netneutrality.db.service.TaskService; -import me.bobert.netneutrality.exceptions.GracefulContextError; -import me.bobert.netneutrality.exceptions.TaskExecutionException; -import me.bobert.netneutrality.utils.ReflectionHelper; -import net.dv8tion.jda.api.interactions.InteractionHook; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - import java.util.Collection; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; +import me.bobert.netneutrality.bootstrap.Lifecycle; +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.core.context.TaskContext; +import me.bobert.netneutrality.core.tasks.trait.WithButton; +import me.bobert.netneutrality.db.service.TaskService; -public class TaskManager { - private static final Logger logger = LogManager.getLogger(TaskManager.class); - private static TaskManager instance; - private final TaskService taskService; - private final Map tasks; - private final List pendingTasks; - private final Map deletableTasksContext; - private final Map tasksLock; +/** + * Owns the in-memory task registry, button/interaction dispatch, and persistable-task loading. + * + *

As a {@link Lifecycle} component it loads built-in, scheduled and persisted tasks on + * {@link #start()}. + */ +public interface TaskManager extends Lifecycle { - private TaskManager() { - this.tasks = new ConcurrentHashMap<>(); - this.pendingTasks = new CopyOnWriteArrayList<>(); - this.deletableTasksContext = new ConcurrentHashMap<>(); - this.tasksLock = new ConcurrentHashMap<>(); - this.taskService = new TaskService(); - } + /** + * Inserts a newly created task into the registry (and persists it when applicable). + * + * @param ctx the command context that produced the task + * @param task the task to insert + */ + void insert(CommandContext ctx, Task task); - public static TaskManager getInstance() { - if (instance == null) { - instance = new TaskManager(); - } + /** + * Looks up a task by its full id. + * + * @param id the task id + * @return the task, or {@code null} if unknown + */ + Task getTask(String id); - return instance; - } + /** + * Whether a task with the given id is registered or pending activation. + * + * @param id the task id + * @return {@code true} if present + */ + boolean hasTask(String id); - public void init() { - logger.info("Initializing task manager..."); + /** + * Dispatches an interaction/context event to the matching task. + * + * @param ctx the task context + */ + void handle(TaskContext ctx); - loadBuiltInTasks(); - loadScheduledTasks(); - taskService.loadTasks(); - } + /** + * Promotes a pending step-task into the active registry under its (possibly new) id. + * + * @param task the task to activate + * @param oldId the previous id used while the task was pending + */ + void activateTask(Task task, String oldId); - public TaskService getTaskService() { - return taskService; - } + /** + * Removes a task from the active registry. + * + * @param task the task to remove + */ + void removeTask(Task task); - public Task getTask(String id) { - return tasks.get(id); - } + /** + * Inserts a previously persisted task back into the active registry. + * + * @param task the persistable task + */ + void insertPersistableTask(PersistableTask task); - public boolean hasTask(String id) { - return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id)); - } + /** + * Returns every currently active task. + * + * @return the active tasks + */ + Collection getTasks(); - public synchronized Collection 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 the task type + * @return the matching task, if any + */ + Optional findTaskByPartialId(Class 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 the task type + * @return the matching tasks + */ + List findTasksByPartialId(Class clazz, String partialId); - if (hasTask(id)) { - context.reply("A task with the same ID already exists!"); - return; - } + /** + * Finds the first button-bearing task of the given class. + * + * @param clazz the expected button-task type + * @return the matching task, or {@code null} + */ + WithButton findButtonTaskByClass(Class clazz); - task.replyToInteraction(context); - - if (task.isInsertable()) { - logger.info("Inserting task: {}", task); - - if (task instanceof PersistableTask persistableTask) { - taskService.persistTask(persistableTask); - } - - if (task instanceof StepTask) { - pendingTasks.add(task); - } else { - tasks.put(id, task); - } - } - - if (task instanceof Deletable) { - deletableTasksContext.put(task.getId(), context); - } - - if (task instanceof WithChildren taskWithChildren) { - List 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 Optional findTaskByPartialId(Class clazz, String partialId) { - List tasks = findTasksByPartialId(clazz, partialId); - return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst()); - } - - public List findTasksByPartialId(Class 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 clazz) { - return tasks.values().stream() - .filter(clazz::isInstance) - .map(task -> (WithButton) task) - .findFirst() - .orElse(null); - } - - public void insertPersistableTask(PersistableTask task) { - if (tasks.containsKey(task.getId())) { - logger.error("Trying to load persistable task but ID already exists: {}", task.getId()); - return; - } - - logger.info("Loaded persistable task: {}", task); - tasks.put(task.getId(), task); - } - - private void loadBuiltInTasks() { - logger.info("Loading built-in tasks..."); - - ReflectionHelper.loadTasks(BuiltInTask.class, builtInTask -> { - if (builtInTask.isInsertable()) { - logger.info("Loaded task: {}", builtInTask.getClass().getName()); - tasks.put(builtInTask.getId(), builtInTask); - } - }); - } - - private void loadScheduledTasks() { - logger.info("Loading scheduled tasks..."); - - ReflectionHelper.loadTasks(ScheduledTask.class, scheduledTask -> { - String taskId = scheduledTask.getId(); - - if (taskId == null || taskId.trim().isEmpty()) { - logger.error("Cannot load scheduled task {}: task ID is null or empty", scheduledTask.getClass().getName()); - return; - } - - ScheduledTaskManager.schedule(scheduledTask); - }); - } - - private void deleteInteraction(Task task) { - if (task instanceof WithParent taskWithParent) { - Task parent = taskWithParent.getParentTask(); - - if (parent instanceof Deletable deletable && task.isInsertable()) { - deleteInteraction(parent.getId(), deletable); - return; - } - } - - if (task instanceof Deletable deletable && task.isInsertable()) { - deleteInteraction(task.getId(), deletable); - } - } - - private void deleteInteraction(String taskId, Deletable deletable) { - logger.debug("Deleting interaction: {}", taskId); - - CommandContext context = deletableTasksContext.get(taskId); - if (context != null) { - deletableTasksContext.remove(taskId); - - InteractionHook hook = context.getInteractionHook(); - if (hook != null) { - if (hook.isExpired()) { - context.replyEphemeral("This interaction has expired, can't remove it"); - } - - if (!hook.isExpired()) { - hook.deleteOriginal().queue(); - } - } - } - - deletable.onInteractionDelete(); - } - - private void handleTaskLifecycle(Task task, Task.Lifecycle status) { - if (status == null) { - throw new IllegalArgumentException("Task lifecycle status cannot be null"); - } - - switch (status) { - case REMOVE_SELF: - removeTask(task); - break; - - case REMOVE_SELF_AND_CHILDREN: - if (task instanceof WithChildren taskWithChildren) { - for (Task child : taskWithChildren.getChildTasks()) { - removeTask(child); - } - } - removeTask(task); - break; - - case KEEP: - default: - break; - } - } + /** + * Returns the persistence service used for loadable tasks. + * + * @return the task service + */ + TaskService getTaskService(); } diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java b/src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java new file mode 100644 index 0000000..8175522 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java @@ -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 tasks; + private final List pendingTasks; + private final Map deletableTasksContext; + private final Map 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 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 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 Optional findTaskByPartialId(Class clazz, String partialId) { + List tasks = findTasksByPartialId(clazz, partialId); + return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst()); + } + + @Override + public List findTasksByPartialId(Class 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 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 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 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; + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/tasks/generic/AcceptButton.java b/src/main/java/me/bobert/netneutrality/core/tasks/generic/AcceptButton.java similarity index 56% rename from src/main/java/me/bobert/netneutrality/tasks/generic/AcceptButton.java rename to src/main/java/me/bobert/netneutrality/core/tasks/generic/AcceptButton.java index af27ff4..f7642e2 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/generic/AcceptButton.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/generic/AcceptButton.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.tasks.generic; +package me.bobert.netneutrality.core.tasks.generic; /* * Copyright (c) anthorob @@ -7,6 +7,7 @@ package me.bobert.netneutrality.tasks.generic; */ import me.bobert.netneutrality.core.context.TaskContext; +import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.impl.ButtonTask; import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable; import me.bobert.netneutrality.core.tasks.trait.WithChildren; @@ -24,26 +25,26 @@ public class AcceptButton

extends ButtonTask implements private final P parentTask; private final Function function; - protected AcceptButton(P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function function) { - super(buttonStyle, buttonLabel); + protected AcceptButton(TaskManager taskManager, P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function function) { + super(taskManager, buttonStyle, buttonLabel); this.parentTask = parentTask; this.function = function; } - public static

AcceptButton

createPrimaryButton(P parentTask, String buttonLabel, Function function) { - return new AcceptButton<>(parentTask, ButtonStyle.PRIMARY, buttonLabel, function); + public static

AcceptButton

createPrimaryButton(TaskManager taskManager, P parentTask, String buttonLabel, Function function) { + return new AcceptButton<>(taskManager, parentTask, ButtonStyle.PRIMARY, buttonLabel, function); } - public static

AcceptButton

createSecondaryButton(P parentTask, String buttonLabel, Function function) { - return new AcceptButton<>(parentTask, ButtonStyle.SECONDARY, buttonLabel, function); + public static

AcceptButton

createSecondaryButton(TaskManager taskManager, P parentTask, String buttonLabel, Function function) { + return new AcceptButton<>(taskManager, parentTask, ButtonStyle.SECONDARY, buttonLabel, function); } - public static

AcceptButton

createSuccessButton(P parentTask, String buttonLabel, Function function) { - return new AcceptButton<>(parentTask, ButtonStyle.SUCCESS, buttonLabel, function); + public static

AcceptButton

createSuccessButton(TaskManager taskManager, P parentTask, String buttonLabel, Function function) { + return new AcceptButton<>(taskManager, parentTask, ButtonStyle.SUCCESS, buttonLabel, function); } - public static

AcceptButton

createDangerButton(P parentTask, String buttonLabel, Function function) { - return new AcceptButton<>(parentTask, ButtonStyle.DANGER, buttonLabel, function); + public static

AcceptButton

createDangerButton(TaskManager taskManager, P parentTask, String buttonLabel, Function function) { + return new AcceptButton<>(taskManager, parentTask, ButtonStyle.DANGER, buttonLabel, function); } @Override diff --git a/src/main/java/me/bobert/netneutrality/tasks/generic/CancelButton.java b/src/main/java/me/bobert/netneutrality/core/tasks/generic/CancelButton.java similarity index 69% rename from src/main/java/me/bobert/netneutrality/tasks/generic/CancelButton.java rename to src/main/java/me/bobert/netneutrality/core/tasks/generic/CancelButton.java index 00fcee0..4f6fe48 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/generic/CancelButton.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/generic/CancelButton.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.tasks.generic; +package me.bobert.netneutrality.core.tasks.generic; /* * Copyright (c) anthorob @@ -7,6 +7,7 @@ package me.bobert.netneutrality.tasks.generic; */ import me.bobert.netneutrality.core.context.TaskContext; +import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.impl.ButtonTask; import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable; import me.bobert.netneutrality.core.tasks.trait.WithChildren; @@ -19,14 +20,14 @@ public class CancelButton

extends ButtonTask implements private final P parentTask; private final Function function; - protected CancelButton(P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function function) { - super(buttonStyle, buttonLabel); + protected CancelButton(TaskManager taskManager, P parentTask, ButtonStyle buttonStyle, String buttonLabel, Function function) { + super(taskManager, buttonStyle, buttonLabel); this.parentTask = parentTask; this.function = function; } - public static

CancelButton

create(P parentTask, String buttonLabel, Function function) { - return new CancelButton<>(parentTask, ButtonStyle.DANGER, buttonLabel, function); + public static

CancelButton

create(TaskManager taskManager, P parentTask, String buttonLabel, Function function) { + return new CancelButton<>(taskManager, parentTask, ButtonStyle.DANGER, buttonLabel, function); } @Override diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractBuiltInTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractBuiltInTask.java index 61dbbbd..e4dedf9 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractBuiltInTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractBuiltInTask.java @@ -8,9 +8,14 @@ package me.bobert.netneutrality.core.tasks.impl; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.tasks.BuiltInTask; +import me.bobert.netneutrality.core.tasks.TaskManager; public abstract class AbstractBuiltInTask extends AbstractTask implements BuiltInTask { + public AbstractBuiltInTask(TaskManager taskManager) { + super(taskManager); + } + @Override public String getName() { return getId(); diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractPersistableTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractPersistableTask.java index 820aad8..3c65f11 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractPersistableTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractPersistableTask.java @@ -12,6 +12,15 @@ import org.json.JSONObject; public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask { + /** + * Preferred constructor for DI. + * + * @param taskManager the application task manager, must not be {@code null} + */ + public AbstractPersistableTask(TaskManager taskManager) { + super(taskManager); + } + @Override public boolean isInsertable() { return true; @@ -29,26 +38,26 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe @Override public void insert(CommandContext context) { - if (TaskManager.getInstance().hasTask(getId())) { + if (taskManager().hasTask(getId())) { throw new RuntimeException("Cannot insert a task that already exists"); } - TaskManager.getInstance().insert(context, this); + taskManager().insert(context, this); } @Override public void save() { - if (!TaskManager.getInstance().hasTask(getId())) { + if (!taskManager().hasTask(getId())) { throw new RuntimeException("Cannot save a task that doesn't exist yet"); } - TaskManager.getInstance().getTaskService().persistTask(this); + taskManager().getTaskService().persistTask(this); } @Override public void delete() { - TaskManager.getInstance().getTaskService().unpersistTask(this); - TaskManager.getInstance().removeTask(this); + taskManager().getTaskService().unpersistTask(this); + taskManager().removeTask(this); } @Override diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractStepTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractStepTask.java index 3876037..fe9ba35 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractStepTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractStepTask.java @@ -31,6 +31,15 @@ public abstract class AbstractStepTask extends AbstractTask implements StepTask private Guild guild; private String id = UUID.randomUUID().toString(); + /** + * Preferred constructor for DI. + * + * @param taskManager the application task manager, must not be {@code null} + */ + public AbstractStepTask(TaskManager taskManager) { + super(taskManager); + } + @Override public String getId() { return id; @@ -156,7 +165,7 @@ public abstract class AbstractStepTask extends AbstractTask implements StepTask String oldId = id; synchronized (this) { this.id = newId; - TaskManager.getInstance().activateTask(this, oldId); + taskManager().activateTask(this, oldId); } } } diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractTask.java index c972192..a41247d 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/AbstractTask.java @@ -14,9 +14,29 @@ import java.util.Objects; public abstract class AbstractTask implements Task { private final OffsetDateTime creationTime; + /** + * Injected task manager when constructed via DI. + */ + private final TaskManager taskManager; - public AbstractTask() { - creationTime = OffsetDateTime.now(); + /** + * Preferred constructor for DI: stores the shared {@link TaskManager} for insert/activate/remove. + * + * @param taskManager the application task manager, must not be {@code null} + */ + public AbstractTask(TaskManager taskManager) { + Objects.requireNonNull(taskManager, "TaskManager must not be null"); + this.creationTime = OffsetDateTime.now(); + this.taskManager = taskManager; + } + + /** + * Returns the injected task manager. + * + * @return the task manager to use + */ + protected final TaskManager taskManager() { + return taskManager; } @Override @@ -26,7 +46,7 @@ public abstract class AbstractTask implements Task { @Override public void insert(CommandContext context) { - TaskManager.getInstance().insert(context, this); + taskManager().insert(context, this); } @Override diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/ButtonTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/ButtonTask.java index b5ec995..e94e650 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/ButtonTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/ButtonTask.java @@ -7,6 +7,7 @@ package me.bobert.netneutrality.core.tasks.impl; import me.bobert.netneutrality.core.tasks.Task; import me.bobert.netneutrality.core.tasks.trait.WithButton; +import me.bobert.netneutrality.core.tasks.TaskManager; import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.components.buttons.ButtonStyle; import net.dv8tion.jda.api.entities.emoji.Emoji; @@ -18,7 +19,8 @@ public abstract class ButtonTask extends GenericRandomIdTask implements WithButt private final String buttonLabel; private Emoji emoji; - public ButtonTask(ButtonStyle buttonStyle, String buttonLabel) { + public ButtonTask(TaskManager taskManager, ButtonStyle buttonStyle, String buttonLabel) { + super(taskManager); this.buttonStyle = buttonStyle; this.buttonLabel = buttonLabel; } diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/EmojiTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/EmojiTask.java index cfdb734..b4b5ae0 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/EmojiTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/EmojiTask.java @@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.tasks.impl; */ import me.bobert.netneutrality.context.tasks.EmojiTaskContext; +import me.bobert.netneutrality.core.tasks.TaskManager; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -16,7 +17,8 @@ public abstract class EmojiTask extends AbstractPersistableTask { private final String channelId; private final String messageId; - public EmojiTask(String guildId, String channelId, String messageId) { + public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) { + super(taskManager); this.guildId = guildId; this.channelId = channelId; this.messageId = messageId; diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericCustomIdTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericCustomIdTask.java index 573905b..052a9af 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericCustomIdTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericCustomIdTask.java @@ -1,20 +1,22 @@ package me.bobert.netneutrality.core.tasks.impl; - /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ -public abstract class GenericCustomIdTask extends AbstractTask { - private final String customId; +import me.bobert.netneutrality.core.tasks.TaskManager; - public GenericCustomIdTask(String id) { - this.customId = id; +public abstract class GenericCustomIdTask extends AbstractTask { + private final String id; + + public GenericCustomIdTask(TaskManager taskManager, String id) { + super(taskManager); + this.id = id; } @Override public String getId() { - return customId; + return id; } } diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericRandomIdTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericRandomIdTask.java index e7a0e66..c9179ce 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericRandomIdTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/GenericRandomIdTask.java @@ -1,15 +1,16 @@ package me.bobert.netneutrality.core.tasks.impl; - /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.core.tasks.TaskManager; + import java.util.UUID; public abstract class GenericRandomIdTask extends GenericCustomIdTask { - public GenericRandomIdTask() { - super(UUID.randomUUID().toString()); + public GenericRandomIdTask(TaskManager taskManager) { + super(taskManager, UUID.randomUUID().toString()); } } diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/impl/ModalTask.java b/src/main/java/me/bobert/netneutrality/core/tasks/impl/ModalTask.java index ee331bf..d8c749a 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/impl/ModalTask.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/impl/ModalTask.java @@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.tasks.impl; */ import me.bobert.netneutrality.core.context.TaskContext; +import me.bobert.netneutrality.core.tasks.TaskManager; import net.dv8tion.jda.api.interactions.modals.ModalMapping; import java.time.OffsetDateTime; @@ -14,6 +15,10 @@ import java.util.Optional; public abstract class ModalTask extends GenericRandomIdTask { + public ModalTask(TaskManager taskManager) { + super(taskManager); + } + @Override public boolean isExpired() { return getCreationTime().plusMinutes(5).isBefore(OffsetDateTime.now()); diff --git a/src/main/java/me/bobert/netneutrality/core/thread/ExecutorProvider.java b/src/main/java/me/bobert/netneutrality/core/thread/ExecutorProvider.java new file mode 100644 index 0000000..53b91b2 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/thread/ExecutorProvider.java @@ -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. + * + *

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(); +} diff --git a/src/main/java/me/bobert/netneutrality/core/thread/ScheduledTaskManager.java b/src/main/java/me/bobert/netneutrality/core/thread/ScheduledTaskManager.java index bc33c35..7d4f403 100644 --- a/src/main/java/me/bobert/netneutrality/core/thread/ScheduledTaskManager.java +++ b/src/main/java/me/bobert/netneutrality/core/thread/ScheduledTaskManager.java @@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.thread; * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.bootstrap.Lifecycle; import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.tasks.ScheduledTask; import org.apache.logging.log4j.LogManager; @@ -16,16 +17,38 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; 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 ScheduledExecutorService executorService = ThreadManager.getScheduledExecutor(); - private static final Map> scheduledTasks = new ConcurrentHashMap<>(); - private ScheduledTaskManager() {} + private final Map> 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(); - + if (scheduledTasks.containsKey(taskId)) { logger.warn("Task {} is already scheduled.", taskId); return; @@ -47,16 +70,16 @@ public class ScheduledTaskManager { ScheduledFuture future = executorService.scheduleWithFixedDelay( runnable, 1, - timeFrame.getInterval(), + timeFrame.getInterval(), timeFrame.getUnit() ); - + scheduledTasks.put(taskId, future); - logger.info("Successfully scheduled task: {} with interval {} {}", + logger.info("Successfully scheduled task: {} with interval {} {}", taskId, timeFrame.getInterval(), timeFrame.getUnit()); } - public static void cancelAll() { + public void cancelAll() { logger.info("Cancelling all {} scheduled tasks", scheduledTasks.size()); scheduledTasks.forEach((id, future) -> future.cancel(false)); scheduledTasks.clear(); diff --git a/src/main/java/me/bobert/netneutrality/core/thread/ThreadManager.java b/src/main/java/me/bobert/netneutrality/core/thread/ThreadManager.java index 1f03be1..4e56d67 100644 --- a/src/main/java/me/bobert/netneutrality/core/thread/ThreadManager.java +++ b/src/main/java/me/bobert/netneutrality/core/thread/ThreadManager.java @@ -12,11 +12,29 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; 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 executors = new ConcurrentHashMap<>(); + private final Map 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( "scheduled-executor", 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))); } - public static ExecutorService getExecutor() { + @Override + public ExecutorService getExecutor() { return getExecutor("general-executor"); } - public static void shutdownExecutor() { + @Override + public void stop() { System.out.println("[Executor] Shutting down..."); try { @@ -56,6 +77,4 @@ public class ThreadManager { System.out.println("[Executor] Shutdown finished!"); } - - } diff --git a/src/main/java/me/bobert/netneutrality/core/db/DatabaseMigration.java b/src/main/java/me/bobert/netneutrality/db/DatabaseMigration.java similarity index 64% rename from src/main/java/me/bobert/netneutrality/core/db/DatabaseMigration.java rename to src/main/java/me/bobert/netneutrality/db/DatabaseMigration.java index 9f7144d..9cb116e 100644 --- a/src/main/java/me/bobert/netneutrality/core/db/DatabaseMigration.java +++ b/src/main/java/me/bobert/netneutrality/db/DatabaseMigration.java @@ -1,11 +1,11 @@ -package me.bobert.netneutrality.core.db; +package me.bobert.netneutrality.db; /* * Copyright (c) anthorob * * 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.Logger; import org.flywaydb.core.Flyway; @@ -14,12 +14,12 @@ import org.flywaydb.core.api.output.MigrateResult; public class DatabaseMigration { private final static Logger logger = LogManager.getLogger(DatabaseMigration.class); - public static void migrate() { - 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"); + public static void migrate(SettingsProvider settings) { + String dbName = settings.get("MYSQL_DATABASE", "netneutrality"); + String dbUser = settings.get("MYSQL_USER", "root"); + String dbPass = settings.get("MYSQL_PASSWORD", ""); + String dbHost = settings.get("MYSQL_HOST", "localhost"); + String dbPort = settings.get("MYSQL_PORT", "3306"); String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName); try { diff --git a/src/main/java/me/bobert/netneutrality/utils/JpaUtils.java b/src/main/java/me/bobert/netneutrality/db/JpaUtils.java similarity index 58% rename from src/main/java/me/bobert/netneutrality/utils/JpaUtils.java rename to src/main/java/me/bobert/netneutrality/db/JpaUtils.java index 7f55a3b..baf02af 100644 --- a/src/main/java/me/bobert/netneutrality/utils/JpaUtils.java +++ b/src/main/java/me/bobert/netneutrality/db/JpaUtils.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.utils; +package me.bobert.netneutrality.db; /* * Copyright (c) anthorob @@ -6,7 +6,6 @@ package me.bobert.netneutrality.utils; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.core.db.SessionManager; import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.service.GuildService; import org.apache.logging.log4j.LogManager; @@ -14,15 +13,59 @@ import org.apache.logging.log4j.Logger; import org.hibernate.HibernateException; import org.hibernate.ObjectNotFoundException; import org.hibernate.Session; +import org.hibernate.SessionFactory; import org.hibernate.Transaction; import org.hibernate.exception.ConstraintViolationException; import java.io.Serializable; +import java.util.Objects; import java.util.function.Consumer; +import java.util.function.Function; +/** + * Static Hibernate helpers used by repositories and domain services. + * + *

Because repositories are created deep in the object graph without constructor injection, + * this class holds a single {@link SessionProvider} bound once during bootstrap via + * {@link #bind(SessionProvider)}. That is the only global access point for sessions — there is no + * {@code SessionManager.instance()} shim. + */ public class JpaUtils { private static final Logger logger = LogManager.getLogger(JpaUtils.class); + private static volatile SessionProvider sessions; + private static volatile Function 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 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. @@ -44,7 +87,12 @@ public class JpaUtils { String cachedId = oGuild.getGuildId(); logger.error("Trying to load Guild[{}] while entity doesn't exist in database, reloading..", cachedId); - GuildService service = GuildService.get(cachedId); + Function lookup = guildServiceLookup; + if (lookup == null) { + session.close(); + throw exception; + } + GuildService service = lookup.apply(cachedId); service.reloadEntity(); service.getBlacklistService().removeGuildBlacklist(); logger.info("Guild[{}] reloaded", cachedId); @@ -56,14 +104,18 @@ public class JpaUtils { } } + public static Session openSession() { + return factory().openSession(); + } + public static void runWithSession(Consumer consumer) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = factory().openSession()) { consumer.accept(session); } } public static void runWithTransaction(Consumer consumer) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = factory().openSession()) { session.beginTransaction(); consumer.accept(session); session.getTransaction().commit(); @@ -71,7 +123,7 @@ public class JpaUtils { } public static void updateWithTransaction(Object persistantEntity, Runnable runnable) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = factory().openSession()) { Transaction transaction = session.beginTransaction(); try { @@ -98,7 +150,7 @@ public class JpaUtils { * @param consumer The comsumer, which exposes the session */ public static void getWithSession(Object persistantEntity, Serializable id, Consumer consumer) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = factory().openSession()) { tryLoad(session, persistantEntity, id); consumer.accept(session); } diff --git a/src/main/java/me/bobert/netneutrality/db/Repository.java b/src/main/java/me/bobert/netneutrality/db/Repository.java index 572ad9b..e3b1184 100644 --- a/src/main/java/me/bobert/netneutrality/db/Repository.java +++ b/src/main/java/me/bobert/netneutrality/db/Repository.java @@ -5,7 +5,6 @@ package me.bobert.netneutrality.db; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.core.db.SessionManager; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.hibernate.Session; @@ -24,7 +23,7 @@ public interface Repository { default void save(O entity) { Transaction transaction = null; - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = JpaUtils.openSession()) { transaction = session.beginTransaction(); session.merge(entity); transaction.commit(); diff --git a/src/main/java/me/bobert/netneutrality/db/SessionManager.java b/src/main/java/me/bobert/netneutrality/db/SessionManager.java new file mode 100644 index 0000000..96769b2 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/SessionManager.java @@ -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. + * + *

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 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; + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/SessionProvider.java b/src/main/java/me/bobert/netneutrality/db/SessionProvider.java new file mode 100644 index 0000000..9a99d51 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/SessionProvider.java @@ -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. + * + *

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(); +} diff --git a/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java index 6c794a5..6e92f41 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java @@ -5,11 +5,10 @@ package me.bobert.netneutrality.db.repository; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.core.db.SessionManager; import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.entities.OBlacklist; 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 org.hibernate.Session; import org.hibernate.query.Query; @@ -92,7 +91,7 @@ public class BlacklistRepository implements Repository { } private OBlacklist getWordFromDb(String guildId, String word) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = JpaUtils.openSession()) { Query query = session.createQuery("SELECT o FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class); query.setParameter("guildId", guildId); query.setParameter("word", word); diff --git a/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java index 0738e6b..3cbbddc 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java @@ -4,10 +4,15 @@ package me.bobert.netneutrality.db.repository;/* * 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.entities.OGuild; -import me.bobert.netneutrality.utils.JpaUtils; +import me.bobert.netneutrality.db.JpaUtils; import org.hibernate.Session; import org.hibernate.query.Query; @@ -30,7 +35,7 @@ public class GuildRepository implements Repository { @Override public OGuild findById(String id) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = JpaUtils.openSession()) { Query query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class); query.setParameter("guildId", id); diff --git a/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java index 921579b..c53a05a 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java @@ -4,12 +4,17 @@ package me.bobert.netneutrality.db.repository;/* * 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.Repository; import me.bobert.netneutrality.db.entities.OGuild; 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.query.Query; @@ -32,7 +37,7 @@ public class GuildSettingRepository implements Repository { @Override public OGuildSetting findById(String id) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = JpaUtils.openSession()) { Query query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class); query.setParameter("id", id); diff --git a/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java index e65b9ec..2403622 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java @@ -5,11 +5,10 @@ package me.bobert.netneutrality.db.repository; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.core.db.SessionManager; import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.entities.OGuild; 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.Message; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; @@ -35,7 +34,7 @@ public class MessageRepository implements Repository { @Override public OLogMessage findById(String id) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = JpaUtils.openSession()) { Query query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class); query.setParameter("messageId", id); diff --git a/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java index 48535c1..d4acd36 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java @@ -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.entities.OTask; -import me.bobert.netneutrality.utils.JpaUtils; +import me.bobert.netneutrality.db.JpaUtils; import org.hibernate.Session; import org.hibernate.query.Query; @@ -33,7 +32,7 @@ public class TaskRepository implements Repository { @Override public OTask findById(String id) { - try (Session session = SessionManager.getSessionFactory().openSession()) { + try (Session session = JpaUtils.openSession()) { Query query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class); query.setParameter("taskId", id); diff --git a/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java b/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java index 9d95f3b..d64538f 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java @@ -10,7 +10,7 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.db.entities.OBlacklist; import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.repository.BlacklistRepository; -import me.bobert.netneutrality.utils.JpaUtils; +import me.bobert.netneutrality.db.JpaUtils; import net.dv8tion.jda.api.entities.Member; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildService.java b/src/main/java/me/bobert/netneutrality/db/service/GuildService.java index cb60287..f36cfca 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/GuildService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildService.java @@ -8,40 +8,29 @@ package me.bobert.netneutrality.db.service; import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.repository.GuildRepository; -import me.bobert.netneutrality.utils.JpaUtils; -import net.dv8tion.jda.api.entities.Guild; +import me.bobert.netneutrality.db.JpaUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - +/** + * Per-guild domain service (blacklist, settings, entity access). + * + *

Instances are owned by {@link GuildServiceProviderImpl}; obtain them via + * {@link me.bobert.netneutrality.db.service.GuildServiceProvider}. + */ public class GuildService { - private static final Map services = new ConcurrentHashMap<>(); private static final Logger logger = LoggerFactory.getLogger(GuildService.class); private final GuildRepository guildRepository = new GuildRepository(); private final BlacklistService blacklistService; private final GuildSettingService guildSettingService; private OGuild oGuild; - private GuildService(String guildId) { + GuildService(String guildId) { this.oGuild = findOrCreateGuild(guildId); this.blacklistService = new BlacklistService(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 getServices() { - return services; - } - public OGuild getEntity() { return oGuild; } diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProvider.java b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProvider.java new file mode 100644 index 0000000..9434d50 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProvider.java @@ -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 getServices(); +} diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProviderImpl.java b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProviderImpl.java new file mode 100644 index 0000000..3e36687 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProviderImpl.java @@ -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 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 getServices() { + return services; + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/service/TaskService.java b/src/main/java/me/bobert/netneutrality/db/service/TaskService.java index 5685b51..9fb6a80 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/TaskService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/TaskService.java @@ -18,10 +18,12 @@ import java.util.List; import java.util.Objects; public class TaskService { + private final TaskManager taskManager; private static final Logger logger = LogManager.getLogger(TaskService.class); private final TaskRepository taskRepository; - public TaskService() { + public TaskService(TaskManager taskManager) { + this.taskManager = taskManager; this.taskRepository = new TaskRepository(); } @@ -51,7 +53,7 @@ public class TaskService { } validateTask(task, oTask); - TaskManager.getInstance().insertPersistableTask(task); + taskManager.insertPersistableTask(task); } catch (Exception e) { logger.error("Error loading task with ID {}: {}", taskId, e.getMessage()); } diff --git a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java index 9944593..c129361 100644 --- a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java +++ b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java @@ -19,9 +19,11 @@ import me.bobert.netneutrality.core.command.CommandProcessor; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.TaskContext; -import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.db.service.GuildServiceProvider; 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.events.Event; import net.dv8tion.jda.api.events.GenericEvent; @@ -40,15 +42,34 @@ import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.function.Function; public class EventsHandler implements EventListener { - private static final Map, Function> listeners = new ConcurrentHashMap<>(); private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class); - static { + private final Map, Function> 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 -> { if (event.getAuthor().isBot()) { return null; @@ -59,9 +80,9 @@ public class EventsHandler implements EventListener { Message message = event.getMessage(); String rawMessage = message.getContentDisplay().trim(); - String prefix = Settings.getInstance().get("NETNEUTRALITY_BOT_PREFIX", "$"); + String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$"); if (rawMessage.startsWith(prefix)) { - return new MessageCommandContext(event); + return new MessageCommandContext(event, settings); } return new MessageTaskContext(event); @@ -74,13 +95,13 @@ public class EventsHandler implements EventListener { addListener(ButtonInteractionEvent.class, ButtonTaskContext::new); addListener(MessageReactionAddEvent.class, EmojiTaskContext::new); addGenericListener(GuildJoinEvent.class, event -> { - GuildService service = GuildService.get(event.getGuild()); + GuildService service = guildServices.get(event.getGuild()); if (service.isDebugEnabled()) { 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 the type of the event that extends the {@link Event} class */ @SuppressWarnings("unchecked") - public static void addListener(Class eventClass, Function function) { + public void addListener(Class eventClass, Function function) { logger.info("Registering listener for {}", eventClass.getSimpleName()); listeners.put(eventClass, (Function) 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 the type of the event that extends the {@link Event} class */ - public static void addGenericListener(Class eventClass, Consumer consumer) { + public void addGenericListener(Class eventClass, Consumer consumer) { addListener(eventClass, t -> { consumer.accept(t); return null; @@ -141,11 +162,11 @@ public class EventsHandler implements EventListener { } private void handleCommand(CommandContext context) { - CommandProcessor processor = new CommandProcessor(context); + CommandProcessor processor = new CommandProcessor(context, commands, taskManager); processor.dispatch(); } private void handleTask(TaskContext context) { - TaskManager.getInstance().handle(context); + taskManager.handle(context); } } diff --git a/src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java b/src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java index 7637ed7..d4689b1 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java +++ b/src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java @@ -8,25 +8,38 @@ package me.bobert.netneutrality.http.server; import com.google.gson.Gson; 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 net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; import org.apache.logging.log4j.LogManager; import org.json.JSONObject; import java.io.IOException; import java.util.Map; +import java.util.Objects; import java.util.TreeMap; +import java.util.function.Supplier; public class ActionHandler extends AbstractHandler { + private final Supplier jdaSupplier; + private final GuildServiceProvider guildServices; + private final Runnable shutdownHook; + + public ActionHandler(Supplier jdaSupplier, GuildServiceProvider guildServices, Runnable shutdownHook) { + this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier"); + this.guildServices = Objects.requireNonNull(guildServices, "guildServices"); + this.shutdownHook = Objects.requireNonNull(shutdownHook, "shutdownHook"); + } + @Override public void handle(HttpExchange exchange) throws IOException { String path = exchange.getRequestURI().getRawPath(); if (path.equals("/actions/shutdown")) { - LogManager.getLogger(NetNeutrality.class).info("Request to shutdown received"); - NetNeutrality.getInstance().shutdown(); + LogManager.getLogger(ActionHandler.class).info("Request to shutdown received"); + shutdownHook.run(); sendStringResponse(exchange, "Shutting down.."); System.exit(0); @@ -35,8 +48,9 @@ public class ActionHandler extends AbstractHandler { if (path.equals("/actions/status")) { TreeMap json = new TreeMap<>(); - json.put("health", String.valueOf(NetNeutrality.getInstance().getJDA().getStatus())); - json.put("ping", String.valueOf(NetNeutrality.getInstance().getJDA().getGatewayPing())); + JDA jda = jdaSupplier.get(); + json.put("health", String.valueOf(jda.getStatus())); + json.put("ping", String.valueOf(jda.getGatewayPing())); sendJsonResponse(exchange, new Gson().toJson(json)); return; @@ -47,9 +61,9 @@ public class ActionHandler extends AbstractHandler { String[] args = exchange.getRequestURI().toString().split("/"); String guildId = args[args.length - 1]; - Guild guild = NetNeutrality.getInstance().getJDA().getGuildById(guildId); + Guild guild = jdaSupplier.get().getGuildById(guildId); if (guild != null) { - GuildService service = GuildService.get(guild); + GuildService service = guildServices.get(guild); Map settings = service.getGuildSettingService().getSettings(); JSONObject setting = new JSONObject(settings); @@ -69,7 +83,7 @@ public class ActionHandler extends AbstractHandler { String guildId = args[args.length - 1]; boolean ok = true; //TODO: add bool - GuildService.get(guildId).getGuildSettingService().reloadSettingsCache(); + guildServices.get(guildId).getGuildSettingService().reloadSettingsCache(); json.put("status", String.valueOf(ok)); sendJsonResponse(exchange, new Gson().toJson(json)); diff --git a/src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java b/src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java index c082b06..d8d9e09 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java +++ b/src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java @@ -7,14 +7,20 @@ package me.bobert.netneutrality.http.server; */ import com.sun.net.httpserver.HttpExchange; -import me.bobert.netneutrality.NetNeutrality; import net.dv8tion.jda.api.JDA; import org.json.JSONArray; import org.json.JSONObject; import java.io.IOException; +import java.util.Objects; +import java.util.function.Supplier; public class GuildHandler extends AbstractHandler { + private final Supplier jdaSupplier; + + public GuildHandler(Supplier jdaSupplier) { + this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier"); + } @Override public void handle(HttpExchange exchange) throws IOException { @@ -25,7 +31,7 @@ public class GuildHandler extends AbstractHandler { JSONArray array = new JSONArray(); - JDA jda = NetNeutrality.getInstance().getJDA(); + JDA jda = jdaSupplier.get(); jda.getGuilds().forEach(guild -> { JSONObject obj = new JSONObject(); @@ -45,7 +51,7 @@ public class GuildHandler extends AbstractHandler { String guild = args[args.length - 1]; JSONArray array = new JSONArray(); - JDA jda = NetNeutrality.getInstance().getJDA(); + JDA jda = jdaSupplier.get(); if (jda.getGuildById(guild) == null) { sendStringResponse(exchange, path); @@ -69,7 +75,7 @@ public class GuildHandler extends AbstractHandler { String[] args = exchange.getRequestURI().toString().split("/"); String guild = args[args.length - 1]; JSONArray array = new JSONArray(); - JDA jda = NetNeutrality.getInstance().getJDA(); + JDA jda = jdaSupplier.get(); if (jda.getGuildById(guild) == null) { sendStringResponse(exchange, path); diff --git a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java index c7a595d..8db4dd3 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java +++ b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java @@ -7,7 +7,11 @@ package me.bobert.netneutrality.http.server; */ import com.sun.net.httpserver.HttpServer; +import me.bobert.netneutrality.bootstrap.Lifecycle; 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.Logger; @@ -15,32 +19,73 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.util.concurrent.ExecutorService; 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 final SettingsProvider settings; + private final GuildServiceProvider guildServices; + private final Supplier jdaSupplier; + private final Runnable shutdownHook; private HttpServer server; + private ExecutorService executor; - public WebApi(int port) { + public WebApi( + SettingsProvider settings, + GuildServiceProvider guildServices, + Supplier 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..."); try { InetSocketAddress sockAddr = new InetSocketAddress(port); server = HttpServer.create(sockAddr, 0); } catch (IOException e) { - e.printStackTrace(); + logger.error("Failed to create HTTP server", e); + return; } server.createContext("/", new RootHandler()); - server.createContext("/actions", new ActionHandler()); - server.createContext("/guilds", new GuildHandler()); - ExecutorService executor = Executors.newFixedThreadPool(10, new BaseThreadFactory("web-api")); + server.createContext("/actions", new ActionHandler(jdaSupplier, guildServices, shutdownHook)); + server.createContext("/guilds", new GuildHandler(jdaSupplier)); + executor = Executors.newFixedThreadPool(10, new BaseThreadFactory("web-api")); server.setExecutor(executor); server.start(); 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; + } + } } - diff --git a/src/main/java/me/bobert/netneutrality/music/MusicManager.java b/src/main/java/me/bobert/netneutrality/music/MusicManager.java index b4a09b8..45978fd 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicManager.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicManager.java @@ -6,89 +6,56 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.utils.MusicUtils; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; 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 dev.lavalink.youtube.YoutubeAudioSourceManager; -import dev.lavalink.youtube.YoutubeSourceOptions; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.service.GuildService; -import me.bobert.netneutrality.music.source.UdioAudioSourceManager; -import me.bobert.netneutrality.utils.MusicUtils; -import me.bobert.netneutrality.utils.Settings; +import me.bobert.netneutrality.core.tasks.TaskManager; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; 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; - +/** + * Per-guild music player and queue owner. + * + *

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 { private static final Logger logger = LogManager.getLogger(MusicManager.class); - private static final Map managers = new ConcurrentHashMap<>(); - private static final AudioPlayerManager playerManager = new DefaultAudioPlayerManager(); private final Guild guild; + private final AudioPlayerManager playerManager; private final AudioPlayer player; private final TrackScheduler scheduler; 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.playerManager = playerManager; this.player = playerManager.createPlayer(); 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)); player.addListener(scheduler); - - managers.put(guild.getId(), this); } - public static MusicManager get(Guild guild) { - if (managers.containsKey(guild.getId())) { - return managers.get(guild.getId()); - } else { - return new MusicManager(guild); - } + public GuildService getGuildService() { + return guildService; } - public static void initPlayerManager() { - logger.debug("Initializing music player sources.."); - - 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 getManagers() { - return managers.values(); + public TaskManager getTaskManager() { + return taskManager; } public TrackScheduler getScheduler() { @@ -152,7 +119,7 @@ public class MusicManager { public synchronized void play(String query, CommandContext context, boolean silent) { 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); String identifier = SourceFinder.getIdentifier(query); if (identifier == null) { diff --git a/src/main/java/me/bobert/netneutrality/music/MusicService.java b/src/main/java/me/bobert/netneutrality/music/MusicService.java new file mode 100644 index 0000000..13cc403 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/MusicService.java @@ -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. + * + *

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 getManagers(); +} diff --git a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java new file mode 100644 index 0000000..ebff57e --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java @@ -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 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 getManagers() { + return managers.values(); + } + + /** + * Returns the shared Lavaplayer {@link AudioPlayerManager}. + * + * @return the player manager + */ + public AudioPlayerManager getPlayerManager() { + return playerManager; + } +} diff --git a/src/main/java/me/bobert/netneutrality/music/TrackInfo.java b/src/main/java/me/bobert/netneutrality/music/TrackInfo.java index 11d1b3c..3efed68 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackInfo.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackInfo.java @@ -4,6 +4,12 @@ package me.bobert.netneutrality.music;/* * https://discord.gg/qUa6zNFZsd */ +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import net.dv8tion.jda.api.entities.Member; import org.apache.logging.log4j.LogManager; diff --git a/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java b/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java index 00ca26f..d980848 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java @@ -6,6 +6,7 @@ package me.bobert.netneutrality.music; */ +import me.bobert.netneutrality.utils.MusicUtils; import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; @@ -13,8 +14,7 @@ import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.tasks.music.LoadPlaylistTask; -import me.bobert.netneutrality.utils.MessageFunction; -import me.bobert.netneutrality.utils.MusicUtils; +import me.bobert.netneutrality.shared.util.MessageFunction; import net.dv8tion.jda.api.entities.Guild; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -29,7 +29,10 @@ public class TrackLoadingHandler implements AudioLoadResultHandler { private final QueueManager queueManager; private final CommandContext context; + private final MusicManager manager; + public TrackLoadingHandler(CommandContext context, MusicManager manager, String identifier, SourceFinder.AudioSourceType type, boolean silent) { + this.manager = manager; this.identifier = identifier; this.type = type; this.silent = silent; @@ -43,7 +46,7 @@ public class TrackLoadingHandler implements AudioLoadResultHandler { logger.debug("Guild {}: track loaded: {}", guild.getId(), audioTrack.getIdentifier()); context.deleteMessage(); - if (MusicUtils.connect(context)) { + if (MusicUtils.connect(context, manager)) { queueManager.queue(audioTrack, context.getGuildUser()); if (!silent) { @@ -56,7 +59,7 @@ public class TrackLoadingHandler implements AudioLoadResultHandler { public void playlistLoaded(AudioPlaylist playlist) { logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier); - new LoadPlaylistTask(playlist, type).insert(context); + new LoadPlaylistTask(playlist, type, manager).insert(context); } @Override diff --git a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java index 47d2b5f..2bc8548 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java @@ -10,8 +10,8 @@ import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; -import me.bobert.netneutrality.context.MusicChannelContext; import me.bobert.netneutrality.core.LogHandler; +import me.bobert.netneutrality.context.MusicChannelContext; import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.exceptions.ContextException; import net.dv8tion.jda.api.entities.Guild; diff --git a/src/main/java/me/bobert/netneutrality/utils/ContextUtils.java b/src/main/java/me/bobert/netneutrality/shared/util/ContextUtils.java similarity index 98% rename from src/main/java/me/bobert/netneutrality/utils/ContextUtils.java rename to src/main/java/me/bobert/netneutrality/shared/util/ContextUtils.java index 995cc33..a3ba871 100644 --- a/src/main/java/me/bobert/netneutrality/utils/ContextUtils.java +++ b/src/main/java/me/bobert/netneutrality/shared/util/ContextUtils.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.utils; +package me.bobert.netneutrality.shared.util; /* * Copyright (c) anthorob * diff --git a/src/main/java/me/bobert/netneutrality/utils/GuildUtils.java b/src/main/java/me/bobert/netneutrality/shared/util/GuildUtils.java similarity index 93% rename from src/main/java/me/bobert/netneutrality/utils/GuildUtils.java rename to src/main/java/me/bobert/netneutrality/shared/util/GuildUtils.java index 66e8fcb..850e9c0 100644 --- a/src/main/java/me/bobert/netneutrality/utils/GuildUtils.java +++ b/src/main/java/me/bobert/netneutrality/shared/util/GuildUtils.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.utils; +package me.bobert.netneutrality.shared.util; /* * Copyright (c) anthorob diff --git a/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java b/src/main/java/me/bobert/netneutrality/shared/util/MessageFunction.java similarity index 98% rename from src/main/java/me/bobert/netneutrality/utils/MessageFunction.java rename to src/main/java/me/bobert/netneutrality/shared/util/MessageFunction.java index b6c894c..03c26af 100644 --- a/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java +++ b/src/main/java/me/bobert/netneutrality/shared/util/MessageFunction.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.utils; +package me.bobert.netneutrality.shared.util; /* * Copyright (c) anthorob @@ -13,7 +13,7 @@ import io.sentry.Sentry; import me.bobert.netneutrality.core.command.Command; import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandContainer; -import me.bobert.netneutrality.core.command.CommandManager; +import me.bobert.netneutrality.core.command.CommandRegistry; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.TaskContext; @@ -205,10 +205,10 @@ public class MessageFunction { return embed.build(); } - public static String buildHelpMessage() { + public static String buildHelpMessage(CommandRegistry commands) { Map help = new HashMap<>(); - for (CommandContainer container : CommandManager.commands.values()) { + for (CommandContainer container : commands.getCommands().values()) { Command command = container.getCommand(); if (command.getCategory() == null) { continue; diff --git a/src/main/java/me/bobert/netneutrality/utils/MiscUtils.java b/src/main/java/me/bobert/netneutrality/shared/util/MiscUtils.java similarity index 91% rename from src/main/java/me/bobert/netneutrality/utils/MiscUtils.java rename to src/main/java/me/bobert/netneutrality/shared/util/MiscUtils.java index c4af9f3..03ffd56 100644 --- a/src/main/java/me/bobert/netneutrality/utils/MiscUtils.java +++ b/src/main/java/me/bobert/netneutrality/shared/util/MiscUtils.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.utils; +package me.bobert.netneutrality.shared.util; /* * Copyright (c) anthorob * diff --git a/src/main/java/me/bobert/netneutrality/utils/UserFunction.java b/src/main/java/me/bobert/netneutrality/shared/util/UserFunction.java similarity index 95% rename from src/main/java/me/bobert/netneutrality/utils/UserFunction.java rename to src/main/java/me/bobert/netneutrality/shared/util/UserFunction.java index 530112f..e216e30 100644 --- a/src/main/java/me/bobert/netneutrality/utils/UserFunction.java +++ b/src/main/java/me/bobert/netneutrality/shared/util/UserFunction.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.utils; +package me.bobert.netneutrality.shared.util; /* * Copyright (c) anthorob diff --git a/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java b/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java index eb8bf50..4fbe79e 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java @@ -11,7 +11,8 @@ import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.tasks.impl.ModalTask; -import me.bobert.netneutrality.utils.Settings; +import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.utils.SettingsProvider; import net.dv8tion.jda.api.components.label.Label; import net.dv8tion.jda.api.components.textinput.TextInput; import net.dv8tion.jda.api.components.textinput.TextInputStyle; @@ -27,9 +28,12 @@ import org.gitlab4j.api.models.Issue; public class ReportIssueTask extends ModalTask { private static final Logger logger = LogManager.getLogger(ReportIssueTask.class); private final boolean insertable; + private final SettingsProvider settings; - public ReportIssueTask(CommandContext context) { + public ReportIssueTask(TaskManager taskManager, CommandContext context, SettingsProvider settings) { + super(taskManager); this.insertable = context instanceof SlashCommandContext; + this.settings = settings; } @Override @@ -68,11 +72,13 @@ public class ReportIssueTask extends ModalTask { public Lifecycle execute(TaskContext context) { context.ephemeral(); - String gitlabHost = Settings.getInstance().get("GITLAB_HOST"); - String gitlabToken = Settings.getInstance().get("GITLAB_TOKEN"); - String projectId = Settings.getInstance().get("GITLAB_PROJECT_ID"); + String gitlabHost = settings.get("GITLAB_HOST"); + String gitlabToken = settings.get("GITLAB_TOKEN"); + String projectId = settings.get("GITLAB_PROJECT_ID"); - if (gitlabHost.isBlank() || gitlabToken.isBlank() || projectId.isBlank()) { + if (gitlabHost == null || gitlabHost.isBlank() + || gitlabToken == null || gitlabToken.isBlank() + || projectId == null || projectId.isBlank()) { logger.error("Gitlab is not configured in the bot settings, can't execute task"); context.reply("Command is not configured, failed to create issue."); return null; diff --git a/src/main/java/me/bobert/netneutrality/tasks/help/BotInfo.java b/src/main/java/me/bobert/netneutrality/tasks/help/BotInfo.java index 8a017d1..c8ade15 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/help/BotInfo.java +++ b/src/main/java/me/bobert/netneutrality/tasks/help/BotInfo.java @@ -7,12 +7,24 @@ package me.bobert.netneutrality.tasks.help; import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.tasks.impl.AbstractBuiltInTask; +import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.trait.WithButton; -import me.bobert.netneutrality.utils.Settings; import net.dv8tion.jda.api.components.buttons.Button; +import me.bobert.netneutrality.utils.SettingsProvider; + +import java.util.Objects; public class BotInfo extends AbstractBuiltInTask implements WithButton { + private final SettingsProvider settings; + + public BotInfo(TaskManager taskManager, SettingsProvider settings) { + super(taskManager); + Objects.requireNonNull(settings, "SettingsProvider must not be null"); + this.settings = settings; + } + + @Override public String getId() { return "help-info"; @@ -26,6 +38,6 @@ public class BotInfo extends AbstractBuiltInTask implements WithButton { @Override public void executeTask(TaskContext context) { context.replyEphemeral("For information about the bot and to follow the development, " + - "you can join the discord here: " + Settings.getDiscordInvite()); + "you can join the discord here: " + settings.get("NETNEUTRALITY_BOT_INVITE", "https://discord.gg/PyCpd7JGMp")); } } diff --git a/src/main/java/me/bobert/netneutrality/tasks/help/CommandHelp.java b/src/main/java/me/bobert/netneutrality/tasks/help/CommandHelp.java index 30efd2d..da1d18b 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/help/CommandHelp.java +++ b/src/main/java/me/bobert/netneutrality/tasks/help/CommandHelp.java @@ -8,12 +8,23 @@ package me.bobert.netneutrality.tasks.help; import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.tasks.impl.AbstractBuiltInTask; import me.bobert.netneutrality.core.tasks.trait.WithButton; -import me.bobert.netneutrality.utils.MessageFunction; -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 me.bobert.netneutrality.shared.util.MessageFunction; import net.dv8tion.jda.api.components.buttons.Button; public class CommandHelp extends AbstractBuiltInTask implements WithButton { + private final SettingsProvider settings; + private final CommandRegistry commands; + + public CommandHelp(TaskManager taskManager, SettingsProvider settings, CommandRegistry commands) { + super(taskManager); + this.settings = settings; + this.commands = commands; + } + @Override public String getId() { return "help-commands"; @@ -26,7 +37,7 @@ public class CommandHelp extends AbstractBuiltInTask implements WithButton { @Override public void executeTask(TaskContext context) { - context.replyEphemeral("The prefix for the bot is " + Settings.getInstance().getPrefix()); - context.replyEphemeral(MessageFunction.buildHelpMessage()); + context.replyEphemeral("The prefix for the bot is " + settings.getPrefix()); + context.replyEphemeral(MessageFunction.buildHelpMessage(commands)); } } diff --git a/src/main/java/me/bobert/netneutrality/tasks/help/ReportBug.java b/src/main/java/me/bobert/netneutrality/tasks/help/ReportBug.java index 87cd262..fc0b44e 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/help/ReportBug.java +++ b/src/main/java/me/bobert/netneutrality/tasks/help/ReportBug.java @@ -7,12 +7,17 @@ package me.bobert.netneutrality.tasks.help; import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.tasks.impl.AbstractBuiltInTask; +import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.trait.WithButton; import me.bobert.netneutrality.utils.Settings; import net.dv8tion.jda.api.components.buttons.Button; public class ReportBug extends AbstractBuiltInTask implements WithButton { + public ReportBug(TaskManager taskManager) { + super(taskManager); + } + @Override public String getId() { return "help-reportbug"; diff --git a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java index d28a7b6..1a200f2 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java @@ -9,17 +9,15 @@ import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.tasks.Task; -import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.impl.ButtonTask; import me.bobert.netneutrality.core.tasks.trait.Deletable; import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.db.GSettings; -import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.SourceFinder; -import me.bobert.netneutrality.tasks.generic.AcceptButton; -import me.bobert.netneutrality.tasks.generic.CancelButton; -import me.bobert.netneutrality.utils.MessageFunction; +import me.bobert.netneutrality.core.tasks.generic.AcceptButton; +import me.bobert.netneutrality.core.tasks.generic.CancelButton; +import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MusicUtils; import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.Member; @@ -32,11 +30,13 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta private final List childrens = new ArrayList<>(); private final AudioPlaylist playlist; private final SourceFinder.AudioSourceType sourceType; + private final MusicManager musicManager; - public LoadPlaylistTask(AudioPlaylist playlist, SourceFinder.AudioSourceType sourceType) { - super(null, null); + public LoadPlaylistTask(AudioPlaylist playlist, SourceFinder.AudioSourceType sourceType, MusicManager musicManager) { + super(musicManager.getTaskManager(), null, null); this.playlist = playlist; this.sourceType = sourceType; + this.musicManager = musicManager; } @Override @@ -57,7 +57,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta } int searchResultsLimit = 5; // Max number of buttons per row - int maxPlaylistSizeBeforeAsking = Integer.parseInt(GuildService.get(context.getGuild()) + int maxPlaylistSizeBeforeAsking = Integer.parseInt(musicManager.getGuildService() .getGuildSettingService() .getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE)); @@ -68,7 +68,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta for (int i = 0; i < searchResults.size(); i++) { AudioTrack track = searchResults.get(i); - loadTasks.add(new LoadTrackFromPlaylistTask(this, String.valueOf(i + 1), track)); + loadTasks.add(new LoadTrackFromPlaylistTask(this, String.valueOf(i + 1), track, musicManager)); } ActionRow firstRow = createActionRow(loadTasks); @@ -88,21 +88,19 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta } // Load playlist normally - MusicManager manager = MusicManager.get(context.getGuild()); - if (MusicUtils.connect(context)) { - manager.getQueueManager().queue(tracks, context.getGuildUser()); + if (MusicUtils.connect(context, musicManager)) { + musicManager.getQueueManager().queue(tracks, context.getGuildUser()); context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), context.getGuildUser(), sourceType)); } } private ActionRow commonButtons(List tracks, String addAllText, String cancelText) { - Task accept = AcceptButton.createSuccessButton(this, addAllText, context -> { - MusicManager manager = MusicManager.get(context.getGuild()); + Task accept = AcceptButton.createSuccessButton(taskManager(), this, addAllText, context -> { Member member = context.getGuildUser(); - if (MusicUtils.connect(context)) { - manager.getQueueManager().queue(tracks, member); + if (MusicUtils.connect(context, musicManager)) { + musicManager.getQueueManager().queue(tracks, member); context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), member, SourceFinder.AudioSourceType.Youtube)); return Lifecycle.REMOVE_SELF_AND_CHILDREN; @@ -111,7 +109,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta return Lifecycle.KEEP; }).setEmoji(Emoji.fromFormatted("U+1F60E")); - Task cancel = CancelButton.create(this, cancelText, context -> Lifecycle.REMOVE_SELF_AND_CHILDREN) + Task cancel = CancelButton.create(taskManager(), this, cancelText, context -> Lifecycle.REMOVE_SELF_AND_CHILDREN) .setEmoji(Emoji.fromFormatted("U+1F92E")); return createActionRow(accept, cancel); @@ -125,7 +123,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta @Override public void onInteractionDelete() { for (Task child : getChildTasks()) { - TaskManager.getInstance().removeTask(child); + taskManager().removeTask(child); } } } diff --git a/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java b/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java index 6c04d29..28ca188 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java @@ -8,8 +8,8 @@ package me.bobert.netneutrality.tasks.music; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.SourceFinder; -import me.bobert.netneutrality.tasks.generic.AcceptButton; -import me.bobert.netneutrality.utils.MessageFunction; +import me.bobert.netneutrality.core.tasks.generic.AcceptButton; +import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MusicUtils; import net.dv8tion.jda.api.components.buttons.ButtonStyle; import net.dv8tion.jda.api.entities.Member; @@ -17,13 +17,12 @@ import net.dv8tion.jda.api.entities.Member; public class LoadTrackFromPlaylistTask extends AcceptButton { private final AudioTrack track; - public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, AudioTrack track) { - super(parentTask, ButtonStyle.PRIMARY, buttonLabel, context -> { - MusicManager manager = MusicManager.get(context.getGuild()); + public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, AudioTrack track, MusicManager musicManager) { + super(musicManager.getTaskManager(), parentTask, ButtonStyle.PRIMARY, buttonLabel, context -> { Member member = context.getGuildUser(); - if (MusicUtils.connect(context)) { - manager.getQueueManager().queue(track, member); + if (MusicUtils.connect(context, musicManager)) { + musicManager.getQueueManager().queue(track, member); context.reply(MessageFunction.buildAddingTrackMessage(track, member, SourceFinder.AudioSourceType.Youtube)); return Lifecycle.REMOVE_SELF_AND_CHILDREN; diff --git a/src/main/java/me/bobert/netneutrality/tasks/scheduled/MusicManagerCleanup.java b/src/main/java/me/bobert/netneutrality/tasks/music/MusicManagerCleanup.java similarity index 76% rename from src/main/java/me/bobert/netneutrality/tasks/scheduled/MusicManagerCleanup.java rename to src/main/java/me/bobert/netneutrality/tasks/music/MusicManagerCleanup.java index 140386d..d1c6a44 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/scheduled/MusicManagerCleanup.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/MusicManagerCleanup.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.tasks.scheduled; +package me.bobert.netneutrality.tasks.music; /* * Copyright (c) anthorob * @@ -10,8 +10,19 @@ import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.TrackScheduler; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import me.bobert.netneutrality.music.MusicService; + +import java.util.Objects; public class MusicManagerCleanup implements ScheduledTask { + + private final MusicService musicService; + + public MusicManagerCleanup(MusicService musicService) { + Objects.requireNonNull(musicService, "MusicService must not be null"); + this.musicService = musicService; + } + private static final Logger logger = LogManager.getLogger(MusicManagerCleanup.class); @Override @@ -27,7 +38,7 @@ public class MusicManagerCleanup implements ScheduledTask { @Override public void run() { try { - for (MusicManager manager : MusicManager.getManagers()) { + for (MusicManager manager : musicService.getManagers()) { processManager(manager); } } catch (InterruptedException e) { diff --git a/src/main/java/me/bobert/netneutrality/tasks/scheduled/MusicPlayerAutoDisconnect.java b/src/main/java/me/bobert/netneutrality/tasks/music/MusicPlayerAutoDisconnect.java similarity index 84% rename from src/main/java/me/bobert/netneutrality/tasks/scheduled/MusicPlayerAutoDisconnect.java rename to src/main/java/me/bobert/netneutrality/tasks/music/MusicPlayerAutoDisconnect.java index 802c195..e0656bf 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/scheduled/MusicPlayerAutoDisconnect.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/MusicPlayerAutoDisconnect.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.tasks.scheduled; +package me.bobert.netneutrality.tasks.music; /* * Copyright (c) anthorob @@ -8,14 +8,24 @@ package me.bobert.netneutrality.tasks.scheduled; import me.bobert.netneutrality.core.tasks.ScheduledTask; import me.bobert.netneutrality.music.MusicManager; +import me.bobert.netneutrality.music.MusicService; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.List; +import java.util.Objects; public class MusicPlayerAutoDisconnect implements ScheduledTask { + + private final MusicService musicService; + + public MusicPlayerAutoDisconnect(MusicService musicService) { + Objects.requireNonNull(musicService, "MusicService must not be null"); + this.musicService = musicService; + } + private static final Logger logger = LogManager.getLogger(MusicPlayerAutoDisconnect.class); @Override @@ -31,7 +41,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask { @Override public void run() { try { - for (MusicManager manager : MusicManager.getManagers()) { + for (MusicManager manager : musicService.getManagers()) { processManager(manager); } } catch (InterruptedException e) { diff --git a/src/main/java/me/bobert/netneutrality/tasks/emoji/EmojiReactionRoleTask.java b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiReactionRoleTask.java similarity index 63% rename from src/main/java/me/bobert/netneutrality/tasks/emoji/EmojiReactionRoleTask.java rename to src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiReactionRoleTask.java index a6853eb..ee7e950 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/emoji/EmojiReactionRoleTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiReactionRoleTask.java @@ -1,4 +1,4 @@ -package me.bobert.netneutrality.tasks.emoji; +package me.bobert.netneutrality.tasks.reaction; /* * Copyright (c) anthorob * @@ -7,19 +7,20 @@ package me.bobert.netneutrality.tasks.emoji; import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.tasks.impl.EmojiTask; -import me.bobert.netneutrality.utils.GuildUtils; +import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.shared.util.GuildUtils; import net.dv8tion.jda.api.entities.Role; import java.util.Map; public class EmojiReactionRoleTask extends EmojiTask { - public EmojiReactionRoleTask(String guildId, String channelId, String messageId) { - super(guildId, channelId, messageId); + public EmojiReactionRoleTask(TaskManager taskManager, String guildId, String channelId, String messageId) { + super(taskManager, guildId, channelId, messageId); } // constructor for auto loading - public EmojiReactionRoleTask(String guildId, String channelId, String messageId, Map emojiMapping) { - super(guildId, channelId, messageId); + public EmojiReactionRoleTask(TaskManager taskManager, String guildId, String channelId, String messageId, Map emojiMapping) { + super(taskManager, guildId, channelId, messageId); getEmojiMapping().putAll(emojiMapping); } diff --git a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleFirstStep.java b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleFirstStep.java index e03a5fe..aef0641 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleFirstStep.java +++ b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleFirstStep.java @@ -7,13 +7,15 @@ package me.bobert.netneutrality.tasks.reaction; */ import me.bobert.netneutrality.core.tasks.impl.AbstractStepTask; +import me.bobert.netneutrality.core.tasks.TaskManager; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Role; public class EmojiRoleFirstStep extends AbstractStepTask { private final Message target; - public EmojiRoleFirstStep(Message target) { + public EmojiRoleFirstStep(TaskManager taskManager, Message target) { + super(taskManager); this.target = target; } @@ -36,12 +38,12 @@ public class EmojiRoleFirstStep extends AbstractStepTask { @Override public String getStepQuestion() { - return "Reply to this message with the role ID you want to configure"; + return "Reply with the role you want to add to the message"; } @Override public StepResult executeStep(Object answer) { - return StepResult.next(new EmojiRoleSecondStep(target, (Role) answer)); + Role role = (Role) answer; + return StepResult.success("Role selected, please select an emoji", new EmojiRoleSecondStep(taskManager(), target, role)); } - } diff --git a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleModify.java b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleModify.java index 96776b2..30917b4 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleModify.java +++ b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleModify.java @@ -8,11 +8,10 @@ package me.bobert.netneutrality.tasks.reaction; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.tasks.Task; -import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.impl.ButtonTask; import me.bobert.netneutrality.core.tasks.trait.WithChildren; -import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask; -import me.bobert.netneutrality.tasks.generic.AcceptButton; +import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.core.tasks.generic.AcceptButton; import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.Message; @@ -25,7 +24,11 @@ public class EmojiRoleModify extends ButtonTask implements WithChildren { private final EmojiReactionRoleTask task; public EmojiRoleModify(Message target, EmojiReactionRoleTask task) { - super(null, null); + this(null, target, task); + } + + public EmojiRoleModify(TaskManager taskManager, Message target, EmojiReactionRoleTask task) { + super(taskManager, null, null); this.target = target; this.task = task; } @@ -42,28 +45,28 @@ public class EmojiRoleModify extends ButtonTask implements WithChildren { @Override public void replyToInteraction(CommandContext context) { - Task addEmojiTask = AcceptButton.createSuccessButton(this, "Add emoji role mapping", taskContext -> { + Task addEmojiTask = AcceptButton.createSuccessButton(taskManager(), this, "Add emoji role mapping", taskContext -> { if (task.getEmojiMapping().size() >= 10) { taskContext.replyEphemeral("You cannot add more than 10 emoji roles on a message"); return Lifecycle.KEEP; } - new EmojiRoleFirstStep(target).insert(context); + new EmojiRoleFirstStep(taskManager(), target).insert(context); return Lifecycle.KEEP; }); - Task removeEmojiTask = AcceptButton.createSecondaryButton(this, "Remove emoji role mapping", taskContext -> { + Task removeEmojiTask = AcceptButton.createSecondaryButton(taskManager(), this, "Remove emoji role mapping", taskContext -> { if (task.getEmojiMapping().isEmpty()) { taskContext.replyEphemeral("No emoji role mapping found for the message"); return Lifecycle.KEEP; } - new EmojiRoleRemoveStep(target, task).insert(context); + new EmojiRoleRemoveStep(taskManager(), target, task).insert(context); return Lifecycle.KEEP; }); - Task deleteMappingTask = AcceptButton.createDangerButton(this, "Unregister task", taskContext -> { - if (!TaskManager.getInstance().hasTask(task.getId())) { + Task deleteMappingTask = AcceptButton.createDangerButton(taskManager(), this, "Unregister task", taskContext -> { + if (!taskManager().hasTask(task.getId())) { taskContext.replyEphemeral("This task no longer exists"); return Lifecycle.REMOVE_SELF; } diff --git a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleRemoveStep.java b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleRemoveStep.java index 0de6bc9..b6ff7a8 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleRemoveStep.java +++ b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleRemoveStep.java @@ -7,7 +7,7 @@ package me.bobert.netneutrality.tasks.reaction; */ import me.bobert.netneutrality.core.tasks.impl.AbstractStepTask; -import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask; +import me.bobert.netneutrality.core.tasks.TaskManager; import net.dv8tion.jda.api.entities.Message; import java.util.Set; @@ -17,7 +17,8 @@ public class EmojiRoleRemoveStep extends AbstractStepTask { private final Message target; private final EmojiReactionRoleTask task; - public EmojiRoleRemoveStep(Message target, EmojiReactionRoleTask task) { + public EmojiRoleRemoveStep(TaskManager taskManager, Message target, EmojiReactionRoleTask task) { + super(taskManager); this.target = target; this.task = task; } diff --git a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleSecondStep.java b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleSecondStep.java index f305909..0d2fad0 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleSecondStep.java +++ b/src/main/java/me/bobert/netneutrality/tasks/reaction/EmojiRoleSecondStep.java @@ -7,9 +7,8 @@ package me.bobert.netneutrality.tasks.reaction; */ import me.bobert.netneutrality.core.tasks.Task; -import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.impl.AbstractStepTask; -import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask; +import me.bobert.netneutrality.core.tasks.TaskManager; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Role; @@ -19,7 +18,8 @@ public class EmojiRoleSecondStep extends AbstractStepTask { private final Message target; private final Role role; - public EmojiRoleSecondStep(Message target, Role role) { + public EmojiRoleSecondStep(TaskManager taskManager, Message target, Role role) { + super(taskManager); this.target = target; this.role = role; } @@ -45,12 +45,12 @@ public class EmojiRoleSecondStep extends AbstractStepTask { @Override public StepResult executeStep(Object answer) { String emoji = (String) answer; - Task emojiTask = new EmojiReactionRoleTask(target.getGuildId(), + Task emojiTask = new EmojiReactionRoleTask(taskManager(), target.getGuildId(), target.getChannelId(), target.getId(), Map.of(emoji, role.getId())); - if (TaskManager.getInstance().hasTask(emojiTask.getId())) { + if (taskManager().hasTask(emojiTask.getId())) { return StepResult.failure("The message ID is already registered with the bot, please use the modify mapping command instead"); } diff --git a/src/main/java/me/bobert/netneutrality/utils/ChannelMessagePurger.java b/src/main/java/me/bobert/netneutrality/utils/ChannelMessagePurger.java index 90e5d84..b7b882e 100644 --- a/src/main/java/me/bobert/netneutrality/utils/ChannelMessagePurger.java +++ b/src/main/java/me/bobert/netneutrality/utils/ChannelMessagePurger.java @@ -6,7 +6,7 @@ package me.bobert.netneutrality.utils; */ import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.core.thread.ThreadManager; +import me.bobert.netneutrality.core.thread.ExecutorProvider; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageHistory; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; @@ -24,6 +24,18 @@ import java.util.concurrent.locks.ReentrantLock; //TODO: not useful since we can't delete messages that are more than 2 weeks old public class ChannelMessagePurger { + private static volatile ExecutorProvider executors; + + /** + * Binds the executor provider used for purge jobs. Called once during bootstrap. + * + * @param provider the live executor provider + */ + public static void bind(ExecutorProvider provider) { + executors = provider; + } + + private static final Logger logger = LogManager.getLogger(ChannelMessagePurger.class); private static final Map instances = new HashMap<>(); private final GuildMessageChannel channel; @@ -48,7 +60,11 @@ public class ChannelMessagePurger { } public void purgeMessages(CommandContext context, int toPurge) { - ThreadManager.getExecutor().submit(() -> { + ExecutorProvider provider = executors; + if (provider == null) { + throw new IllegalStateException("ChannelMessagePurger has not been bound to an ExecutorProvider"); + } + provider.getExecutor().submit(() -> { if (lock.tryLock()) { try { int total = countAllChannelMessages(channel); diff --git a/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java b/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java index dec6910..589ec11 100644 --- a/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java +++ b/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java @@ -1,15 +1,16 @@ package me.bobert.netneutrality.utils; + +import me.bobert.netneutrality.music.MusicManager; +import me.bobert.netneutrality.context.MusicChannelContext; /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.context.MusicChannelContext; import me.bobert.netneutrality.context.command.SlashCommandContext; import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.exceptions.ContextException; -import me.bobert.netneutrality.music.MusicManager; import net.dv8tion.jda.api.entities.GuildVoiceState; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; @@ -17,12 +18,11 @@ import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; public class MusicUtils { - public static boolean connect(Context context) { - return connect(context, false); + public static boolean connect(Context context, MusicManager manager) { + return connect(context, manager, false); } - public static boolean connect(Context context, boolean checkOnly) { - MusicManager manager = MusicManager.get(context.getGuild()); + public static boolean connect(Context context, MusicManager manager, boolean checkOnly) { Member member = context.getGuildUser(); if (member == null) { context.reply("Context doesn't have a Guild user, can't continue"); @@ -35,7 +35,7 @@ public class MusicUtils { return false; } - if (!isUserDj(member) && (manager.isConnected() && manager.isNotConnectedTo(audioChannel))) { + if (!isUserDj(member, manager) && (manager.isConnected() && manager.isNotConnectedTo(audioChannel))) { context.reply("The bot isn't connected to your voice channel"); return false; } @@ -48,13 +48,11 @@ public class MusicUtils { return true; } - public static boolean canConnect(Context context) { - return connect(context, true); + public static boolean canConnect(Context context, MusicManager manager) { + return connect(context, manager, true); } - public static boolean checkIfConnected(Context context) { - MusicManager manager = MusicManager.get(context.getGuild()); - + public static boolean checkIfConnected(Context context, MusicManager manager) { AudioChannel voiceChannel = getVoiceChannel(context); if (voiceChannel == null) { context.reply("You are not in a voice channel"); @@ -89,16 +87,15 @@ public class MusicUtils { return memberVoiceState.getChannel(); } - public static boolean isUserDj(Context context) { + public static boolean isUserDj(Context context, MusicManager manager) { if (context instanceof SlashCommandContext) { return true; } - return isUserDj(context.getGuildUser()); + return isUserDj(context.getGuildUser(), manager); } - public static boolean isUserDj(Member member) { - MusicManager manager = MusicManager.get(member.getGuild()); + public static boolean isUserDj(Member member, MusicManager manager) { Member dj = manager.getScheduler().getDj(); if (manager.isCurrentlyPlaying() && dj != null) { diff --git a/src/main/java/me/bobert/netneutrality/utils/ReflectionHelper.java b/src/main/java/me/bobert/netneutrality/utils/ReflectionHelper.java deleted file mode 100644 index 24e7d5f..0000000 --- a/src/main/java/me/bobert/netneutrality/utils/ReflectionHelper.java +++ /dev/null @@ -1,55 +0,0 @@ -package me.bobert.netneutrality.utils; - -/* - * 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.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, Exception> DEFAULT_FAILURE = (aClass, e) -> logger.error("Failed to load class: {}", aClass.getName(), e); - - public static void loadClassesFromPackage(String packageName, Class clazz, Consumer onInstanciated, BiConsumer, Exception> onFailed) { - Set> classes = new Reflections(packageName).getSubTypesOf(clazz); - for (Class s : classes) { - try { - if (Modifier.isAbstract(s.getModifiers())) { - continue; - } - - E instance = s.getConstructor().newInstance(); - onInstanciated.accept(instance); - } catch (InstantiationException | IllegalAccessException | IllegalArgumentException - | InvocationTargetException | NoSuchMethodException | SecurityException e) { - onFailed.accept(s, e); - } - } - } - - public static void loadCommands(Class clazz, Consumer onInstanciated, BiConsumer, Exception> onFailed) { - loadClassesFromPackage("me.bobert.netneutrality.command", clazz, onInstanciated, onFailed); - } - - public static void loadCommands(Class clazz, Consumer onInstanciated) { - loadClassesFromPackage("me.bobert.netneutrality.command", clazz, onInstanciated, DEFAULT_FAILURE); - } - - public static void loadTasks(Class clazz, Consumer onInstanciated, BiConsumer, Exception> onFailed) { - loadClassesFromPackage("me.bobert.netneutrality.tasks", clazz, onInstanciated, onFailed); - } - - public static void loadTasks(Class clazz, Consumer onInstanciated) { - loadClassesFromPackage("me.bobert.netneutrality.tasks", clazz, onInstanciated, DEFAULT_FAILURE); - } -} diff --git a/src/main/java/me/bobert/netneutrality/utils/Settings.java b/src/main/java/me/bobert/netneutrality/utils/Settings.java index ec0c689..3d2595b 100644 --- a/src/main/java/me/bobert/netneutrality/utils/Settings.java +++ b/src/main/java/me/bobert/netneutrality/utils/Settings.java @@ -18,7 +18,14 @@ import java.util.Map; import java.util.Properties; import java.util.concurrent.TimeUnit; -public class Settings { +/** + * Process-wide configuration loaded from {@code bot.properties} and environment variables. + * + *

{@link #getInstance()} is retained intentionally as the early-bootstrap access point: Sentry, + * env and the bot token must be readable before the {@code ApplicationContext} exists. Once the + * context is built, prefer injecting {@link SettingsProvider}. + */ +public class Settings implements SettingsProvider { private static Settings INSTANCE; private final File propFile = new File("bot.properties"); @@ -46,6 +53,11 @@ public class Settings { } } + /** + * Early-bootstrap accessor used before the application context exists. + * + * @return the process-wide settings instance + */ public static Settings getInstance() { if (INSTANCE == null) { INSTANCE = new Settings(); @@ -54,14 +66,25 @@ public class Settings { return INSTANCE; } + /** + * Bootstrap helper for the public Discord invite URL. + * + * @return the invite URL + */ public static String getDiscordInvite() { return Settings.getInstance().get("NETNEUTRALITY_BOT_INVITE", "https://discord.gg/PyCpd7JGMp"); } + /** + * Bootstrap helper for the bot version string. + * + * @return the version + */ public static String getVersion() { return Settings.getInstance().getMavenVersion(); } + @Override public String get(String key) { String property = prop.getProperty(key); @@ -72,6 +95,7 @@ public class Settings { return property; } + @Override public String get(String key, String def) { String property = get(key); @@ -82,6 +106,7 @@ public class Settings { return property; } + @Override public String getPrefix() { return get("NETNEUTRALITY_BOT_PREFIX", "$"); } diff --git a/src/main/java/me/bobert/netneutrality/utils/SettingsProvider.java b/src/main/java/me/bobert/netneutrality/utils/SettingsProvider.java new file mode 100644 index 0000000..ffef644 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/utils/SettingsProvider.java @@ -0,0 +1,41 @@ +package me.bobert.netneutrality.utils; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +/** + * Read-only access to the bot configuration (properties file + environment variables). + * + *

Introduced so configuration can be constructor-injected and mocked in tests instead of being + * reached through a static singleton. + */ +public interface SettingsProvider { + + /** + * Returns the value for the given key, or {@code null} if it is not configured. + * + * @param key the property key + * @return the configured value, or {@code null} + */ + String get(String key); + + /** + * Returns the value for the given key, falling back to {@code def} when the key is missing or + * empty. + * + * @param key the property key + * @param def the default value + * @return the configured value, or {@code def} + */ + String get(String key, String def); + + /** + * Returns the message command prefix. + * + * @return the configured prefix + */ + String getPrefix(); +} diff --git a/src/main/resources/META-INF/MANIFEST.MF b/src/main/resources/META-INF/MANIFEST.MF index 9250482..b7096d5 100644 --- a/src/main/resources/META-INF/MANIFEST.MF +++ b/src/main/resources/META-INF/MANIFEST.MF @@ -1,2 +1,2 @@ Manifest-Version: 1.0 -Main-Class: me.bobert.netneutrality.NetNeutrality +Main-Class: me.bobert.netneutrality.App diff --git a/src/main/resources/persistable-tasks.json b/src/main/resources/persistable-tasks.json index 556499f..e132f48 100644 --- a/src/main/resources/persistable-tasks.json +++ b/src/main/resources/persistable-tasks.json @@ -1,7 +1,7 @@ { "tasks": [ { - "class": "me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask", + "class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask", "params": [ "guildId", "channelId", diff --git a/src/test/java/me/bobert/netneutrality/bootstrap/ApplicationContextTest.java b/src/test/java/me/bobert/netneutrality/bootstrap/ApplicationContextTest.java new file mode 100644 index 0000000..63a6e39 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/bootstrap/ApplicationContextTest.java @@ -0,0 +1,89 @@ +package me.bobert.netneutrality.bootstrap; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ApplicationContextTest { + + private static final class RecordingComponent implements Lifecycle { + private final String name; + private final int priority; + private final List startLog; + private final List stopLog; + + private RecordingComponent(String name, int priority, List startLog, List stopLog) { + this.name = name; + this.priority = priority; + this.startLog = startLog; + this.stopLog = stopLog; + } + + @Override + public void start() { + startLog.add(name); + } + + @Override + public void stop() { + stopLog.add(name); + } + + @Override + public int priority() { + return priority; + } + } + + @Test + public void startsAscendingAndStopsDescending() throws Exception { + List startLog = new ArrayList<>(); + List stopLog = new ArrayList<>(); + + ApplicationContext context = new ApplicationContext(); + // Register out of order to prove sorting by priority, not by registration order. + context.register(new RecordingComponent("b", 20, startLog, stopLog)); + context.register(new RecordingComponent("c", 30, startLog, stopLog)); + context.register(new RecordingComponent("a", 10, startLog, stopLog)); + + context.start(); + context.stop(); + + assertEquals(List.of("a", "b", "c"), startLog, "start should run in ascending priority order"); + assertEquals(List.of("c", "b", "a"), stopLog, "stop should run in descending priority order"); + } + + @Test + public void nonLifecycleComponentsAreResolvableButNotStarted() throws Exception { + List startLog = new ArrayList<>(); + List stopLog = new ArrayList<>(); + + ApplicationContext context = new ApplicationContext(); + String plain = context.register("plain-service"); + context.register(new RecordingComponent("lifecycle", 5, startLog, stopLog)); + + context.start(); + context.stop(); + + assertEquals(plain, context.get(String.class)); + assertEquals(List.of("lifecycle"), startLog); + assertEquals(List.of("lifecycle"), stopLog); + } + + @Test + public void getReturnsNullWhenNothingRegistered() { + ApplicationContext context = new ApplicationContext(); + + assertNull(context.get(Integer.class)); + } +} diff --git a/src/test/java/me/bobert/netneutrality/bootstrap/ReflectionInjectionTest.java b/src/test/java/me/bobert/netneutrality/bootstrap/ReflectionInjectionTest.java new file mode 100644 index 0000000..eced4b3 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/bootstrap/ReflectionInjectionTest.java @@ -0,0 +1,158 @@ +package me.bobert.netneutrality.bootstrap; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Proves context-based constructor injection in {@link ReflectionHelper} and the no-arg fallback. + */ +public class ReflectionInjectionTest { + + /** Marker for package-scanned fixtures. */ + public interface Fixture { + } + + /** Dependency registered in the test context. */ + public static final class SampleDependency { + private final String label; + + public SampleDependency(String label) { + this.label = label; + } + + public String label() { + return label; + } + } + + /** Fixture that requires a registered dependency via constructor injection. */ + public static final class InjectedFixture implements Fixture { + private final SampleDependency dependency; + + public InjectedFixture(SampleDependency dependency) { + this.dependency = dependency; + } + + public SampleDependency dependency() { + return dependency; + } + } + + /** Fixture that only exposes a no-arg constructor (fallback path). */ + public static final class NoArgFixture implements Fixture { + public NoArgFixture() { + } + } + + /** + * Fixture with both a no-arg constructor and a multi-arg injectable constructor; the loader + * must prefer the richer resolvable constructor. + */ + public static final class PreferInjectedFixture implements Fixture { + private final SampleDependency dependency; + private final boolean injected; + + public PreferInjectedFixture() { + this.dependency = null; + this.injected = false; + } + + public PreferInjectedFixture(SampleDependency dependency) { + this.dependency = dependency; + this.injected = true; + } + + public boolean wasInjected() { + return injected; + } + + public SampleDependency dependency() { + return dependency; + } + } + + @Test + public void injectsRegisteredDependencyViaRichestConstructor() throws Exception { + ApplicationContext context = new ApplicationContext(); + SampleDependency dependency = context.register(new SampleDependency("from-context")); + + InjectedFixture instance = ReflectionHelper.instantiate(InjectedFixture.class, context); + + assertNotNull(instance); + assertSame(dependency, instance.dependency()); + assertEquals("from-context", instance.dependency().label()); + } + + @Test + public void fallsBackToNoArgConstructorWhenNothingToInject() throws Exception { + ApplicationContext context = new ApplicationContext(); + + NoArgFixture instance = ReflectionHelper.instantiate(NoArgFixture.class, context); + + assertNotNull(instance); + } + + @Test + public void prefersInjectableConstructorOverNoArgWhenBothExist() throws Exception { + ApplicationContext context = new ApplicationContext(); + SampleDependency dependency = context.register(new SampleDependency("preferred")); + + PreferInjectedFixture instance = ReflectionHelper.instantiate(PreferInjectedFixture.class, context); + + assertTrue(instance.wasInjected(), "richest resolvable constructor should win over no-arg"); + assertSame(dependency, instance.dependency()); + } + + @Test + public void packageLoaderInjectsAndFallsBack() { + ApplicationContext context = new ApplicationContext(); + SampleDependency dependency = context.register(new SampleDependency("package-scan")); + + List loaded = new ArrayList<>(); + List> failed = new ArrayList<>(); + + ReflectionHelper.loadClassesFromPackage( + "me.bobert.netneutrality.bootstrap", + Fixture.class, + context, + loaded::add, + (type, error) -> failed.add(type) + ); + + assertTrue(failed.isEmpty(), "fixture classes should instantiate without error: " + failed); + assertTrue(loaded.stream().anyMatch(NoArgFixture.class::isInstance), + "no-arg fixture should still load"); + assertTrue(loaded.stream().anyMatch(InjectedFixture.class::isInstance), + "injected fixture should load"); + assertTrue(loaded.stream().anyMatch(PreferInjectedFixture.class::isInstance), + "prefer-injected fixture should load"); + + InjectedFixture injected = loaded.stream() + .filter(InjectedFixture.class::isInstance) + .map(InjectedFixture.class::cast) + .findFirst() + .orElseThrow(); + assertSame(dependency, injected.dependency()); + + PreferInjectedFixture preferred = loaded.stream() + .filter(PreferInjectedFixture.class::isInstance) + .map(PreferInjectedFixture.class::cast) + .findFirst() + .orElseThrow(); + assertTrue(preferred.wasInjected()); + assertSame(dependency, preferred.dependency()); + } +} diff --git a/src/test/java/me/bobert/netneutrality/command/git/ReportIssueCommandTest.java b/src/test/java/me/bobert/netneutrality/feature/git/command/ReportIssueCommandTest.java similarity index 100% rename from src/test/java/me/bobert/netneutrality/command/git/ReportIssueCommandTest.java rename to src/test/java/me/bobert/netneutrality/feature/git/command/ReportIssueCommandTest.java diff --git a/src/test/java/me/bobert/netneutrality/music/source/UdioAudioSourceManagerTest.java b/src/test/java/me/bobert/netneutrality/feature/music/source/UdioAudioSourceManagerTest.java similarity index 100% rename from src/test/java/me/bobert/netneutrality/music/source/UdioAudioSourceManagerTest.java rename to src/test/java/me/bobert/netneutrality/feature/music/source/UdioAudioSourceManagerTest.java diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java b/src/test/java/me/bobert/netneutrality/framework/command/CommandProcessorTest.java similarity index 98% rename from src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java rename to src/test/java/me/bobert/netneutrality/framework/command/CommandProcessorTest.java index 1aa7b55..e4caaa4 100644 --- a/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java +++ b/src/test/java/me/bobert/netneutrality/framework/command/CommandProcessorTest.java @@ -11,6 +11,8 @@ 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.Settings; +import me.bobert.netneutrality.core.tasks.TaskManagerImpl; +import me.bobert.netneutrality.core.tasks.TaskManager; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; @@ -24,6 +26,7 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; import net.dv8tion.jda.api.utils.messages.MessageCreateData; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import me.bobert.netneutrality.bootstrap.ApplicationContext; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -108,7 +111,7 @@ class CommandProcessorTest { } // ===== Test Helper Classes ===== - private static class CommandTester { + private class CommandTester { private final MessageCommandContext ctx; private CommandContext executedContext; private final ArgumentCaptor replyCaptor; @@ -119,7 +122,7 @@ class CommandProcessorTest { } CommandTester execute() { - CommandProcessor processor = new CommandProcessor(ctx); + CommandProcessor processor = new CommandProcessor(ctx, commandManager, taskManager); TestCommand.onExecute = context -> this.executedContext = context; processor.dispatch(); return this; @@ -227,6 +230,8 @@ class CommandProcessorTest { @Mock private Settings settings; + private CommandManager commandManager; + private TaskManager taskManager; private MockedStatic settingsMockedStatic; @@ -235,7 +240,7 @@ class CommandProcessorTest { TestCommand.onExecute = ctx -> {}; // Reset executor Command command = new TestCommand(); CommandContainer container = new CommandContainer(command); - CommandManager.commands.put(command.getCommandName(), container); + commandManager.getCommands().put(command.getCommandName(), container); } private MessageCommandContext createMessageContext(String messageContent) { @@ -287,25 +292,25 @@ class CommandProcessorTest { } } - return spy(new MessageCommandContext(event)); + return spy(new MessageCommandContext(event, settings)); } @BeforeEach void setup() { - // Mock Settings to return "$" as prefix + // Mock Settings to return "$" as prefix (still used by message-context prefix checks) settingsMockedStatic = mockStatic(Settings.class); settingsMockedStatic.when(Settings::getInstance).thenReturn(settings); when(settings.getPrefix()).thenReturn("$"); - CommandManager.commands.clear(); + commandManager = new CommandManager(mock(ApplicationContext.class)); + taskManager = mock(TaskManager.class); } @AfterEach void teardown() { - CommandManager.commands.clear(); - if (settingsMockedStatic != null) { settingsMockedStatic.close(); + settingsMockedStatic = null; } } diff --git a/src/test/java/me/bobert/netneutrality/utils/SettingsTest.java b/src/test/java/me/bobert/netneutrality/platform/config/SettingsTest.java similarity index 100% rename from src/test/java/me/bobert/netneutrality/utils/SettingsTest.java rename to src/test/java/me/bobert/netneutrality/platform/config/SettingsTest.java