Replaced metadata-driven reflection with self-describing `PersistableTask` types and a new `PersistableTaskRegistry`. Removed `persistable-tasks.json` and `TaskRegistry`, migrating to type-safe DI instantiation and explicit `restore()` methods. Fixed task restoration issues, ensuring `EmojiReactionRoleTask` and its `emojiMapping` now persist and restore correctly. Introduced `typeId()` for stable task identification, updated payload serialization for symmetry, and applied database migration to map FQCNs to logical IDs. Added unit tests for robust type discovery, payload symmetry, and registry behavior, ensuring no breaking changes.
17 KiB
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 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.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, it is also saved to the database viaTaskService. - If the task is a
StepTaskwithout a message id yet, it is held in a separatependingTaskscollection until its id is assigned.
Execution — handle(TaskContext)
- Look up the task by
context.getId(). - For ordinary (non-built-in, non-persistable) tasks, acquire a
ReentrantLockso the same task cannot be executed concurrently (prevents double-clicks / races). Tasks that share a parent share the same lock id. - Call
task.execute(context). - Apply the returned
Lifecycle(keep / remove / remove-with-children); persistable tasks removed here are also deleted from the DB. - If the task is
Deletable, delete the original message/interaction. - Unless the task is
NotAcknowledgeable, acknowledge the interaction.
Startup — init
Called from App.startup():
- Reflectively load all
BuiltInTaskimplementations. - Reflectively load all
ScheduledTaskimplementations and register them withScheduledTaskManager. - 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 whosecomponentIdmatches the task id is clicked. The matching context is aButtonTaskContext.ModalTask— executed when a modal is submitted; matched by the modalcustomId. A modal is typically opened by another interaction (e.g.ReportIssueTaskopens a title/description modal).EmojiTask— executed when a reaction is added to a specific message; matched by the compositeguildId:messageIdid.
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 totasks.task_class. It defaults togetName(), so a package move or class rename never breaks restoring existing rows.restore(JSONObject)is the counterpart oftoPayload().AbstractPersistableTaskprovides a no-op default, so only tasks that carry extra state override it.toPayload()serializes the bean getters (new JSONObject(this)) and always includesidandlockId, whichTaskService.validateTaskverifies 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(orEmojiTask), give it a(TaskManager)constructor for the registry plus whatever constructors the feature code needs, and overriderestore()if it holds state beyond the bean getters. Nothing else has to be registered — the scan picks it up andgetName()becomes itstypeId().
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:
- 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. - Once the prompt message exists, the task re-keys itself to that message's id and moves into the
active
tasksmap. - When the user replies to that message, the incoming
MessageReceivedEventbecomes aMessageTaskContextwhose id is the replied-to message id — soTaskManagerfinds the step task. executeStepvalidates/processes the answer and returns aStepResult: 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
ThreadManagerthreads 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 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)
EmojiRoleFirstStepsends a prompt: "reply with the role id";AbstractStepTaskvalidates it is a real role in the guild.executeStepreturnsStepResult.next(new EmojiRoleSecondStep(...)), carrying the chosen role.EmojiRoleSecondStepasks for the emoji; on a valid answer it creates anEmojiReactionRoleTask.- That
EmojiReactionRoleTaskisinserted intoTaskManager— because it is a persistable task it is written to thetaskstable and will be restored on the next startup via thePersistableTaskRegistry.
Worked example — GitLab bug report (modal)
ReportIssueTask.replyToInteractionopens a modal with title and description fields.- On submit,
ModalTaskContextroutes back to the task;executereads the field values. - The task connects to GitLab (using
GITLAB_HOST/GITLAB_TOKEN/GITLAB_PROJECT_IDfrom configuration) and creates an issue, then returnsLifecycle.REMOVE_SELF.
Worked example — music results (parent/children)
LoadPlaylistTaskposts a message with a button per result (children:LoadTrackFromPlaylistTask/AcceptButton).- All children share the parent's lock id (
WithParent), so simultaneous clicks are serialized. - Selecting a result can return
REMOVE_SELF_AND_CHILDRENto tear down the whole interaction.