Replaced metadata-driven reflection with self-describing `PersistableTask` types and a new `PersistableTaskRegistry`. Removed `persistable-tasks.json` and `TaskRegistry`, migrating to type-safe DI instantiation and explicit `restore()` methods. Fixed task restoration issues, ensuring `EmojiReactionRoleTask` and its `emojiMapping` now persist and restore correctly. Introduced `typeId()` for stable task identification, updated payload serialization for symmetry, and applied database migration to map FQCNs to logical IDs. Added unit tests for robust type discovery, payload symmetry, and registry behavior, ensuring no breaking changes.
292 lines
17 KiB
Markdown
292 lines
17 KiB
Markdown
# 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.PersistableTaskRegistry`; entity
|
|
[`OTask`](persistence.md#entity-catalog).
|
|
|
|
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup.
|
|
There is **no mapping file** — task types are discovered on the classpath and each task knows how to
|
|
rebuild itself.
|
|
|
|
### The contract
|
|
|
|
```java
|
|
public interface PersistableTask extends Task {
|
|
default String typeId() { return getName(); } // stable logical id stored in the DB
|
|
void restore(JSONObject payload); // rebuild state from a previous toPayload()
|
|
void insert(CommandContext context);
|
|
void save();
|
|
void delete();
|
|
String toPayload();
|
|
}
|
|
```
|
|
|
|
- **`typeId()`** is the value written to `tasks.task_class`. It defaults to `getName()`, so a package
|
|
move or class rename never breaks restoring existing rows.
|
|
- **`restore(JSONObject)`** is the counterpart of `toPayload()`. `AbstractPersistableTask` provides a
|
|
no-op default, so only tasks that carry extra state override it.
|
|
- **`toPayload()`** serializes the bean getters (`new JSONObject(this)`) and always includes `id` and
|
|
`lockId`, which `TaskService.validateTask` verifies against the stored row.
|
|
|
|
### `PersistableTaskRegistry` — discovery and restore
|
|
|
|
`PersistableTaskRegistry.scan()` runs once during `TaskManagerImpl.init()` and uses the same
|
|
`ReflectionHelper.loadTasks(..., ApplicationContext, ...)` discovery used for
|
|
[built-in](#32-built-in-tasks) and [scheduled](#34-scheduled-tasks) tasks: every concrete
|
|
`PersistableTask` under `me.bobert.netneutrality` is instantiated once and indexed by its `typeId()`.
|
|
Two types declaring the same id fail fast at startup with both class names in the message.
|
|
|
|
`create(typeId, payload)` then produces a **fresh** instance per row:
|
|
|
|
```java
|
|
PersistableTask task = ReflectionHelper.instantiate(type, context);
|
|
task.restore(payload);
|
|
```
|
|
|
|
Because instantiation goes through the DI context, a restored task needs a constructor whose
|
|
parameters are all resolvable from the `ApplicationContext` — in practice `(TaskManager)`. Fields
|
|
that only exist in the payload (ids, mappings) are assigned by `restore()` before the instance is
|
|
handed to the `TaskManager`.
|
|
|
|
> **Adding a persistable task:** write **one class** extending `AbstractPersistableTask` (or
|
|
> `EmojiTask`), give it a `(TaskManager)` constructor for the registry plus whatever constructors the
|
|
> feature code needs, and override `restore()` if it holds state beyond the bean getters. Nothing
|
|
> else has to be registered — the scan picks it up and `getName()` becomes its `typeId()`.
|
|
|
|
### Persistence round-trip
|
|
|
|
```
|
|
insert(task) ──▶ TaskService.persistTask ──▶ OTask row (task_id, task_class = typeId, payload JSON)
|
|
startup ──▶ PersistableTaskRegistry.scan() (typeId ──▶ class)
|
|
──▶ TaskService reads OTask rows ──▶ registry.create(task_class, payload)
|
|
──▶ DI instantiate ──▶ task.restore(payload)
|
|
```
|
|
|
|
Rows whose `task_class` is not a registered `typeId` are logged and skipped; the remaining rows still
|
|
load.
|
|
|
|
## 3.2 Built-in tasks
|
|
|
|
**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
|
|
[`PersistableTaskRegistry`](#persistabletaskregistry--discovery-and-restore).
|
|
|
|
### 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.
|