# 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.