Files
netneutrality/docs/tasks.md
T
2026-10-19 08:21:42 -04:00

17 KiB

Tasks

Packages: me.bobert.netneutrality.core.tasks (framework), me.bobert.netneutrality.tasks.* (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 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 ButtonInteractionEvent button componentId
Modal task ModalInteractionEvent modal customId
Emoji task MessageReactionAddEvent composite guildId:messageId
Step task replied-to message last-message id (assigned dynamically)
Built-in task button with a hardcoded id hardcoded id
Persistable task any of the above, restored from DB as stored
Scheduled task 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).

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.

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.core.tasks.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, it is also saved to the database via TaskService.
  • If the task is a StepTask 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 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, delete the original message/interaction.
  6. Unless the task is NotAcknowledgeable, acknowledge the interaction.

Startup — init

Called from App.startup():

  1. Reflectively load all BuiltInTask implementations.
  2. Reflectively load all ScheduledTask implementations and register them with ScheduledTaskManager.
  3. Load persistable tasks from the database (via TaskService) and reconstruct them (see persistence rules).

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

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

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 and scheduled 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:

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:

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

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 threads and is still evolving. The concrete scheduled tasks that exist today are listed in the 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 inserted into TaskManager — because it is a persistable task it is written to the tasks table and will be restored on the next startup via the PersistableTaskRegistry.

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 /rn 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), so simultaneous clicks are serialized.
  3. Selecting a result can return REMOVE_SELF_AND_CHILDREN to tear down the whole interaction.