15 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.TaskRegistry, core.tasks.persistence.TaskFactory;
resource src/main/resources/persistable-tasks.json; entity
OTask.
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:
{
"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:
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):
- Validates that
params.length == types.length. - Loads the class and verifies it implements
PersistableTask. - For each declared parameter, converts the JSON
types[i]to aParamType, then extractspayload[params[i]]as the corresponding Java type:string→payload.getString(name)int→payload.getInt(name)map→payload.getJSONObject(name).toMap()
- Reflectively resolves the constructor whose parameter types match
typesand instantiates it with the extracted values.
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
Rule of thumb for adding a persistable task: create a class that implements
PersistableTask(usually by extendingAbstractPersistableTask), give it a constructor whose parameters match theparams/typesyou declare, ensure it serializes those same fields into its JSON payload, and add a matching entry topersistable-tasks.json. The payload is stored in theOTask.payloadcolumn 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:
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 thepersistable-tasks.jsonmapping.
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.