Files
netneutrality/docs/tasks.md
T
anthorob abdd2d1656 Replace GitLab integration with Gitea in issue reporting
Migrated issue reporting functionality from GitLab to Gitea. Updated environment variables, dependencies, and configuration references to support Gitea. Introduced `GiteaClient` for communication with the Gitea API and refactored related code and tests to align with the new integration.
2026-10-13 13:16:54 -04:00

291 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 Gitea 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 — Gitea 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 calls the Gitea API via `GiteaClient` (using `GITEA_HOST` / `GITEA_TOKEN` /`r`n `GITEA_OWNER` / `GITEA_REPO` 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.