Hooks
Reject or rewrite a write before it happens.
Events tell you what already happened. Hooks run before the server writes, and can change or refuse it. This is where moderation, filtering, scanning, naming conventions, allowlists and gates belong.
There are five:
| Hook | Runs before | Can change |
|---|---|---|
onBeforeMessageSave | A message is stored, on both sends and edits | content |
onBeforeFileSave | Any file is stored: attachments, avatars, banners, emojis, logo | bytes, originalName |
onBeforeChannelCreate | A channel is created, by a person or a plugin | name |
onBeforeVoiceJoin | Someone joins a voice channel | nothing: a gate |
onBeforeLogin | A login is accepted, after the password is checked | nothing: a gate |
The Three Answers
Every hook answers the same way:
ctx.hooks.onBeforeMessageSave(async (payload) => {
if (payload.textContent.includes("badword")) {
return { reject: "That word is not allowed here." };
}
if (payload.textContent.includes("http://")) {
return { update: { content: payload.content.replaceAll("http://", "https://") } };
}
// return nothing to allow it unchanged
});- Return nothing to allow it.
- Return
{ update }to change what gets written. - Return
{ reject: 'reason' }to refuse, with a reason the user sees. Reasons are capped at 200 characters.
Mutating the payload does nothing: each handler gets its own copy.
A hook that throws is treated as a broken plugin: the error is logged, the
user gets a generic message, and the operation fails closed. Return
{ reject } when you mean to refuse.
Hooks from several plugins run in order, each seeing the previous one's result, and the first refusal wins. Each handler is given 30 seconds. Every hook is unregistered when the plugin unloads.
A slow hook is a slow send, upload or login for the user. onBeforeLogin runs
on an unauthenticated request, so a slow one slows down everyone signing in.
Messages
ctx.hooks.onBeforeMessageSave(async ({ content, textContent, channelId, userId, type, messageId }) => {});| Field | Notes |
|---|---|
content | Sanitized HTML, which is also what an update must return |
textContent | The plain text of that HTML, for matching against |
type | MessageSaveType.CREATE or MessageSaveType.EDIT |
messageId | Present on an edit, absent on a send |
Match on the text and return HTML. A replacement is sanitized again, and one that leaves nothing behind is refused. Messages sent by plugins with ctx.messages.send do not run this hook.
Files
ctx.hooks.onBeforeFileSave(async ({ readBytes, originalName, extension, size, userId, type }) => {});type is a FileSaveType: MESSAGE, AVATAR, BANNER, EMOJI or SERVER_LOGO. extension is lowercased, with the dot.
The file's contents come from readBytes(), not from a field, because reading loads the whole file into memory. A hook that decides on the name, the extension or the size never pays for it:
import { FileSaveType, type PluginContext } from "@sharkord/plugin-sdk";
const onLoad = (ctx: PluginContext) => {
// decides on metadata alone, so it never reads the file
ctx.hooks.onBeforeFileSave(async ({ extension, size }) => {
if (extension === ".exe") return { reject: "Executables are not allowed." };
if (size > 10_000_000) return { reject: "That file is too large." };
});
ctx.hooks.onBeforeFileSave(async ({ readBytes, type }) => {
if (type !== FileSaveType.AVATAR) return;
return { update: { bytes: await convertToWebp(await readBytes()) } };
});
};Reading twice costs nothing, and once an earlier hook has replaced the file, readBytes() returns the replacement. It is only callable while your hook runs: it reads the temporary file, which is gone once the save finishes, so do not hold onto it.
The hook runs before the size and quota checks, so a replacement is what gets measured, not the original. Sharkord still decides the final stored name: you control the file, not where it lands.
Channels
ctx.hooks.onBeforeChannelCreate(async ({ name, type, categoryId, userId }) => {
return { update: { name: name.toLowerCase().replaceAll(" ", "-") } };
});userId is absent when a plugin is creating the channel, including your own. A replacement name that is blank is refused.
Voice and Login
Both are gates: allow or refuse, nothing to change.
ctx.hooks.onBeforeVoiceJoin(async ({ channelId, userId, movedByModerator }) => {
if (movedByModerator) return;
if (await isBanned(userId)) return { reject: "You cannot join this channel." };
});Check movedByModerator before refusing, or a moderator moving someone will be blocked by rules meant for the user.
ctx.hooks.onBeforeLogin(async ({ identity, ip, isExistingUser }) => {
if (!isExistingUser && !invited(identity)) {
return { reject: "Registration is invite only." };
}
});ip is undefined when it cannot be determined, so treat that as unknown, not as trusted. isExistingUser is false when this login would create the account, which is what makes registration rules possible.