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
+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.
|
||||
Reference in new issue
Block a user