Commands are the primary way users interact with your bot. Each file in src/commands/ exports a single command() definition, and Popii registers it as a slash command automatically.
// src/commands/hello.ts
import { command } from "popii";
export default command({
name: "hello",
description: "Say hello back",
async do(pop) {
await pop.reply(`Hello, ${pop.user.username}!`);
}
});
Nested folders become subcommand groups automatically:
src/commands/
config/
set.ts → /config set
reset.ts → /config reset
admin/
ban.ts → /admin ban
ping.ts → /ping
Use options to declare slash command parameters. The values are accessed via pop.options:
import { command } from "popii";
import { ApplicationCommandOptionType } from "discord.js";
export default command({
name: "greet",
description: "Greet a user",
options: [
{
name: "user",
description: "Who to greet",
type: ApplicationCommandOptionType.User,
required: true,
},
{
name: "message",
description: "Custom greeting message",
type: ApplicationCommandOptionType.String,
}
],
async do(pop) {
const target = pop.options.getUser("user")!;
const msg = pop.options.getString("message") ?? "Hello!";
await pop.reply(`${msg}, ${target}!`);
}
});
Pass a schema to validate and type the options object as pop.input:
import { command } from "popii";
import { z } from "zod";
import { ApplicationCommandOptionType } from "discord.js";
export default command({
name: "echo",
description: "Repeats your text",
options: [{ name: "text", description: "Text to echo", type: ApplicationCommandOptionType.String, required: true }],
schema: z.object({ text: z.string().min(1).max(200) }),
async do(pop) {
await pop.reply(pop.input.text); // fully typed
}
});
export default command({
name: "daily",
description: "Claim your daily reward",
cooldown: { ms: 86_400_000, scope: "user" }, // once per 24h per user
async do(pop) {
await pop.reply("Here's your daily reward!");
}
});
scope can be "user" (default), "guild", or "global".
import { PermissionFlagsBits } from "discord.js";
export default command({
name: "kick",
description: "Kick a member",
guildOnly: true,
permissions: ["KickMembers"], // requires this Discord permission
allowedRoles: ["123456789012345678"], // or restrict to specific role IDs
// ...
});
export default command({
ephemeral: true, // all replies from this command are ephemeral by default
// ...
async do(pop) {
await pop.reply("Only you can see this.");
}
});
You can also pass ephemeral: true inside pop.reply() for per-reply control.
For commands that take longer than ~3 seconds, defer first:
async do(pop) {
await pop.defer();
const result = await someLongOperation();
await pop.reply(result);
}
import { ApplicationCommandType } from "discord.js";
export default command({
name: "Report Message",
type: ApplicationCommandType.Message,
async do(pop) {
const msg = pop.targetMessage!;
await pop.reply({ content: `Reported: "${msg.content}"`, ephemeral: true });
}
});
export default command({
name: "ping",
description: "Legacy prefix command",
text: true, // enables !ping
slash: false, // disable slash registration
async do(pop) {
await pop.reply("Pong!");
}
});
Set prefix in popiiClient() config to enable text commands globally.
From the web dashboard or programmatically:
client._disabledCommands.add("ping");
Disabled commands still appear in Discord's command list but return an "unavailable" message when invoked.