A batteries-included, type-safe Discord.js framework with fluent builder APIs.
- 🔗 Fluent Builder Pattern - Chainable APIs for commands, events, and components
- 🛡️ Type-Safe - End-to-end TypeScript with no
anytypes - 🎯 Full Discord.js Coverage - Slash commands, context menus, buttons, select menus, modals, autocomplete
- 🔌 Plugin System - Extensible architecture with setup hooks
- 🛡️ Guards - Composable permission and condition checks for commands and components
- 📦 DI Container - Built-in dependency injection
- 🎨 Rich Builders - Embed, pagination, component, and collector builders
- 🚀 Auto-Discovery - Automatic loading of commands, events, and components
- 📁 Modular Imports - Import only what you need
- 📝 Comprehensive Helpers - Markdown, Discord utilities, and data manipulation
bun add roastery discord.js
# or
npm install roastery discord.jsimport { Roastery, command, event, Events } from "roastery";
const bot = Roastery.create({
token: process.env.DISCORD_TOKEN!,
clientId: process.env.DISCORD_CLIENT_ID!,
intents: Roastery.intents.common,
});
bot.registerCommand(
command("ping", "Ping the bot").execute(async (ctx) => {
await ctx.reply("Pong! 🏓");
}),
);
bot.registerEvent(
event(Events.ClientReady).execute(async (client) => {
console.log(`Ready as ${client.user.tag}`);
}),
);
bot.start();import { command } from "roastery/commands";
import { guildOnly } from "roastery/support/guards";
export default command("greet", "Greet a user")
.user("target", "User to greet", { required: true })
.string("message", "Custom greeting message")
.guard(guildOnly)
.execute(async (ctx) => {
const target = ctx.getUser("target", true);
const message = ctx.getString("message") ?? "Hello";
await ctx.reply(`${message}, ${target}!`);
});command("profile", "View user profile")
.string("username", "Filter by username", { required: false, minLength: 3, maxLength: 20 })
.user("target", "Specific user", { required: false })
.number("age", "Age filter", { minValue: 1, maxValue: 100, autocomplete: true })
.integer("level", "Level filter", { minValue: 1 })
.boolean("public", "Show publicly", { required: false })
.role("role", "Filter by role")
.channel("channel", "Specific channel", { channelTypes: [ChannelType.GuildText] })
.mentionable("entity", "User or role")
.attachment("file", "Attached file")
.execute(async (ctx) => {
// Handle command
});command("settings", "Manage settings")
.subcommand("view", "View current settings", (sub) =>
sub.execute(async (ctx) => {
await ctx.reply("Current settings: ...");
}),
)
.subcommandGroup("limits", "Rate limits", (group) =>
group.subcommand("set", "Set a limit", (sub) =>
sub
.string("key", "Limit type", { required: true })
.integer("value", "Limit value", { required: true })
.execute(async (ctx) => {
await ctx.success(`Limit updated!`);
}),
),
);const colors = ["red", "green", "blue", "yellow", "purple"];
command("color", "Pick a color")
.string("color", "Choose a color", { required: true, autocomplete: true })
.autocomplete(async (ctx) => {
const focused = ctx.focused.value.toString().toLowerCase();
const filtered = colors.filter((c) => c.startsWith(focused));
await ctx.respond(filtered.map((c) => ({ name: c, value: c })));
})
.execute(async (ctx) => {
const color = ctx.getString("color", true);
await ctx.success(`You chose ${color}!`);
});// Reply methods
await ctx.reply("Hello");
await ctx.reply({ content: "Hello", embeds: [embed] });
await ctx.defer();
await ctx.defer(true); // ephemeral
await ctx.editReply("Updated");
await ctx.deleteReply();
await ctx.followUp("Follow up message");
// Helper replies
await ctx.success("Done!");
await ctx.error("Something went wrong");
await ctx.info("Here's some info");
await ctx.warn("Be careful");
// Option getters
ctx.getString(name, required?)
ctx.getUser(name, required?)
ctx.getMember(name)
ctx.getNumber(name, required?)
ctx.getInteger(name, required?)
ctx.getBoolean(name, required?)
ctx.getRole(name, required?)
ctx.getChannel(name, required?)
ctx.getAttachment(name, required?)
ctx.getMentionable(name, required?)
// Subcommand info
ctx.getSubcommand()
ctx.getSubcommandGroup()
// Embed shortcut
await ctx.embed((embed) => embed.setTitle("Hello"));Guards protect commands and components with conditions:
| Guard | Description |
|---|---|
guildOnly |
Server-only commands |
dmOnly |
DM-only commands |
adminOnly |
Administrator permission required |
modOnly |
Moderator permissions required |
ownerOnly(...ids) |
Bot owner(s) only |
boosterOnly |
Server boosters only |
nsfwOnly |
NSFW channels only |
requireRole(...ids) |
Specific role(s) required |
requireAllRoles(...ids) |
All roles required |
requirePermission(perm) |
Specific permission required |
requireAnyPermission(...perms) |
Any permission required |
requireBotPermission(perm) |
Bot must have permission |
cooldown(seconds) |
Per-user cooldown |
guildCooldown(seconds) |
Per-guild cooldown |
globalCooldown(seconds) |
Global cooldown |
inChannel(...ids) |
Specific channels only |
notInChannel(...ids) |
Exclude channels |
inCategory(...ids) |
Specific categories only |
import { all, any, not } from "roastery/support/guards";
// All guards must pass (AND)
command("admin", "Admin only").guard(all(guildOnly, adminOnly)).execute(/* ... */);
// Any guard must pass (OR)
command("vip", "VIP command")
.guard(any(adminOnly, requireRole("VIP_ROLE_ID")))
.execute(/* ... */);
// Negate a guard
command("public", "Not in NSFW").guard(not(nsfwOnly)).execute(/* ... */);import type { Guard } from "roastery/types";
const isWeekend: Guard = async (ctx) => {
const day = new Date().getDay();
return day === 0 || day === 6 ? true : "This command only works on weekends!";
};
command("weekend", "Weekend only command").guard(isWeekend).execute(/* ... */);import { button } from "roastery/components";
import { Button, Row } from "roastery/builders";
export const confirmButton = button("confirm:.*").execute(async (ctx) => {
await ctx.reply("Confirmed!");
});
// In command
await ctx.reply({
content: "Click to confirm",
components: [
Row.buttons(
Button.success("confirm:123", "Confirm"),
Button.danger("cancel", "Cancel").disabled(),
),
],
});Button.create(customId)
Button.primary(customId, label)
Button.secondary(customId, label)
Button.success(customId, label)
Button.danger(customId, label)
Button.link(url, label)
// Chainable methods
.primary() / .secondary() / .success() / .danger() / .link() / .premium()
.label(label)
.emoji(emoji)
.disabled(true/false)
.url(url)import { selectMenu } from "roastery/components";
import {
StringSelect,
UserSelect,
RoleSelect,
ChannelSelect,
MentionableSelect,
Row,
} from "roastery/builders";
export const colorSelect = selectMenu("color-select").execute(async (ctx) => {
const [color] = ctx.values as string[];
await ctx.reply(`You selected: ${color}`);
});
// In command
await ctx.reply({
content: "Pick a color",
components: [
Row.stringSelect(
StringSelect.create("color-select")
.placeholder("Choose a color")
.option("Red", "red", { description: "Warm color", emoji: "🔴" })
.option("Green", "green")
.option("Blue", "blue")
.maxValues(3),
),
],
});// String Select
StringSelect.create(customId)
.option(label, value, { description?, emoji?, default? })
.placeholder(text)
.minValues(count)
.maxValues(count)
.disabled(true/false)
// User/Role/Channel/Mentionable Select
UserSelect.create(customId)
RoleSelect.create(customId)
ChannelSelect.create(customId).channelTypes(...types)
MentionableSelect.create(customId)import { modal } from "roastery/components";
import { Modal } from "roastery/builders";
export const feedbackModal = modal("feedback").execute(async (ctx) => {
const title = ctx.getField("title");
const description = ctx.getField("description");
await ctx.success(`Feedback received: ${title}`);
});
// Show modal
await ctx.interaction.showModal(
Modal.create("feedback", "Submit Feedback")
.shortField("title", "Title", { required: true, placeholder: "Short title" })
.paragraphField("description", "Description", {
required: true,
minLength: 50,
placeholder: "Detailed description...",
}),
);ctx.getField(customId)
ctx.getFieldValue(customId) // returns null if not found
await ctx.reply(content)
await ctx.defer(ephemeral?)
await ctx.success(message)
await ctx.error(message)import { userContextMenu, messageContextMenu } from "roastery/commands";
// Right-click on user
export const userInfo = userContextMenu("Get User Info")
.guard(guildOnly)
.execute(async (ctx) => {
await ctx.reply(`User: ${ctx.targetUser.tag}`);
if (ctx.targetMember) {
await ctx.followUp(`Member since: ${ctx.targetMember.joinedAt}`);
}
});
// Right-click on message
export const reportMessage = messageContextMenu("Report Message").execute(async (ctx) => {
await ctx.success(`Reported message from ${ctx.targetMessage.author.tag}`);
});import { event } from "roastery/events";
import { Events } from "roastery/discord";
// Basic event
export default event(Events.GuildMemberAdd).execute(async (member, client) => {
const channel = member.guild.systemChannel;
if (channel) {
await channel.send(`Welcome, ${member}!`);
}
});
// One-time event
export const ready = event(Events.ClientReady)
.runOnce()
.execute(async (client) => {
console.log(`Ready as ${client.user.tag}`);
});import { Embed, embed } from "roastery/builders";
// Fluent builder
const myEmbed = embed()
.setTitle("Hello")
.setDescription("World")
.primary()
.field("Field 1", "Value 1", true)
.field("Field 2", "Value 2", true)
.inlineField("Inline", "Value")
.blankField()
.authorUser(user)
.footerText("Footer text", "https://icon.url")
.now()
.image("https://image.url")
.thumbnail("https://thumb.url");
// Preset embeds
const success = Embed.success("Done!", "Operation completed");
const error = Embed.error("Failed", "Something went wrong");
const warning = Embed.warning("Warning", "Be careful");
const info = Embed.info("Info", "Here's some information");
// Create from data
const fromData = Embed.from({ title: "Existing", description: "Data" });
await ctx.reply({ embeds: [myEmbed] });embed()
.setTitle(title)
.setDescription(description)
.primary() / .secondary() / .success() / .danger() / .warning()
.hex(color)
.field(name, value, inline?)
.inlineField(name, value)
.blankField(inline?)
.clearFields()
.authorUser(user, url?)
.footerText(text, iconURL?)
.now() // sets timestamp to now
.image(url)
.thumbnail(url)import { Pagination } from "roastery/builders";
const pages = items.map((item, i) =>
Embed.create()
.setTitle(`Item ${i + 1}`)
.setDescription(item.description),
);
// Static pages
await Pagination.fromEmbeds(ctx.interaction, pages, {
ephemeral: true,
timeout: 120000,
showPageNumbers: true,
firstLabel: "⏮",
prevLabel: "◀",
nextLabel: "▶",
lastLabel: "⏭",
deleteOnEnd: false,
}).start();
// Dynamic pages
await Pagination.dynamic(
ctx.interaction,
async (page, total) => {
const data = await fetchPage(page);
return {
embed: Embed.create()
.setTitle(`Page ${page + 1}`)
.setDescription(data),
};
},
totalPages,
options,
).start();
// Create from any pages
await Pagination.create(ctx.interaction, pages, options).start();import {
awaitButton,
awaitStringSelect,
awaitModal,
awaitMessage,
createMessageCollector,
} from "roastery/builders";
// Await specific interactions
const buttonInteraction = await awaitButton(message, {
time: 60000,
filter: (i) => i.user.id === userId,
});
const selectInteraction = await awaitStringSelect(message, options);
const modalSubmit = await awaitModal(message, options);
const messageResponse = await awaitMessage(message, {
time: 30000,
filter: (m) => m.author.id === userId,
});
// Create collectors
const collector = createMessageCollector(message, {
time: 60000,
max: 5,
filter: (m) => m.content.startsWith("!"),
});
collector.on("collect", (msg) => {
console.log(`Collected: ${msg.content}`);
});import {
userMention,
channelMention,
roleMention,
slashMention,
customEmoji,
timestamp,
escapeMarkdown,
} from "roastery/support/helpers";
userMention(userId) // <@userId>
channelMention(channelId) // <#channelId>
roleMention(roleId) // <@&roleId>
slashMention(commandName, commandId) // </commandName>
customEmoji(name, id, animated?) // <:name:id>
timestamp(date?, style?) // <t:unix:style>
escapeMarkdown(text)
escapeCodeBlock(text)
codeBlock(content, language?)
inlineCode(content)
bold(text)
italic(text)
strikethrough(text)
underline(text)
spoiler(text)
quote(text)
blockQuote(text)
hyperlink(text, url)
hiddenLink(url)import {
randomInt,
randomElement,
shuffle,
chunk,
unique,
uniqueBy,
groupBy,
pick,
omit,
clamp,
truncate,
capitalize,
titleCase,
pluralize,
formatNumber,
formatDuration,
formatRelativeTime,
sleep,
parseHexColor,
isSnowflake,
} from "roastery/support/helpers";
randomInt(min, max)
randomElement(array)
shuffle(array)
chunk(array, size)
unique(array)
uniqueBy(array, (item) => item.key)
groupBy(array, (item) => item.category)
pick(obj, "key1", "key2")
omit(obj, "key1", "key2")
clamp(value, min, max)
truncate(str, length, suffix?)
capitalize(str)
titleCase(str)
pluralize(count, "singular", "plural")
formatNumber(1234, "en-US") // "1,234"
formatDuration(60000) // "1m"
formatRelativeTime(date)
sleep(ms)
parseHexColor("#ff0000")
isSnowflake(value)import {
RoasteryError,
CommandError,
ValidationError,
GuardError,
ComponentError,
ConfigurationError,
} from "roastery/support/errors";
// Command errors with ephemeral option
throw new CommandError("Something went wrong", true);
throw new CommandError("Public error message");
// Validation error
throw new ValidationError("Invalid input");
// Component error
throw new ComponentError("Interaction failed", true);
// Configuration error
throw new ConfigurationError("Missing required config");import {
assertDefined,
assertGuild,
assertMember,
assertChannel,
fail,
failPublic,
} from "roastery/support/errors";
assertDefined(value, "Value is required");
assertGuild(ctx); // throws if not in guild
assertMember(ctx); // throws if no member
assertChannel(ctx); // throws if no channel
fail("This should not happen");
failPublic("User-facing error message");import { container } from "roastery/core";
// Register services
container.bind("database", () => new Database());
container.singleton("cache", () => new Cache());
container.instance("config", { apiKey: "xxx" });
// Resolve services
const db = container.make<Database>("database");
const cache = container.make<Cache>("cache");
// Safe resolution
const maybeDb = container.tryMake<Database>("database"); // T | undefined
// Check and clear
container.has("database");
container.flush(); // clear allimport { logger, createLogger } from "roastery/core";
// Global logger
logger.info("Information");
logger.success("Operation completed");
logger.warn("Warning message");
logger.error("Error occurred", error);
logger.debug("Debug info");
logger.ready("Bot is ready!");
logger.command("ping");
logger.event("guildCreate");
logger.component("submit-btn");
logger.box("Title", "Boxed message");
// Custom logger
const customLogger = createLogger("MyModule");
customLogger.info("Module info");const bot = Roastery.create(config)
.commands("./src/commands")
.events("./src/events")
.components("./src/components");
await bot.start();Files are auto-loaded from directories. Export command/event/component as default:
// src/commands/ping.ts
import { command } from "roastery/commands";
export default command("ping", "Ping!").execute(async (ctx) => ctx.reply("Pong!"));
// src/events/ready.ts
import { event } from "roastery/events";
import { Events } from "roastery/discord";
export default event(Events.ClientReady).execute(async (client) => {
console.log(`Ready as ${client.user.tag}`);
});
// src/components/confirm.ts
import { button } from "roastery/components";
export default button("confirm:.*").execute(async (ctx) => {
await ctx.reply("Confirmed!");
});import { Roastery, type Plugin } from "roastery";
const loggingPlugin: Plugin = {
name: "logging",
version: "1.0.0",
setup: (roastery) => {
roastery.onInteraction(async (interaction) => {
console.log(`Interaction: ${interaction.id}`);
});
roastery.onReady((client) => {
console.log(`Plugin loaded on ${client.user.tag}`);
});
roastery.onError((error, context) => {
console.error(`[${context}] ${error.message}`);
});
},
};
const bot = Roastery.create(config).use(loggingPlugin);// Configuration
Roastery.create(config: RoasteryOptions): Roastery
// Intents
Roastery.intents.guilds
Roastery.intents.members
Roastery.intents.moderation
// ... all GatewayIntentBits available
// Preset intents
Roastery.intents.all // All intents
Roastery.intents.common // Common intents for most bots
Roastery.intents.minimal // Just Guilds
// Methods
bot.commands(path) // Set commands directory
bot.events(path) // Set events directory
bot.components(path) // Set components directory
bot.registerCommand(cmd) // Register single command
bot.registerCommands(...cmds) // Register multiple
bot.registerUserContextMenu(menu)
bot.registerMessageContextMenu(menu)
bot.registerButton(handler)
bot.registerSelectMenu(handler)
bot.registerModal(handler)
bot.registerEvent(event)
bot.onReady(callback)
bot.onError(callback)
bot.onInteraction(callback)
bot.use(plugin)
bot.getClient()
bot.getCommands()
bot.isDebug()
bot.start() // Start the bot
bot.stop() // Shutdown gracefully
bot.deployCommands() // Deploy commands to DiscordMIT