Remove deprecated classes and static singleton-based architecture
This commit is contained in:
1 parent
54a27883d1
commit
b8786c8dc9
133 files changed
+4984
-1038
No files matched your search
@@ -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).
|
||||
@@ -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 |
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
@@ -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)).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user