Remove deprecated classes and static singleton-based architecture

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

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# Net Neutrality — Technical Documentation
This directory contains detailed technical documentation for the **Net Neutrality** Discord bot
(`me.bobert:netneutrality`, version `3.0.0`).
It is intended for developers working on the codebase. It documents every major system and
subsystem, how they fit together, and the end-to-end flows that tie them.
## Table of contents
| Document | Subsystem |
|---|---|
| [architecture.md](architecture.md) | High-level architecture, startup/shutdown lifecycle, tech stack |
| [events.md](events.md) | Event handling & routing (`EventsHandler`) |
| [contexts.md](contexts.md) | Contexts — the abstraction over Discord events |
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
| [music.md](music.md) | Music — Lavaplayer pipeline, queue, custom Udio source |
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
| [threading.md](threading.md) | Threading & scheduling infrastructure |
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
## How to read this documentation
Start with [architecture.md](architecture.md) for the big picture and the startup sequence.
Then read [events.md](events.md) and [contexts.md](contexts.md), because almost everything else
is triggered through a **Context** produced by the **EventsHandler**. From there, the two primary
execution paths are [commands.md](commands.md) and [tasks.md](tasks.md).
## Conventions
- Class names are given fully qualified on first mention, then by simple name.
- Database entity classes are prefixed with `O` (e.g. `OGuild`), and are documented in
[persistence.md](persistence.md).
- Environment variables / configuration keys use the `NETNEUTRALITY_*` and `MYSQL_*` prefixes and
are documented in [utilities-and-bootstrap.md](utilities-and-bootstrap.md).
+161
View File
@@ -0,0 +1,161 @@
# Architecture Overview
Net Neutrality is a Java 25 Discord bot built on top of [JDA](https://github.com/discord-jda/JDA).
It is packaged as a single fat JAR (via the Maven Shade plugin) whose entry point is
`me.bobert.netneutrality.App`.
## Big picture
Discord sends **events** over the gateway. A single JDA `EventListener`
(`me.bobert.netneutrality.hooks.EventsHandler`) receives every event and turns it into a
**Context** — a high-level abstraction over that event. Each context is then routed to one of three
execution paths:
```
┌──────────────────────────────┐
Discord Gateway ───────▶│ EventsHandler │
(JDA events) │ event class → Context factory│
└───────────────┬──────────────┘
│ builds a Context
┌─────────────────────────┼───────────────────────────┐
▼ ▼ ▼
CommandContext TaskContext (no Context)
│ │ lambda /
▼ ▼ consumer runs
CommandProcessor TaskManager.handle() directly
.dispatch() (lookup task by id) (e.g. GuildJoin)
│ │
▼ ▼
Command.execute() Task.execute()
```
1. **Command flow** — the context is a `CommandContext`; it is handed to `CommandProcessor` which
resolves and runs a `Command`. See [commands.md](commands.md).
2. **Task flow** — the context is a `TaskContext`; it is handed to `TaskManager` which looks up a
`Task` by its **id** and runs it. See [tasks.md](tasks.md).
3. **Lambda flow** — for simple events (e.g. `GuildJoinEvent`, `GuildLeaveEvent`) a consumer runs
directly and no context is routed onward. See [events.md](events.md).
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
[music](music.md) (Lavaplayer), the [web API](web-api.md), and the [threading/scheduling](threading.md)
infrastructure.
## Technology stack
| Concern | Library | Version |
|---|---|---|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
| Voice / DAVE (E2EE) | `club.minnced:jdave-*` | 0.1.8 |
| Audio engine | `dev.arbjerg:lavaplayer` | 2.2.6 |
| YouTube source | `dev.lavalink.youtube:v2` | 1.18.2 |
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
| DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 |
| Error tracking | `io.sentry:sentry` | 8.41.0 |
| GitLab integration | `org.gitlab4j:gitlab4j-api` | 6.2.0 |
| HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 |
| JSON | `org.json:json` + `com.google.code.gson:gson` | — |
| Classpath scanning | `org.reflections:reflections` | 0.10.2 |
| Emoji parsing | `net.fellbaum:jemoji` | 1.7.6 |
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
| Testing | JUnit Jupiter 6 + Mockito 5 | — |
The HTTP **server** for the internal API uses the JDK built-in
`com.sun.net.httpserver.HttpServer` (no external framework); see [web-api.md](web-api.md).
## Startup lifecycle
The entry point is `App.main()` followed by the `App()` constructor which calls
`startup()`. Bootstrap is driven by a hand-rolled
[`ApplicationContext`](utilities-and-bootstrap.md#applicationcontext--lifecycle) that owns wired
instances and starts every [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle)
component in ascending priority order.
1. **Print banner / read env** — reads `NETNEUTRALITY_BOT_ENV` (default `dev`) via the early-bootstrap
`Settings.getInstance()` singleton (the only remaining process-wide settings access point; it is
also registered into the context as `SettingsProvider` for constructor injection).
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
(environment, release = `VERSION`, traces sample rate 1.0).
3. **Log levels** — in `dev`, raise JDA / Lavalink-YouTube / sedmelluq logging to `DEBUG`.
4. `startup()`:
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
3. `context.start()` — start every `Lifecycle` component in ascending priority order.
4. Register a JVM shutdown hook calling `shutdown()` → `context.stop()`.
### Lifecycle priorities (startup order)
| Priority | Component | What `start()` does |
|---|---|---|
| 10 | `SessionManager` (`SessionProvider`) | Build Hibernate `SessionFactory` + run Flyway migrations. Binds itself into `JpaUtils`. |
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
| 40 | `MusicServiceImpl` (`MusicService`) | Initialise the shared Lavaplayer `AudioPlayerManager`. |
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
| 70 | `WebApi` | Start the internal HTTP server (`NETNEUTRALITY_API_PORT`, default 9000). |
Commands and tasks that need shared services declare them as constructor parameters
(`MusicService`, `TaskManager`, `GuildServiceProvider`, `SettingsProvider`, `CommandRegistry`, …).
`ReflectionHelper` picks the richest public constructor whose parameter types all resolve from the
context (falling back to a no-arg constructor when none match).
### Gateway intents
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
`MESSAGE_CONTENT`. It also installs the DAVE (end-to-end encrypted voice) session factory via
`AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())`.
## Shutdown lifecycle
`App.shutdown()` (invoked by the JVM shutdown hook, `ShutdownCommand`, or the HTTP
`/actions/shutdown` endpoint) calls `context.stop()`, which stops every registered `Lifecycle`
component in the **exact reverse** of start order. Failures in one component are logged and do not
block the others. Effectively this means:
1. `WebApi.stop()` — stop the HTTP server and its executor.
2. Post-JDA adapter — no-op.
3. JDA adapter — `jda.shutdown()`.
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
5. `MusicServiceImpl.stop()` — disconnect players / leave voice channels.
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
7. `ThreadManager.stop()` — shut down the shared executors.
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
## Configuration & build
- **Config sources** — `Settings` loads `bot.properties` and overlays environment variables (env
vars win). See [utilities-and-bootstrap.md](utilities-and-bootstrap.md#settings). An example file
lives at `bot.properties.example`.
- **Build** — `./mvnw package` produces `target/netneutrality.jar` (Shade fat JAR, main class
`me.bobert.netneutrality.App`). Checkstyle (`checkstyle.xml`) runs in the `test` phase
and fails the build on violations.
- **Run** — the preferred method is Docker Compose (`docker compose build && docker compose up -d`),
which also provisions the MariaDB database. See `README.md` and `docker-compose.yml`.
## Package map
| Package | Contents |
|---|---|
| `me.bobert.netneutrality` | Entry point (`App`) |
| `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` |
| `…core.command` | Command engine (registry, processor, manager, options, impl bases) |
| `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) |
| `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) |
| `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) |
| `…core` | `LogHandler`, `NetNeutralityBot` |
| `…command.*` | Concrete command implementations (`admin`, `basic`, `bot`, `crypto`, `custom`, `git`, `music`, `react`) |
| `…context.command` / `…context.tasks` | Concrete context implementations (slash, message, button, modal, emoji, etc.) |
| `…context` | `MusicChannelContext` |
| `…tasks.*` | Concrete task implementations (`git`, `help`, `music`, `reaction`) |
| `…db` | Session/migration, `Repository`, `JpaUtils`, `GSettings` |
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
| `…hooks` | `EventsHandler` |
| `…http` | Internal web API and HTTP client module |
| `…music` | Music domain (service, manager, queue, sources, tracks) |
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
| `…shared.util` | Cross-cutting helpers |
| `…exceptions` | Custom exception hierarchy |
+169
View File
@@ -0,0 +1,169 @@
# Commands
**Packages:** `me.bobert.netneutrality.framework.command` (framework),
`me.bobert.netneutrality.feature.*.command` (concrete commands)
A **Command** is what a user executes on Discord. It receives a
[`CommandContext`](contexts.md#commandcontext) and runs logic based on it. There are two primary
kinds:
- **Slash commands** — first-class Discord application commands. Their structure (options,
subcommands, groups) has rules defined by Discord and must be configured **procedurally** via
`CommandOptionsBuilder`.
- **Message (prefix) commands** — a plain message beginning with the configured prefix
(`NETNEUTRALITY_BOT_PREFIX`, default `$`).
A core goal of the framework is that **both kinds are authored the same way**: a command declares its
metadata and options once, and the framework adapts it to either interaction style. Most of that
adaptation logic lives in `CommandProcessor`.
## The `Command` contract
`Command` (interface) defines:
- Metadata — `getCommandName()`, `getDescription()`, `getCategory()`
([`CommandCategory`](#commandcategory)).
- Type — `getType()` (`SLASH` by default).
- Permissions — `needAdmin()`, `canBeExecutedBy(...)`, guild-only / slash-only constraints.
- Option declaration — `registerCommandOptions(CommandOptionsBuilder)` (procedural option setup).
- Execution — `execute(CommandContext) : CommandExecutionResult`.
### Base implementations (`core.command.impl`)
| Class | Adds |
|---|---|
| `AbstractCommand` | Default `Command` implementation; type defaults to `SLASH`; basic permission logic. |
| `AdminCommand` | Sets `needAdmin() = true` and category `ADMINISTRATION`. |
| `MusicCommand` | Guild-only; verifies the guild's music role and music channel before execution. |
| `MessageMenuCommand` | Discord **message** context-menu command; skips option registration; wraps the target `Message`. |
| `UserMenuCommand` | Discord **user** context-menu command; skips option registration; wraps the target `User`. |
## Unified options
To let one command definition serve both slash and message input, values are read through a wrapper
interface:
- `CommandOption` (interface) — uniform accessors: `getAsString()`, `getAsInt()`, `getAsMember()`,
`getAsBoolean()`, … .
- `SlashCommandOption` — wraps JDA's `OptionMapping` (Discord already parsed the value).
- `MessageCommandOption` — wraps a value parsed manually from the message text.
### Procedural option configuration — `CommandOptionsBuilder`
`CommandOptionsBuilder` implements JDA's `SlashCommandData` and acts as a proxy over JDA's internal
`CommandDataImpl`. A command builds its argument surface in `registerCommandOptions(builder)` by
adding options, subcommands, and subcommand groups. The same declaration is used both to:
1. register the slash command with Discord (see [registration](#registration-lifecycle)), and
2. drive the message-command parser (which tries to match tokens in the message against the declared
`OptionData`).
## Command management
### `CommandManager`
Responsible for **discovery** and **registration**.
- **Discovery** — at `init()`, uses [`ReflectionHelper`](utilities-and-bootstrap.md#reflectionhelper)
to scan for `Command` implementations and instantiate them.
- **Storage** — commands are kept in a `ConcurrentHashMap<String, CommandContainer>` keyed by command
name.
- **Slash registration** — `registerSlashCommands(JDA)` converts each command's declaration into JDA
`SlashCommandData` and pushes it to Discord's API.
### `CommandContainer`
Wraps a `Command` instance together with its JDA metadata (`CommandDataImpl`). It also builds the
"usage" string shown by the help command.
### `CommandCategory`
Enum grouping commands for help/display purposes (e.g. `ADMINISTRATION`, `UTILITY`, `MUSIC`,
`CRYPTO`, and an unlisted/hidden group for owner/debug commands).
### `CommandExecutionResult`
The value returned by `execute(...)`. It encodes the outcome and any response payload:
- `success(...)` — normal success (optionally with a message/embed to send).
- `error(...)` / `warning(...)` — failure/advisory outcomes with a message.
- `none()` — nothing to send synchronously (the command handles its own async response, e.g. music).
- May also carry a follow-up **`Task`** so a command can install an interactive
[task](tasks.md) (e.g. buttons) as part of its result.
## The dispatch flow — `CommandProcessor`
`CommandProcessor` is constructed with the `CommandContext` and driven by `dispatch()`:
1. **Resolution** — match the invoked name against a `CommandContainer` in `CommandManager`.
2. **Validation**
- command enabled?
- guild-only / slash-only constraints satisfied?
- **permissions** — `canBeExecutedBy(...)` and, for admin commands, `needAdmin()` verified against
the bot owner (`NETNEUTRALITY_BOT_OWNER`) and the member's guild-admin status.
3. **Usage parsing (`checkUsage`)** — detect whether a subcommand or subcommand group is being
invoked and record the active subcommand on the context.
4. **Option parsing**
- **Slash** — Discord already parsed the arguments; map each `OptionMapping` to a
`SlashCommandOption`.
- **Message** — `MessageCommandContext` tokenizes the message and matches tokens against the
declared `OptionData` (resolving mentions to members/roles, parsing integers/booleans, etc.).
5. **Execution** — call `command.execute(context)`.
6. **Result handling** — act on the returned `CommandExecutionResult`: send success/error/warning
responses, or, if a `Task` was returned, register it with the [`TaskManager`](tasks.md).
Errors are surfaced through the exception hierarchy
([`CommandExecutionException`](utilities-and-bootstrap.md#exceptions), and
`GracefulContextError` for user-facing, non-logged failures).
## Registration lifecycle
```
App.startup()
├─ CommandManager.init() // reflective discovery → ConcurrentHashMap
└─ CommandManager.registerSlashCommands(jda) // push SlashCommandData to Discord
```
At runtime, an incoming command event becomes a `CommandContext` in the
[`EventsHandler`](events.md), which calls `new CommandProcessor(context).dispatch()`.
## Command catalog
Concrete commands live under `me.bobert.netneutrality.feature.*.command`, grouped by feature package:
| Command | Package | Type | Description |
|---|---|---|---|
| `BlacklistCommand` | `admin` | Slash | Manage the server's word blacklist. |
| `ReactionCommand` | `admin` | Slash | Manage emoji-reaction roles on the server. |
| `HelpCommand` | `basic` | Slash | List every command with its usage. |
| `PingCommand` | `basic` | Slash | Reply with the gateway ping. |
| `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). |
| `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. |
| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the GitLab report flow). |
| `PlayCommand` | `music` | Slash | Search/queue and start playback. |
| `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. |
| `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. |
| `ClearQueueCommand` / `ShuffleCommand` | `music` | Slash | Clear / shuffle the queue. |
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
### Representative commands
**`PingCommand` (simplest slash command)** — extends `AbstractCommand`, overrides `execute` to return
`CommandExecutionResult.success(...)` containing the JDA gateway ping. It illustrates the minimal
execution path (no options, synchronous response).
**`ConfigureCommand` (complex slash command)** — uses `registerCommandOptions` to declare several
`SubcommandData` (e.g. music-volume, error-logging). In `execute`, it switches on
`context.getSubcommand()` and reads typed values via `context.getOption(name, resolver)`, then
persists them through the [guild settings service](persistence.md#service-layer).
**`PlayCommand` (music)** — extends `MusicCommand` (so it enforces guild-only + music role/channel).
It declares a single required `STRING` query option, delegates loading to
[`MusicManager`](music.md), and returns `CommandExecutionResult.none()` because the audio subsystem
produces the user-facing response asynchronously (often installing a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with choice buttons).
+154
View File
@@ -0,0 +1,154 @@
# Contexts
**Packages:** `me.bobert.netneutrality.framework.context` (framework),
`me.bobert.netneutrality.framework.context` (concrete implementations)
A **Context** is a high-level abstraction over a Discord event. It hides the differences between the
many JDA event types (slash command, message, button, modal, reaction, …) behind a single, uniform
API for reading who/where the event came from and for replying to it.
Contexts are created either by the [`EventsHandler`](events.md) when Discord triggers an event, or by
another context further down a flow (for example, a reply produces an [`AsyncContext`](#asynccontext)
that can be edited later). A context can be handled by one of three flows depending on its type and
the event it wraps: the [command processor](commands.md), the [task manager](tasks.md), or a simple
lambda. **The id of a context only matters for tasks** — the task manager uses `getId()` to locate
the `Task` that should run (see [tasks.md](tasks.md)).
## Type hierarchy
```
Context (interface)
├── InteractionContext (interface) ── reply/defer/ack via IReplyCallback + InteractionHook
├── NoMessageContext (interface) ── defaults for events without a triggering message
│ └── MessageContext (interface) ── adds a triggering Message (prefix commands, replies)
│
└── AbstractContext (abstract, implements Context) ── stores JDA/Guild/Channel/Member/User
├── CommandContext (abstract) ── command flow; holds CommandOptions + subcommand metadata
│ ├── SlashCommandContext
│ ├── MessageCommandContext
│ ├── MessageMenuContext
│ └── UserMenuContext
├── TaskContext (abstract) ── task flow; abstract getId(); contextData state map
│ ├── ButtonTaskContext
│ ├── EmojiTaskContext
│ ├── MessageTaskContext
│ └── ModalTaskContext
├── AsyncContext ── wraps the result of a previous reply (edit/follow-up)
└── MusicChannelContext ── synthetic context targeting a guild's music channel
```
## Core framework types
### `Context` (interface)
The root contract for every context. Notable methods:
- `getJDA()`, `getGuild()`, `getChannel()`, `getGuildUser()` (`Member`), `getDiscordUser()` (`User`)
- `reply(...)` — unified send; overloads support plain text, embeds, message components, an
`ephemeral` flag, and an async callback that receives an [`AsyncContext`](#asynccontext) for
follow-up edits.
- `defer(ephemeral, consumer)` — signals "thinking…" and later completes the response.
- `ack(ephemeral)` — minimal acknowledgement of the event.
- `getInteractionHook()` — the JDA `InteractionHook` when the underlying event is an interaction.
### `AbstractContext` (abstract class)
Base implementation that stores the common JDA entities (`JDA`, `Guild`, `MessageChannel`, `Member`,
`User`) and exposes them via the `Context` getters. Its constructor also sets the **Sentry user**
(id + effective name) so any error captured while handling this context is attributed to the right
Discord user:
```java
if (user != null) {
io.sentry.protocol.User sentryUser = new io.sentry.protocol.User();
sentryUser.setId(user.getId());
sentryUser.setUsername(user.getEffectiveName());
Sentry.setUser(sentryUser);
}
```
### `InteractionContext` (interface)
Behavioral mix-in for events that are JDA `Interaction`s (slash commands, buttons, modals, context
menus). It implements `reply`/`defer`/`ack` in terms of `IReplyCallback` and `InteractionHook`, so
these contexts can reply ephemerally, defer, and edit the original response.
### `NoMessageContext` / `MessageContext` (interfaces)
- `NoMessageContext` — provides sensible defaults for events that have **no** triggering message
(e.g. reactions, interactions).
- `MessageContext extends NoMessageContext` — adds access to a triggering `Message`, used by prefix
commands and by replies that drive step tasks.
## Command contexts
### `CommandContext`
Abstract base for the [command flow](commands.md). It carries the parsed
[`CommandOption`s](commands.md#unified-options) and the active subcommand / subcommand-group metadata,
and exposes type-safe option access, e.g. `getOption(name, resolver)`. Concrete subclasses adapt a
specific JDA event to this API.
| Class | Wraps | Notes |
|---|---|---|
| `SlashCommandContext` | `SlashCommandInteractionEvent` | Options already parsed by Discord; maps `OptionMapping` → `SlashCommandOption`. Interaction-based replies. |
| `MessageCommandContext` | `MessageReceivedEvent` (prefix) | Contains the **manual parser** that turns the raw message string into `CommandOption`s (mentions, integers, booleans, …). Message-based replies. |
| `MessageMenuContext` | `MessageContextInteractionEvent` | "Apps → …" action on a message; exposes the target `Message`. |
| `UserMenuContext` | `UserContextInteractionEvent` | "Apps → …" action on a user; exposes the target `User`. |
## Task contexts
### `TaskContext`
Abstract base for the [task flow](tasks.md). Two things distinguish it:
- **`getId()` (abstract)** — the id used by `TaskManager` to find the matching `Task`. How the id is
derived depends on the concrete subtype (see the table below and [tasks.md](tasks.md#the-task-id)).
- **`contextData` map** — a small state bag used to carry values between steps of a multi-step flow.
| Class | Wraps | `getId()` derived from |
|---|---|---|
| `ButtonTaskContext` | `ButtonInteractionEvent` | `getComponentId()` of the clicked button |
| `ModalTaskContext` | `ModalInteractionEvent` | modal `customId` |
| `EmojiTaskContext` | `MessageReactionAddEvent` | composite `guildId:messageId` |
| `MessageTaskContext` | `MessageReceivedEvent` (no prefix) | the id of the message being replied to (drives step tasks) |
## Reply mechanism & `AsyncContext`
### `AsyncContext`
`me.bobert.netneutrality.framework.context.AsyncContext` wraps the **result** of a previous reply — either an
`InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
expressed the same way regardless of the original trigger:
```java
context.reply("Initial response", async -> {
// 'async' is an AsyncContext for the message just sent
async.edit("Updated response");
});
```
Under the hood:
- **Interactions** — edits/follow-ups go through `hook.editOriginal()` / `hook.sendMessage()`.
- **Messages** — edits/follow-ups go through `message.editMessage()` / `message.reply()`.
This is what makes the command and task code path-agnostic: authors write against `Context` /
`AsyncContext` and never branch on "was this a slash command or a text message?".
### `MusicChannelContext`
A synthetic `AbstractContext` that targets a guild's configured **music channel** rather than the
channel an event came from. The music subsystem uses it to post now-playing / queue messages into the
correct channel even when playback state changes without a fresh user interaction. See
[music.md](music.md).
## End-to-end summary
| Event | Context | Flow | Async result wraps |
|---|---|---|---|
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command | `InteractionHook` |
| `MessageReceivedEvent` (prefix) | `MessageCommandContext` | Command | `Message` |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task | `InteractionHook` |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task | `Message` |
| `ModalInteractionEvent` | `ModalTaskContext` | Task | `InteractionHook` |
+102
View File
@@ -0,0 +1,102 @@
# Event Handling & Routing
**Package:** `me.bobert.netneutrality.platform.event`
**Primary class:** `EventsHandler`
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
(`jda.addEventListener(new EventsHandler())` in `App.startup()`). It is the funnel through
which **every** gateway event passes. Its job is to translate a raw JDA event into a
[Context](contexts.md) and route that context to the correct subsystem.
## Listener registry
Listeners are held in a static, thread-safe map keyed by the JDA event class:
```java
private static final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners
= new ConcurrentHashMap<>();
```
Each entry is a **factory function**: given an event of that type, it returns a `Context` (or `null`).
The map is populated once in a `static {}` block.
### `addListener` vs `addGenericListener`
- `addListener(Class<T>, Function<? super T, Context>)` — registers a factory that produces a
`Context` to be routed onward (command or task flow).
- `addGenericListener(Class<T>, Consumer<T>)` — registers a factory that runs a side-effecting
consumer and returns `null`, so nothing is routed onward. This is the **lambda flow** for simple
events.
## Registered events
| JDA event | Produces | Flow |
|---|---|---|
| `MessageReceivedEvent` (prefix match) | `MessageCommandContext` | Command |
| `MessageReceivedEvent` (no prefix) | `MessageTaskContext` | Task |
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command |
| `UserContextInteractionEvent` | `UserMenuContext` | Command |
| `MessageContextInteractionEvent` | `MessageMenuContext` | Command |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task |
| `ModalInteractionEvent` | `ModalTaskContext` | Task |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task |
| `GuildJoinEvent` | — (consumer) | Lambda |
| `GuildLeaveEvent` | — (consumer) | Lambda |
### Message routing detail
For `MessageReceivedEvent` the factory:
1. Ignores messages authored by bots and webhook messages (returns `null`).
2. Reads the configured prefix (`NETNEUTRALITY_BOT_PREFIX`, default `$`).
3. If the trimmed message starts with the prefix → **`MessageCommandContext`** (command flow).
4. Otherwise → **`MessageTaskContext`** (task flow). This lets ordinary messages participate in the
task system, e.g. as a reply that advances a [step task](tasks.md#33-step-tasks).
### Guild lifecycle (lambda flow)
- `GuildJoinEvent` — resolves `GuildService.get(guild)`; if debug logging is enabled for that guild,
sends a notice to the debug channel.
- `GuildLeaveEvent` — `GuildService.get(guild).deleteGuild()` removes the guild's stored data.
## Dispatch algorithm
```java
@Override
public void onEvent(@Nonnull GenericEvent event) {
handleEvent((Event) event);
}
private void handleEvent(Event event) {
Class<?> eventClass = event.getClass();
while (eventClass != null) {
if (listeners.containsKey(eventClass)) {
Context context = listeners.get(eventClass).apply(event);
if (context instanceof CommandContext c) handleCommand(c);
if (context instanceof TaskContext t) handleTask(t);
break;
}
eventClass = eventClass.getSuperclass(); // walk up the class hierarchy
}
}
```
Key points:
- **Superclass walk** — if the exact event class is not registered, the handler walks up the
superclass chain until it finds a match. This means a listener registered for a base event type
also catches subclasses.
- **Routing by context type** — the resulting object is routed purely by its runtime type:
a `CommandContext` goes to `CommandProcessor.dispatch()`, a `TaskContext` goes to
`TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
## Extending the event system
To handle a new event type:
1. Add an `addListener(SomeEvent.class, SomeContext::new)` (or `addGenericListener(...)` for a
consumer-only side effect) to the `static {}` block.
2. If it should run the command or task flow, make the produced context extend
[`CommandContext`](contexts.md#commandcontext) or [`TaskContext`](contexts.md#taskcontext)
respectively.
+93
View File
@@ -0,0 +1,93 @@
# Music
**Package:** `me.bobert.netneutrality.feature.music`
**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube
source 1.18.2.
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
sources, queueing, scheduling, and streaming audio into a Discord voice channel.
## Key classes
| Class | Responsibility |
|---|---|
| `MusicManager` | Central per-guild coordinator; owns the `AudioPlayer` and `TrackScheduler`. |
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
| `TrackScheduler` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). |
| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. |
| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). |
| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). |
| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. |
| `source/UdioAudioSourceManager` | Custom Lavaplayer source manager for udio.com. |
| `source/UdioAudioTrack` | Udio track; delegates playback to `Mp3AudioTrack`. |
`MusicManager` keeps a static `Map<String, MusicManager>` of managers keyed by guild id, so each guild
has its own player, scheduler, and queue.
## Startup
`MusicManager.initPlayerManager()` (called from
[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer
`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom
[Udio source](#custom-udio-source), and HTTP).
## Audio pipeline (end-to-end)
```
/play query
│
▼
MusicManager.play(query, ctx)
│ SourceFinder identifies the source
▼
audioPlayerManager.loadItemOrdered(guild, identifier, new TrackLoadingHandler(...))
│
├─ trackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons)
├─ noMatches ──▶ reply "nothing found"
└─ loadFailed ──▶ reply / log error
│
▼
QueueManager: if the player is idle, start immediately; else append to the deque
│
▼
AudioPlayerSendHandler streams PCM/Opus frames to the voice channel
│
▼ (track ends)
TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
```
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
now-playing/queue displays and vote-skip.
- **Playlists / search** — playlist and multi-result loads produce a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
- **Stuck tracks** — `TrackScheduler.onTrackStuck` attempts recovery (reload/skip).
- **Output** — playback messages are posted through a
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
music channel.
## Custom Udio source
`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist,
duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the
page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the
`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's
`Mp3AudioTrack`.
## Guild lifecycle & cleanup
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the player has been idle.
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the
static map.
- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
## Related commands
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
[commands.md](commands.md#command-catalog).
+128
View File
@@ -0,0 +1,128 @@
# Persistence & Database
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
`me.bobert.netneutrality.db` (entities, repositories, services).
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`.
## Stack
| Concern | Technology |
|---|---|
| ORM | Hibernate 7.1.9 (Jakarta JPA) |
| Engine | MariaDB 11 (per `docker-compose.yml`), MySQL Connector/J 9.7.0 driver, InnoDB |
| Pooling | HikariCP (via `hibernate-hikaricp`) |
| Migrations | Flyway 11.20.3 (`flyway-core` + `flyway-mysql`) |
Configuration is externalized via `Settings` (env vars / `bot.properties`):
`MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`.
## Session & migration lifecycle
Both are wired in at [startup](architecture.md#startup-lifecycle):
1. **`SessionManager.getSessionFactory()`** — lazily builds the Hibernate `SessionFactory`. It reads
the static config from `hibernate.cfg.xml` (dialect `MySQLDialect`, driver
`com.mysql.cj.jdbc.Driver`, the Hikari connection provider, and the mapped entity list) and
overrides the JDBC url/user/password from environment variables:
```java
settings.put(Environment.JAKARTA_JDBC_URL,
String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
settings.put(Environment.JAKARTA_JDBC_USER, dbUser);
settings.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
```
2. **`DatabaseMigration.migrate()`** — runs `flyway.migrate()` against the same database, applying the
versioned scripts under `src/main/resources/db/migration/` (starting with
`V1__initial_schema.sql`). The Flyway Maven plugin (`flyway-dev.sh`/`.ps1`) is available for local
development, using `FLYWAY_URL` / `FLYWAY_USER` / `FLYWAY_PASSWORD`.
3. **`SessionManager.shutdown()`** — destroys the service registry / releases the pool on shutdown.
Repositories obtain sessions through the factory; transactional access uses helpers in
[`JpaUtils`](utilities-and-bootstrap.md) (open session, run in a transaction, close).
## Entity catalog
Entities live in `me.bobert.netneutrality.data.entity` and are prefixed with `O`. `OGuild` is the hub
of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
| Entity | Table | Key | Relationships / notes |
|---|---|---|---|
| `OGuild` | `guilds` | `guild_id` (String) | Central entity; one-to-many with settings, blacklist, log messages, logging. |
| `OGuildSetting` | `guild_settings` | `id` (auto int) | Many-to-one `OGuild`; key/value guild settings. |
| `OBlacklist` | `blacklist` | `id` (auto int) | Many-to-one `OGuild`; blacklisted words. |
| `OLogging` | `logging` | `OLoggingId` (composite) | Composite key `guild_id` + `channel_id`; `@MapsId` to `OGuild`; debug/error log channel config. |
| `OLoggingId` | — (embeddable) | — | `@Embeddable`/`@IdClass` for the composite key. |
| `OLogMessage` | `log_message` | `id` (auto int) | Many-to-one `OGuild`; message logging history. |
| `OTask` | `tasks` | `id` (auto int) | [Persistable task](tasks.md#31-persistable-tasks) state; unique `task_id`, class name, JSON `payload`. |
| `OWebUser` | `web_users` | `id` (String) | Web dashboard user (tokens/expiry). *Present in schema & code but not listed in `hibernate.cfg.xml`.* |
### ER overview
```
┌───────────────┐
│ OGuild │ (guilds.guild_id)
└───────┬───────┘
┌───────────┬───────┼────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
OGuildSetting OBlacklist OLogging OLogMessage (…)
guild_settings blacklist logging log_message
(FK guild) (FK guild) (PK guild+ (FK guild)
channel)
OTask (tasks) OWebUser (web_users) ── standalone
```
`OTask` and `OWebUser` are not tied to the guild graph.
## Repository layer
A lightweight generic repository pattern is used.
- **`Repository<O>`** (interface) — generic CRUD: `findAll()`, `findById(String)`, `save(O)` (via
`session.merge()`), `deleteBy(String)`.
- **`BlacklistRepository`** — find-by-guild and case-insensitive word lookups.
- **`GuildRepository`** — CRUD for guilds.
- **`GuildSettingRepository`** — set/get setting values; removes rows that equal the default value.
- **`MessageRepository`** — message logging / edit history.
- **`TaskRepository`** — CRUD for `OTask` rows (used by the task persistence flow).
## Service layer
Services wrap repositories with business logic and **caching**.
- **`GuildService`** — gateway for guild operations; caches one `GuildService` per guild id
(`ConcurrentHashMap`); `findOrCreate` guarantees a guild row exists; `deleteGuild()` on leave;
`isDebugEnabled()` / debug-channel helpers used by [`EventsHandler`](events.md) and
[`LogHandler`](utilities-and-bootstrap.md#loghandler).
- **`BlacklistService`** — maintains a `blacklistCache` (guild id → set of words) for fast message
filtering.
- **`GuildSettingService`** — reads/writes guild settings backed by [`GSettings`](#gsettings); caches
values in `settingsCache` and reloads on change; used by the
[web API settings endpoints](web-api.md).
- **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON
for the `tasks` table and reconstructs them at startup via
[`TaskRegistry`/`TaskFactory`](tasks.md#31-persistable-tasks).
## GSettings
`GSettings` is an **enum** of the available per-guild configuration keys and their defaults:
| Setting | Default |
|---|---|
| `MUSIC_ROLE` | (none) |
| `MUSIC_CHANNEL` | (none) |
| `MUSIC_VOLUME` | `30` |
| `MUSIC_MAXPLAYLISTSIZE` | `20` |
`GuildSettingService` looks up an `OGuildSetting` row by the enum `name()`; if no row exists for a
guild/setting, the enum's `defaultValue` is used. This is how `MusicCommand` obtains the guild's music
role/channel and how `ConfigureCommand` persists changes.
## Schema (`V1__initial_schema.sql`)
The initial migration creates the tables backing the entities above (`guilds`, `guild_settings`,
`blacklist`, `logging`, `log_message`, `tasks`, `web_users`), with `guild_id` foreign keys and
`ON DELETE CASCADE` from the guild-scoped tables to `guilds`. Subsequent schema changes should be
added as new `V2__…`, `V3__…` Flyway scripts in the same directory.
+315
View File
@@ -0,0 +1,315 @@
# Tasks
**Packages:** `me.bobert.netneutrality.framework.task` (framework),
`me.bobert.netneutrality.feature.*.task` (concrete tasks)
Tasks are one of the more advanced concepts in the bot. A **Task** is a unit of logic that is looked
up and executed by **id** when a matching Discord event arrives, as opposed to a
[command](commands.md) which is looked up by name.
The original motivation was **Discord interactions** (buttons). When a user clicks a button, Discord
sends a `ButtonInteractionEvent` whose id is `getComponentId()`. A `Task` with that same id was
inserted earlier (typically by the command that created the button), so the `TaskManager` can find
the task and execute it. The system has since grown to cover several task subtypes:
| Subtype | Trigger | Id source |
|---|---|---|
| [Button task](#button-emoji-and-modal-tasks) | `ButtonInteractionEvent` | button `componentId` |
| [Modal task](#button-emoji-and-modal-tasks) | `ModalInteractionEvent` | modal `customId` |
| [Emoji task](#button-emoji-and-modal-tasks) | `MessageReactionAddEvent` | composite `guildId:messageId` |
| [Step task](#33-step-tasks) | replied-to message | last-message id (assigned dynamically) |
| [Built-in task](#32-built-in-tasks) | button with a hardcoded id | hardcoded id |
| [Persistable task](#31-persistable-tasks) | any of the above, restored from DB | as stored |
| [Scheduled task](#34-scheduled-tasks) | timer | n/a (time-based, not id-based) |
## The `Task` id
The id is the **primary lookup key** used by `TaskManager`. How it is derived depends on the subtype:
- **Buttons** → the button's `componentId`.
- **Modals** → the modal's `customId`.
- **Emoji reactions** → a composite `guildId:messageId` (a reaction is tied to a specific message).
- **Step tasks** → start life with a random UUID, then re-key themselves to the DM/last-message id
once that message exists (see [step tasks](#33-step-tasks)).
## `Task` contract & lifecycle
`Task` (interface) defines `execute(TaskContext)` and returns a **`Lifecycle`** value that tells the
`TaskManager` what to do with the task afterwards:
| Lifecycle | Meaning |
|---|---|
| `BUILTIN` | Task is static and stays registered indefinitely. |
| `KEEP` | Keep the task in memory after execution (can run again). |
| `REMOVE_SELF` | Remove this task after execution (one-shot). |
| `REMOVE_SELF_AND_CHILDREN` | Remove this task and all of its [child tasks](#trait-interfaces). |
### Task base classes (`core.tasks.impl`)
```
AbstractTask (implements Task)
├── ButtonTask ── executed from a ButtonTaskContext
├── EmojiTask ── executed from an EmojiTaskContext
├── ModalTask ── executed from a ModalTaskContext
├── GenericCustomIdTask ── task keyed by an explicit custom id
├── GenericRandomIdTask ── task keyed by a generated random id
├── AbstractBuiltInTask ── base for hardcoded, reflectively-loaded tasks
├── AbstractPersistableTask ── base for DB-backed tasks (implements PersistableTask)
└── AbstractStepTask ── base for multi-step Q&A flows (implements StepTask)
```
## `TaskManager`
`me.bobert.netneutrality.framework.task.TaskManager` is the registry, lookup, and execution
coordinator.
### Registration — `insert`
- Adds the task to the active map keyed by its id.
- If the task is a [`PersistableTask`](#31-persistable-tasks), it is also **saved to the database**
via [`TaskService`](persistence.md#service-layer).
- If the task is a [`StepTask`](#33-step-tasks) without a message id yet, it is held in a separate
`pendingTasks` collection until its id is assigned.
### Execution — `handle(TaskContext)`
1. Look up the task by `context.getId()`.
2. For ordinary (non-built-in, non-persistable) tasks, acquire a **`ReentrantLock`** so the same task
cannot be executed concurrently (prevents double-clicks / races). Tasks that share a
[parent](#trait-interfaces) share the same lock id.
3. Call `task.execute(context)`.
4. Apply the returned `Lifecycle` (keep / remove / remove-with-children); persistable tasks removed
here are also deleted from the DB.
5. If the task is [`Deletable`](#trait-interfaces), delete the original message/interaction.
6. Unless the task is [`NotAcknowledgeable`](#trait-interfaces), acknowledge the interaction.
### Startup — `init`
Called from `App.startup()`:
1. Reflectively load all [`BuiltInTask`](#32-built-in-tasks) implementations.
2. Reflectively load all [`ScheduledTask`](#34-scheduled-tasks) implementations and register them
with [`ScheduledTaskManager`](threading.md).
3. Load persistable tasks from the database (via `TaskService`) and reconstruct them (see
[persistence rules](#31-persistable-tasks)).
## Button, emoji and modal tasks
These are the "interaction" tasks and the original purpose of the subsystem:
- **`ButtonTask`** — executed when a button whose `componentId` matches the task id is clicked. The
matching context is a [`ButtonTaskContext`](contexts.md#task-contexts).
- **`ModalTask`** — executed when a modal is submitted; matched by the modal `customId`. A modal is
typically opened by another interaction (e.g. `ReportIssueTask` opens a title/description modal).
- **`EmojiTask`** — executed when a reaction is added to a specific message; matched by the composite
`guildId:messageId` id.
The generic helpers `GenericCustomIdTask` / `GenericRandomIdTask` let a command install an ad-hoc,
function-backed task under a chosen or generated id (e.g. the generic `AcceptButton` / `CancelButton`
under `tasks/generic`).
## 3.1 Persistable tasks
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
`core.tasks.persistence.TaskRegistry`, `core.tasks.persistence.TaskFactory`;
resource `src/main/resources/persistable-tasks.json`; entity
[`OTask`](persistence.md#entity-catalog).
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup. The
rules for *how* a persistable task can be defined and reconstructed live in `TaskRegistry` and
`TaskFactory`.
### The mapping file — `persistable-tasks.json`
The file declares, for each persistable task class, the constructor parameters and their types:
```json
{
"tasks": [
{
"class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask",
"params": ["guildId", "channelId", "messageId", "emojiMapping"],
"types": ["string", "string", "string", "map"]
}
]
}
```
Schema:
| Key | Meaning |
|---|---|
| `tasks` | array of task mappings |
| `class` | fully-qualified class name; **must** implement `PersistableTask` |
| `params` | ordered list of payload field names |
| `types` | parallel list of types for those fields |
Supported types (`TaskFactory.ParamType`): `string` → `String`, `int` → `Integer`, `map` → `Map`.
### `TaskRegistry` — loading the mappings
`TaskRegistry.loadTaskConfig()` reads `persistable-tasks.json` from the classpath and, for each entry,
registers a `TaskCreator` (a functional interface `PersistableTask fromPayload(JSONObject payload)`)
keyed by class name:
```java
taskCreators.put(className, payload -> {
try {
return TaskFactory.createTaskInstance(className, params, types, payload);
} catch (Exception e) {
logger.error("Failed to create instance of '{}': {}", className, e.getMessage());
return null;
}
});
```
Entries missing `class`, `params`, or `types` are logged and skipped. Consumers later look up a
creator via `TaskRegistry.getTaskCreator(className)`.
### `TaskFactory` — reconstructing an instance
`TaskFactory.createTaskInstance(className, params, types, payload)`:
1. Validates that `params.length == types.length`.
2. Loads the class and verifies it implements `PersistableTask`.
3. For each declared parameter, converts the JSON `types[i]` to a `ParamType`, then extracts
`payload[params[i]]` as the corresponding Java type:
- `string` → `payload.getString(name)`
- `int` → `payload.getInt(name)`
- `map` → `payload.getJSONObject(name).toMap()`
4. Reflectively resolves the constructor whose parameter types match `types` and instantiates it with
the extracted values.
```java
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
```
> **Rule of thumb for adding a persistable task:** create a class that implements `PersistableTask`
> (usually by extending `AbstractPersistableTask`), give it a constructor whose parameters match the
> `params`/`types` you declare, ensure it serializes those same fields into its JSON payload, and add
> a matching entry to `persistable-tasks.json`. The payload is stored in the
> [`OTask.payload`](persistence.md#entity-catalog) column and reconstructed via the flow above on
> startup.
### Persistence round-trip
```
insert(task) ──▶ TaskService serializes payload ──▶ OTask row (task_id, class, payload JSON)
startup ──▶ TaskService reads OTask rows ──▶ TaskRegistry.getTaskCreator(class)
.fromPayload(payload) ──▶ TaskFactory ──▶ instance
```
## 3.2 Built-in tasks
**Classes:** `BuiltInTask`, `AbstractBuiltInTask`.
Built-in tasks are hardcoded tasks with hardcoded ids, conceptually similar to commands. They are
loaded reflectively at startup (in `TaskManager.init()`) and stay registered for the lifetime of the
bot (`Lifecycle.BUILTIN`).
A button that triggers a built-in task is created from its class:
```java
Button button = BuiltInTask.createButton(SomeBuiltInTask.class);
```
This produces a JDA `Button` whose component id equals the built-in task's hardcoded id, so clicking
it routes straight to that task. Examples: the help buttons `BotInfo`, `CommandHelp`, `ReportBug`
(`tasks/help`).
## 3.3 Step tasks
**Classes:** `StepTask` (interface), `AbstractStepTask`.
Step tasks ask the user a series of questions and run logic based on each answer. Because Discord has
no native "conversation" primitive, the abstraction tracks the **last message id** and only runs the
next step when *that* message is replied to.
Flow:
1. A step task is created (e.g. by a setup command) and sends a prompt message; initially it is keyed
by a random UUID and held in `pendingTasks`.
2. Once the prompt message exists, the task **re-keys itself to that message's id** and moves into the
active `tasks` map.
3. When the user replies to that message, the incoming `MessageReceivedEvent` becomes a
[`MessageTaskContext`](contexts.md#task-contexts) whose id is the replied-to message id — so
`TaskManager` finds the step task.
4. `executeStep` validates/processes the answer and returns a `StepResult`:
success, failure, or **next step** (which installs the following step task).
`AbstractStepTask` provides answer validation keyed to a step "type" (e.g. a role id, an emoji, …).
## 3.4 Scheduled tasks
**Classes:** `ScheduledTask`, coordinated by
[`ScheduledTaskManager`](threading.md#scheduledtaskmanager).
Scheduled tasks are time-based background jobs (as opposed to id-based interaction tasks). They are
loaded reflectively at startup and registered with `ScheduledTaskManager`, which schedules them at a
fixed interval derived from the task's `TimeFrame` (e.g. minutely / hourly / daily / custom) and
cancels them all on shutdown.
> **Status:** the scheduled-task abstraction is intended to eventually replace the ad-hoc
> [`ThreadManager`](threading.md) threads and is still evolving. The concrete scheduled tasks that
> exist today are listed in the [catalog](#concrete-task-catalog).
## Trait interfaces
Tasks can opt into extra behavior by implementing marker/behavior interfaces in
`core.tasks.trait`:
| Trait | Effect |
|---|---|
| `Deletable` | After successful execution, `TaskManager` deletes the original message/interaction. |
| `NotAcknowledgeable` | Suppresses the default acknowledgement (the task sends its own response). |
| `WithButton` | The task can render itself as a JDA `Button` (`createButton()`). |
| `WithChildren` | The task tracks child tasks and can clean them up together (`REMOVE_SELF_AND_CHILDREN`). |
| `WithParent` | Links a child task to a parent; children share the parent's execution lock. |
## Concrete task catalog
| Task | Package | Base type | Description |
|---|---|---|---|
| `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. |
| `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. |
| `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. |
| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a GitLab issue. |
| `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. |
| `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. |
| `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. |
| `LoadPlaylistTask` | `music` | `ButtonTask` (with children) | Present playlist/search results as choice buttons. |
| `LoadTrackFromPlaylistTask` | `music` | `ButtonTask` | Load one specific track from the results. |
| `EmojiRoleFirstStep` | `reaction` | `AbstractStepTask` | Ask for the target role id. |
| `EmojiRoleSecondStep` | `reaction` | `AbstractStepTask` | Ask for the emoji to map. |
| `EmojiRoleModify` | `reaction` | `AbstractStepTask` | Modify an existing emoji-role mapping. |
| `EmojiRoleRemoveStep` | `reaction` | `AbstractStepTask` | Remove an emoji-role mapping. |
| `MusicManagerCleanup` | `scheduled` | `ScheduledTask` | Evict unused per-guild music managers. |
| `MusicPlayerAutoDisconnect` | `scheduled` | `ScheduledTask` | Disconnect from voice when idle. |
| `TaskCleanup` | `scheduled` | `ScheduledTask` | Periodically remove expired non-persistable tasks. |
### Worked example — emoji-role setup (step → persistable)
1. `EmojiRoleFirstStep` sends a prompt: "reply with the role id"; `AbstractStepTask` validates it is a
real role in the guild.
2. `executeStep` returns `StepResult.next(new EmojiRoleSecondStep(...))`, carrying the chosen role.
3. `EmojiRoleSecondStep` asks for the emoji; on a valid answer it creates an
`EmojiReactionRoleTask`.
4. That `EmojiReactionRoleTask` is `insert`ed into `TaskManager` — because it is a
[persistable task](#31-persistable-tasks) it is written to the `tasks` table and will be restored
on the next startup via the [`persistable-tasks.json`](#the-mapping-file--persistable-tasksjson)
mapping.
### Worked example — GitLab bug report (modal)
1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields.
2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values.
3. The task connects to GitLab (using `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` from
configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
### Worked example — music results (parent/children)
1. `LoadPlaylistTask` posts a message with a button per result (children:
`LoadTrackFromPlaylistTask` / `AcceptButton`).
2. All children share the parent's lock id ([`WithParent`](#trait-interfaces)), so simultaneous
clicks are serialized.
3. Selecting a result can return `REMOVE_SELF_AND_CHILDREN` to tear down the whole interaction.
+45
View File
@@ -0,0 +1,45 @@
# Threading & Scheduling
**Package:** `me.bobert.netneutrality.platform.concurrency`
This subsystem centralizes asynchronous execution and recurring jobs.
## Classes
| Class | Responsibility |
|---|---|
| `ThreadManager` | Static registry of `ExecutorService` instances shared across the bot. |
| `BaseThreadFactory` | Custom `ThreadFactory` giving threads meaningful names and an uncaught-exception handler. |
| `ScheduledTaskManager` | Schedules and cancels recurring [scheduled tasks](tasks.md#34-scheduled-tasks). |
## `ThreadManager`
- `getScheduledExecutor()` — a `ScheduledThreadPool` sized to the number of CPU cores.
- `getExecutor(name)` — returns (and caches) a named thread pool for ad-hoc async work.
- `shutdownExecutor()` — shuts the executors down cleanly; called from
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
## `BaseThreadFactory`
Names created threads (e.g. `web-api-1`) so they are identifiable in logs and stack dumps, and installs
an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
`UncaughtExceptionHandler` so crashes on worker threads are still routed through
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used both by `ThreadManager` and by the
[Web API](web-api.md) server pool.
## `ScheduledTaskManager`
Coordinates [`ScheduledTask`](tasks.md#34-scheduled-tasks) instances (discovered reflectively at
startup by [`TaskManager.init()`](tasks.md#taskmanager)):
- `schedule(ScheduledTask task)` — schedules the task at a fixed delay/interval derived from its
`TimeFrame` (minutely / hourly / daily / custom), using the shared scheduled executor.
- `cancelAll()` — cancels every scheduled task; called first in
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect`, and
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
> threads created via `ThreadManager`, giving a single, uniform way to express recurring background
> work. It is still evolving (see [tasks.md §3.4](tasks.md#34-scheduled-tasks)).
+164
View File
@@ -0,0 +1,164 @@
# Utilities, Bootstrap, Logging & Exceptions
This document covers the cross-cutting glue of the bot: the entry points, configuration, logging /
error tracking, the shared utility helpers, and the custom exception hierarchy.
## Bootstrap
**Package:** `me.bobert.netneutrality` / `me.bobert.netneutrality.bootstrap`
| Class | Role |
|---|---|
| `App` | The application: holds the process instance and the `JDA` handle, builds the `ApplicationContext`, runs `context.start()` / `context.stop()`, and is the JAR main class (`main()`). See [architecture.md](architecture.md). |
| `bootstrap.ApplicationContext` | Hand-rolled container: `register(T)`, `get(Class<T>)`, ordered `start()` / `stop()` for `Lifecycle` components. |
| `bootstrap.Lifecycle` | Optional component contract: `start() throws Exception`, default `stop()`, default `priority()`. |
| `NetNeutralityBot` | Currently an **empty placeholder** class under `framework` (reserved name; no logic yet). |
| `core.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
The full startup and shutdown sequences are documented in
[architecture.md](architecture.md#startup-lifecycle).
### `ApplicationContext` + `Lifecycle`
Domain services are constructed once in `App.buildContext()`, registered with the context,
and resolved later by type:
- `register(component)` — indexes the concrete class; if the component implements `Lifecycle` it
participates in ordered start/stop.
- `get(Class<T>)` — returns the first registered instance assignable to `T` (interfaces work).
- `start()` — sorts `Lifecycle` components by ascending `priority()` and starts them.
- `stop()` — stops them in the exact reverse order; individual failures are logged and swallowed so
remaining components still tear down.
Constructor injection for reflectively loaded commands/tasks is handled by
[`ReflectionHelper`](#reflectionhelper): the richest public constructor whose parameter types all
resolve via `context.get(type)` is preferred; otherwise the no-arg constructor is used.
**Interfaces used for injection** (implemented by the concrete managers registered above):
| Interface | Typical implementor |
|---|---|
| `platform.config.SettingsProvider` | `utils.Settings` |
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
| `feature.music.MusicService` | `music.MusicServiceImpl` |
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
static access points left are:
- `Settings.getInstance()` — early bootstrap only (`main()`, Sentry, token validation) before the
context exists; the same instance is registered as `SettingsProvider` for everything else.
- `App.getInstance()` — application root used by a couple of shutdown entry points
(`ShutdownCommand`). Prefer constructor injection of the needed collaborators where possible.
- `JpaUtils.bind(SessionProvider)` / `LogHandler.bind(GuildServiceProvider)` /
`ChannelMessagePurger.bind(ExecutorProvider)` — single well-defined static bind points for deep
utility graphs that are not constructor-injected (repositories, static log helpers).
## Configuration
### `Settings` / `SettingsProvider`
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements
`me.bobert.netneutrality.platform.config.SettingsProvider`.
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties`
file and overlays **environment variables** on top (environment variables win).
- **Early bootstrap:** `Settings.getInstance()` (process-wide, created once before the context).
- **Runtime injection:** depend on `SettingsProvider` and let `ApplicationContext` supply the same
instance. Prefer this path in commands, tasks, handlers, and services.
- `Settings.getVersion()` / `Settings.getDiscordInvite()` — thin static helpers used by the banner /
invite strings; they delegate to the bootstrap instance.
An example configuration file is `bot.properties.example`. Key configuration values:
| Key | Purpose |
|---|---|
| `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. |
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). |
| `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). |
| `NETNEUTRALITY_BOT_OWNER` | Bot owner user id (used for owner-only commands / permissions). |
| `NETNEUTRALITY_BOT_INVITE` | Invite link surfaced by help/info. |
| `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). |
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). |
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube playback auth/cipher settings for the [music](music.md) source. |
## Utility helpers
**Package:** `me.bobert.netneutrality.shared.util` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.platform.*`
| Class | Purpose |
|---|---|
| `Settings` | Configuration (see above). |
| `ReflectionHelper` | Classpath scanning / reflective instantiation (see below). |
| `ContextUtils` | Boilerplate helpers for building/handling [contexts](contexts.md). |
| `GuildUtils` | Guild-related helpers (lookups, permission checks). |
| `JpaUtils` | Session/transaction helpers for the [persistence](persistence.md) layer. Bound once to a `SessionProvider` during bootstrap (`JpaUtils.bind`). |
| `MusicUtils` | Voice-channel connectivity and DJ/permission checks for [music](music.md). Callers pass the guild `MusicManager` (no static music lookup). |
| `MiscUtils` | Assorted small helpers. |
| `ChannelMessagePurger` | Bulk message deletion (used by the purge command). Bound once to an `ExecutorProvider` during bootstrap. |
| `MessageFunction` | Standardized message/embed formatting. Help text builders take a `CommandRegistry` rather than a static command manager. |
| `UserFunction` | Standardized user/string formatting. |
### `ReflectionHelper`
Built on the `org.reflections` library, `ReflectionHelper` discovers and instantiates implementation
classes in specific packages. It is the mechanism behind the bot's "drop a class in the right package
and it's picked up" model, used to auto-load:
- [`Command`](commands.md) implementations (`CommandManager` / `CommandRegistry.start()`),
- [`BuiltInTask`](tasks.md#32-built-in-tasks) and [`ScheduledTask`](tasks.md#34-scheduled-tasks)
implementations (`TaskManager.start()`).
When given an `ApplicationContext`, `newInstance(Class, ApplicationContext)` selects the public
constructor with the most parameters that can all be resolved via `context.get(parameterType)`. If
no such constructor exists it falls back to the no-arg constructor. That is how music/admin/react
commands receive `MusicService`, `GuildServiceProvider`, `TaskManager`, etc. without static
singletons.
## Logging & error tracking
### `LogHandler`
**Class:** `me.bobert.netneutrality.platform.logging.LogHandler`.
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
1. **Log4j2** (configured by `log4j2.properties`; SLF4J is bridged via `log4j-slf4j2-impl`),
2. **Sentry** (when `NETNEUTRALITY_SENTRY_DSN` is configured), and
3. optionally a per-guild **Discord debug channel** (e.g. `sendMessageToDebugChannel(guild, msg)`),
gated by [`GuildService.isDebugEnabled()`](persistence.md#service-layer) /
[`OLogging`](persistence.md#entity-catalog) configuration.
The Sentry **user** context is attached automatically when a
[`AbstractContext`](contexts.md#abstractcontext-abstract-class) is created, so captured errors are
attributed to the Discord user that triggered them.
## Exception hierarchy
**Package:** `me.bobert.netneutrality.exceptions`
```
RuntimeException
└── ContextException ── carries a Context (guild/channel/user)
├── CommandExecutionException ── thrown during command processing
└── TaskExecutionException ── thrown during task execution
GracefulContextError ── user-facing, non-logged failure → ephemeral reply
ExecutorException ── Thread.UncaughtExceptionHandler → routes crashes to LogHandler
```
- **`ContextException`** — base runtime exception that carries the [`Context`](contexts.md) in which
it occurred, so downstream handling can respond in the right channel and attribute the error.
- **`CommandExecutionException`** / **`TaskExecutionException`** — specializations thrown by the
[command](commands.md) and [task](tasks.md) flows respectively.
- **`GracefulContextError`** — signals an expected, user-facing problem (e.g. bad input); when caught
it produces an ephemeral message to the user instead of a full error log/Sentry event.
- **`ExecutorException`** — an `UncaughtExceptionHandler` installed by
[`BaseThreadFactory`](threading.md#basethreadfactory) so exceptions escaping worker threads are still
captured by [`LogHandler`](#loghandler).
+51
View File
@@ -0,0 +1,51 @@
# HTTP Web API
**Package:** `me.bobert.netneutrality.platform.web` (client module) and
`me.bobert.netneutrality.platform.web.server` (server).
**Server:** JDK built-in `com.sun.net.httpserver.HttpServer` (no external web framework).
The bot exposes a small internal HTTP API for health monitoring, control, and guild introspection —
intended for an external dashboard/companion, not for end users in Discord.
## Server startup
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
It receives `SettingsProvider`, `GuildServiceProvider`, and suppliers for the live `JDA` /
shutdown callback via its constructor. On `start()` it:
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
- creates an `HttpServer` bound to that port,
- installs an `Executor` backed by a fixed thread pool of 10 threads named via
[`BaseThreadFactory`](threading.md),
- registers the handler contexts (below), and
- starts the server.
On `stop()` the server and its executor are shut down gracefully.
## Handler model
`AbstractHandler` (implements `HttpHandler`) is the base for all handlers and provides helpers such as
`sendJsonResponse(...)` and `sendStringResponse(...)`. Concrete handlers extend it and are mounted at
a path prefix.
| Handler | Base path | Endpoints |
|---|---|---|
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown; `/actions/settings/current/{guildId}` — current guild settings; `/actions/settings/reload/{guildId}` — invalidate & reload the settings cache. |
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings
endpoints).
## HTTP client — `WebModule`
`WebModule` wraps an `OkHttpClient` for the bot's own outbound HTTP calls (e.g. checking for updates
via GitLab). It keeps a small `LinkedHashMap`-based response cache limited to 50 entries.
## Notes
- There is no authentication layer documented on these endpoints; the API is expected to be bound to a
trusted/internal network (e.g. within the Docker network). `/actions/shutdown` is destructive, so the
port should not be exposed publicly.