Events & Clocks
Listen to Discord gateway events and schedule recurring background tasks.
Medusa separates time-triggered tasks and Discord gateway events into two dedicated building blocks: Events for responding to gateway events emitted by Discord.js, and Clocks for running scheduled background jobs via cron expressions.
Discord Gateway Events
Gateway listeners reside in src/events/ and load from every *.js file located directly in that directory. Each file exports a class extending Events from #medusa/modules.
import { Events } from "#medusa/modules";
import { Events as DiscordEvents } from "discord.js";
export class MessageCreateEvent extends Events {
constructor(medusa) {
super(medusa, {
name: "messageCreate",
event: DiscordEvents.MessageCreate,
once: false
});
}
async run(message) {
if (!message || message.author.bot || !message.guild) return;
const handler = this.medusa.modules.handlers.get(this.module, "items");
if (handler) {
await handler.scanMessage(message);
}
}
}Direct Messages
The client subscribes to the DirectMessages intent with the Channel partial, so messageCreate listeners also receive messages sent to the bot in DMs. DM messages have no guild and run outside any server scope. Guard with if (!message.guild) return; when a listener only handles server messages, or check message.channel.type === ChannelType.DM to handle DMs on purpose, as the Modmail module does. Prefix commands only run in servers.
Event Constructor Options
The configuration object passed to super(medusa, meta) accepts the following properties:
| Property | Type | Default | Description |
|---|---|---|---|
name | string | Required | Unique event identifier within the module. |
event | string | Required | Event identifier string (Discord.js event key or Medusa event name). |
source | string | "discord" | Event dispatcher source: "discord" for gateway events or "medusa" for Medusa internal events. |
once | boolean | false | When true, registers the listener using once() instead of on(). |
Event Methods
| Method | Signature | Description |
|---|---|---|
getName() | () => string | Returns the event name. |
getEvent() | () => string | Returns the event identifier string. |
getSource() | () => string | Returns the dispatcher source ("discord" or "medusa"). |
isOnce() | () => boolean | Returns whether the event triggers only once. |
run() | (...args) => Promise<void> | Invoked whenever the event fires with the payload arguments. |
Handling Discord Component Interactions
When users click buttons, select items from dropdown menus, or submit modal dialogs, Discord emits an interactionCreate event. Medusa parses valid custom IDs and populates interaction.customIdParsed:
import { Events } from "#medusa/modules";
import { Events as DiscordEvents, MessageFlags } from "discord.js";
export class InteractionCreateEvent extends Events {
constructor(medusa) {
super(medusa, {
name: "exampleInteractionCreate",
event: DiscordEvents.InteractionCreate,
once: false
});
}
async run(interaction) {
if (!interaction || !interaction.guildId) return;
if (interaction.isButton()) {
if (!interaction.customIdParsed || interaction.customIdParsed.module !== this.module) return;
const action = interaction.customIdParsed.name;
switch (action) {
case "confirm":
return interaction.reply({
embeds: [this.medusa.embeds.success("Confirmed.")],
flags: MessageFlags.Ephemeral
});
default:
return interaction.deferUpdate().catch(() => {});
}
}
if (interaction.isStringSelectMenu()) {
if (!interaction.customIdParsed || interaction.customIdParsed.module !== this.module) return;
const selected = interaction.values[0];
return interaction.reply({
embeds: [this.medusa.embeds.info("info", `Selected: ${selected}`)],
flags: MessageFlags.Ephemeral
});
}
if (interaction.isModalSubmit()) {
if (!interaction.customIdParsed || interaction.customIdParsed.module !== this.module) return;
const title = interaction.fields.getTextInputValue("title");
return interaction.reply({
embeds: [this.medusa.embeds.success(`Received title: ${title}`)],
flags: MessageFlags.Ephemeral
});
}
}
}Medusa Internal Events
Modules and framework components emit internal events across this.medusa.events whenever lifecycle phases complete or state changes occur. Any module or third-party addon can emit and listen to these events.
Emitting Events
The Modules and Handlers base classes expose direct emission methods that delegate to this.medusa.emitAsync:
await this.emitAsync("ticket.opened", { ticket: record, channel, opener });Listening via Event Classes
Event classes in src/events/ support Medusa events by declaring source: "medusa". Medusa automatically wires these to this.medusa.events during startup:
import { Events } from "#medusa/modules";
export class TicketOpenedEvent extends Events {
constructor(medusa) {
super(medusa, {
name: "exampleTicketOpened",
event: "ticket.opened",
source: "medusa"
});
}
async run({ ticket, channel, opener }) {
this.medusa.console.log(this.medusa.console.levels.info, `Ticket #${ticket.ticketId} opened by ${opener.username}`);
}
}Listening Programmatically
Modules can also subscribe directly inside onStartup():
this.medusa.events.on("ticket.opened", async ({ ticket, channel, opener }) => {
// React to event
});Framework Lifecycle Events
| Event | Payload | Description |
|---|---|---|
medusa.ready | None | Emitted once Medusa finishes initial boot and all modules are loaded. |
medusa.discord.ready | client | Emitted after the bots connect, after a bot restarts, and after modules move between bots. Listeners added in onStartup() run as their module, so they use the module's bot. |
medusa.discord.bot.ready | bot | Emitted once per bot when that bot connects. Used for per-bot work such as application emoji sync. |
module.registered | module | Emitted after a module registers its static packages. |
module.loaded | module | Emitted when a module finishes loading and executes onStartup(). |
<module>.ready | module | Per-module readiness event (e.g. ticket.ready, leveling.ready). |
modules.ready | loadedMap | Emitted after all registered modules have been loaded and models synchronized. |
module.failed | { folder, module, error } | Emitted when a module fails during registration or loading. |
module.deloaded | module | Emitted when a module deloads during shutdown. |
module.toggled | { id, guildId, enabled } | Emitted when a module is turned on or off for one server. |
<module>.shutdown | module | Per-module shutdown event (e.g. ticket.shutdown). |
Module Domain Events
Modules emit the following domain events when state changes occur:
| Module | Event | Payload | Description |
|---|---|---|---|
ticket | ticket.opened | { ticket, channel, opener, category, openedBy } | Fired when a new support ticket channel opens. |
ticket | ticket.closed | { ticket, channel, closer, auto, reason, silent } | Fired when a ticket is closed. |
ticket | ticket.claimed | { ticket, channel, staff } | Fired when staff claims a ticket. |
ticket | ticket.unclaimed | { ticket, channel, staff } | Fired when staff unclaims a ticket. |
moderation | moderation.banned | { guild, targetUser, moderator, reason, durationMs, caseId, record } | Fired when a member is banned. |
moderation | moderation.unbanned | { guild, targetUserId, moderator, reason, caseId, record } | Fired when a member is unbanned. |
moderation | moderation.kicked | { guild, member, moderator, reason, caseId, record } | Fired when a member is kicked. |
moderation | moderation.muted | { guild, member, moderator, durationMs, reason, expiresAt, caseId, record } | Fired when a member is timed out. |
moderation | moderation.unmuted | { guild, member, moderator, reason, caseId, record } | Fired when a timeout is removed. |
moderation | moderation.warned | { guild, member, moderator, reason, points, durationMs, caseId, warnId, record } | Fired when a warning is issued. |
moderation | moderation.warn_removed | { guild, warning, moderator, reason, caseId, record } | Fired when a warning is revoked. |
moderation | moderation.purged | { channel, amount, targetUser, moderator, count } | Fired when messages are bulk deleted. |
moderation | moderation.slowmode_set | { channel, seconds, moderator } | Fired when channel rate limit changes. |
moderation | moderation.channel_locked | { channel, reason, moderator } | Fired when a channel is locked. |
moderation | moderation.channel_unlocked | { channel, moderator } | Fired when a channel is unlocked. |
moderation | moderation.case_created | record | Fired on any moderation case creation. |
moderation | moderation.notice | { guild, user, action, reason, caseId, components } | Fired before a punishment DM is sent. Push component rows into components to attach buttons to the DM. The Appeals module uses it to add its appeal button to ban DMs. |
leveling | leveling.xp_added | { guild, userId, amount, source, xp, level, oldLevel } | Fired when a member earns experience points. |
leveling | leveling.leveled_up | { guild, userId, oldLevel, newLevel, xp } | Fired when a member advances in level. |
leveling | leveling.xp_removed | { guild, userId, amount, xp, level } | Fired when administrator deducts experience. |
leveling | leveling.xp_set | { guild, userId, xp, level } | Fired when experience is explicitly set. |
leveling | leveling.reset | { guildId, userId } | Fired when member leveling progress is cleared. |
economy | economy.balance_added | { guildId, userId, amount, type } | Fired when funds are credited. |
economy | economy.balance_removed | { guildId, userId, amount, type } | Fired when funds are debited. |
economy | economy.balance_set | { guildId, userId, amount, type } | Fired when balance is explicitly updated. |
economy | economy.deposited | { guildId, userId, amount } | Fired when currency moves from wallet to bank. |
economy | economy.withdrawn | { guildId, userId, amount } | Fired when currency moves from bank to wallet. |
economy | economy.transferred | { guildId, fromUserId, toUserId, amount } | Fired when currency is sent between members. |
giveaways | giveaways.started | { guild, channel, host, giveaway } | Fired when a giveaway commences. |
giveaways | giveaways.entered | { giveaway, user } | Fired when a user enters the giveaway pool. |
giveaways | giveaways.ended | { guild, giveaway, winners } | Fired when a giveaway concludes and draws winners. |
giveaways | giveaways.rerolled | { guild, giveaway, winners } | Fired when replacement winners are selected. |
suggestions | suggestions.submitted | { guild, member, text, category, suggestionId, messageId } | Fired when a suggestion is submitted. |
suggestions | suggestions.voted | { guild, user, messageId, direction } | Fired when a vote is recorded on a suggestion. |
suggestions | suggestions.status_updated | { guild, member, entry, status, reason } | Fired when staff changes suggestion status. |
counting | counting.succeeded | { guild, channel, user, count, highest, isRecord } | Fired when a valid count number is posted. |
counting | counting.failed | { guild, channel, user, evaluated, expected } | Fired when a broken count resets or warns. |
invites | invites.joined | { guild, member, inviterId, code, fake, credited } | Fired when a member joins via tracked invite. |
invites | invites.left | { guild, member, inviterId } | Fired when a member departs the guild. |
invites | invites.bonus_adjusted | { guild, userId, delta, before, after } | Fired when bonus invites are adjusted. |
invites | invites.reset | { guild, userId } | Fired when member invite statistics reset. |
join-to-create | join-to-create.channel_created | { channel, member } | Fired when a temporary voice channel spawns. |
join-to-create | join-to-create.channel_deleted | { channel, channelName } | Fired when an empty temporary channel deletes. |
join-to-create | join-to-create.channel_locked | { channel, member } | Fired when a temporary channel locks. |
join-to-create | join-to-create.channel_unlocked | { channel, member } | Fired when a temporary channel unlocks. |
alerts | alerts.dispatched | { sub, details, channel } | Fired when a livestream or upload alert dispatches. details holds title, url and thumbnail. |
alerts | alerts.subscription_added | { guildId, channelId, platform, streamer, actor } | Fired when a creator subscription is created. |
alerts | alerts.subscription_removed | { guildId, platform, streamer, actor } | Fired when a creator subscription is removed. |
Scheduled Clocks
Background schedules reside in src/clocks/ and load from every *.js file located directly in that directory. Each file exports a class extending Clocks from #medusa/modules.
import { Clocks } from "#medusa/modules";
export class CleanupClock extends Clocks {
constructor(medusa) {
super(medusa, {
name: "cleanup",
schedule: "0 * * * *"
});
}
async run() {
const handler = this.medusa.modules.handlers.get(this.module, "items");
if (!handler) return;
this.medusa.console.log(this.medusa.console.levels.info, "Running scheduled cleanup routine...");
await handler.purgeExpired();
}
}Clock Constructor Options
The configuration object passed to super(medusa, meta) accepts the following properties:
| Property | Type | Default | Description |
|---|---|---|---|
name | string | Required | Unique clock identifier within the module. |
schedule | string | Required | Standard 5-field cron expression string evaluated by croner. |
Standard Cron Syntax
The schedule string uses standard 5-field cron format:
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of the month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday)
│ │ │ │ │
* * * * *Common Cron Expressions
| Expression | Execution Interval |
|---|---|
* * * * * | Executes every minute. |
*/5 * * * * | Executes every 5 minutes. |
0 * * * * | Executes hourly at minute 0. |
0 0 * * * | Executes daily at midnight UTC. |
0 0 * * 0 | Executes weekly on Sunday at midnight UTC. |
Clock Lifecycle
When Medusa finishes loading all modules, ClockInterface.schedule() initializes a Cron instance for each registered clock. When Medusa stops or deloads modules, ClockInterface.stop() cancels all running cron timers to ensure clean memory shutdown.