Commands
Slash commands users can run from the chat.
Commands are the simplest way to give users something to do. They appear when a user types / in the message box, and in the plugin's command dialog in the server settings. Running one needs the USE_PLUGINS permission.
Register a Command
import { Permission, type PluginContext } from "@sharkord/plugin-sdk";
import type { TPlugin } from "../types";
const onLoad = (ctx: PluginContext<TPlugin>) => {
ctx.commands.register({
name: "hello",
description: "Greets someone.",
args: [{ name: "name", type: "string", required: true }],
requires: Permission.SEND_MESSAGES,
executes: async (invoker, args) =>
`Hello, ${args.name}! Your user ID is ${invoker.userId}.`,
});
};With a contract passed to PluginContext, name has to be one of its commands keys, and the args and return type of executes follow from it. Without one, both are unknown.
A plugin can register at most 100 commands. Registering the same name twice overwrites the first and logs an error. Commands are addressed by bare name in chat, so if two plugins claim the same one, the first to register keeps it and the loser is told in its log.
Arguments
args describes the fields users fill in. Each entry accepts:
| Field | Type | Notes |
|---|---|---|
name | string | Required. What the user types. |
type | "string" | "number" | "boolean" | Required. |
description | string | Shown in the interface. |
required | boolean | Defaults to optional. |
sensitive | boolean | The chip stores **** instead of the value. |
args: [
{
name: "token",
description: "API token for the external service.",
type: "string",
required: true,
sensitive: true,
},
];A sensitive argument never reaches the message content: the chip is written with **** in its place, so it is not in the channel for anyone to scroll back to. It is still sent to the server, still passed to your handler, and still recorded in the server activity log.
Arguments Are Validated
What you declare in args is enforced before your handler runs. A command whose argument is declared number never receives NaN, one declared required never receives undefined, and keys you did not declare are stripped.
| Declared | Accepted |
|---|---|
string | a string |
number | a number, or a string that parses to one |
boolean | true / false, or the strings "true" / "false" |
Both invocation paths go through the same check, so /roll abc typed in chat and { sides: "abc" } posted by your own UI fail identically, before the plugin is called. The user sees the reason (/roll: sides expected number, received nan) and nothing is written to your plugin's log, because a rejected argument is the caller's mistake, not yours.
This is the one place a declaration is enforced for you. It does not extend to anything else:
- A command that declares no
argsis not checked at all, and its raw object is passed through as-is. - Actions are never checked. They declare no argument shape, so the payload arrives exactly as sent.
- There is no range, length, pattern or set-of-choices. A
numbercan still be-1or1e9.
So the contract is still compile-time typing, and everything past the declared type is yours to validate.
The Invoker
The first handler argument describes who ran the command, and from where:
{
userId: number;
source: "chat" | "api";
channelId?: number;
parentMessageId?: number;
messageId?: number;
currentVoiceChannelId?: number;
locale: string;
}| Field | What it is |
|---|---|
userId | Who ran it |
source | chat when typed into a channel, api when a plugin's own UI called it |
channelId | Where it came from: the channel it was typed in, or the one the caller had open. Absent when they had none |
parentMessageId | The thread it was typed in, when it was one. Chat invocations only |
messageId | The command's own message, for replying to it. Chat invocations only |
currentVoiceChannelId | The voice channel they are connected to, if any |
locale | The language their client is showing |
Every id here is one the server proved the caller can reach, so it is safe to write to: a channelId sent by a plugin's UI is checked against that user's channel permissions before your handler runs. locale is the exception. It is whatever the client said when the session started, so a user who switches language mid-session is still reported as the language they joined with, and it is worth nothing beyond picking the language of a reply. One of en, cs, es, fr, it, ru, zh, defaulting to en.
currentVoiceChannelId is what a music or soundboard plugin uses to know where to play.
source matters because the same command arrives both ways. A chat invocation renders as a public chip in the channel; an api one, including from the Commands tab in the server settings, answers the caller alone.
Answering into the thread the command came from means passing parentMessageId back:
executes: async (invoker) => {
if (invoker.channelId) {
await ctx.messages.send(invoker.channelId, "<p>working on it</p>", {
parentMessageId: invoker.parentMessageId,
replyToMessageId: invoker.messageId,
});
}
return "Started.";
};Leave parentMessageId out and the reply lands in the channel instead of the thread.
Who Can Run It
requires names a permission, not a role, because roles are per install and a plugin cannot know them. It is the default that applies while no admin has configured the command in the plugin's permissions tab.
ctx.commands.register({
name: "purge",
requires: Permission.MANAGE_MESSAGES,
executes: async (invoker, args) => { /* ... */ },
});Two things follow from that:
- A declaration can only narrow. No declaration means everyone with
USE_PLUGINS, sorequirescannot grant more access than a user already has. - It is a default, not a guarantee. An admin with
MANAGE_PLUGIN_PERMISSIONScan override it in either direction, including opening the command to everyone. If a command must never run for the wrong caller, checkctx.permissions.userCaninsideexecutesand refuse. That check is the guarantee.
Nothing is written to the database when a plugin loads: a stored rule exists only once an admin makes a decision, which is why changing requires in an update takes effect on its own, and why resetting a rule in the tab returns it to your declaration.
The Response
Whatever you return goes back to the caller and is rendered as a chip in the channel, so the answer is public. Return a string unless you have a reason not to.
A handler that runs longer is aborted and the user gets an error. For slow
work, return immediately and post the result later with
ctx.messages.send.
Command executions are rate limited (60 per minute per user by default, rateLimiters.pluginExecute in config.ini) and recorded in the server activity log, arguments included. They are never written to the plugin's own log, which anyone who can manage plugins can read.
See Actions for server functions called from your own UI instead of by a user typing.