Sharkord
Plugins

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:

HookRuns beforeCan change
onBeforeMessageSaveA message is stored, on both sends and editscontent
onBeforeFileSaveAny file is stored: attachments, avatars, banners, emojis, logobytes, originalName
onBeforeChannelCreateA channel is created, by a person or a pluginname
onBeforeVoiceJoinSomeone joins a voice channelnothing: a gate
onBeforeLoginA login is accepted, after the password is checkednothing: 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.

Throwing is not a refusal

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.

Hooks are on the request path

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 }) => {});
FieldNotes
contentSanitized HTML, which is also what an update must return
textContentThe plain text of that HTML, for matching against
typeMessageSaveType.CREATE or MessageSaveType.EDIT
messageIdPresent 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.

On this page