316 lines
17 KiB
Markdown
316 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.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.
|