Skip to content

(WIP) A batteries-included, type-safe Discord.js framework with fluent builder APIs.

License

Notifications You must be signed in to change notification settings

powroom/roastery

Repository files navigation

Roastery ☕

Discord

A batteries-included, type-safe Discord.js framework with fluent builder APIs.

Features

  • 🔗 Fluent Builder Pattern - Chainable APIs for commands, events, and components
  • 🛡️ Type-Safe - End-to-end TypeScript with no any types
  • 🎯 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

Installation

bun add roastery discord.js
# or
npm install roastery discord.js

Quick Start

import { 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();

Commands

Basic Command

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 Options

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
  });

Subcommands

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!`);
        }),
    ),
  );

Autocomplete

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}!`);
  });

Command Context Methods

// 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

Guards protect commands and components with conditions:

Available Guards

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

Composition Guards

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(/* ... */);

Custom Guards

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(/* ... */);

Components

Buttons

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 Builder

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)

Select Menus

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),
    ),
  ],
});

Select Builders

// 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)

Modals

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...",
    }),
);

Modal Context Methods

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)

Context Menus

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}`);
});

Events

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}`);
  });

Embeds

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 Methods

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)

Pagination

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();

Collectors

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}`);
});

Helpers

Discord Utilities

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)

Data Utilities

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)

Error Handling

Error Types

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");

Assertions

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");

Dependency Injection

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 all

Logging

import { 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");

Auto-Discovery

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!");
});

Plugins

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);

Roastery API

// 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 Discord

License

MIT

About

(WIP) A batteries-included, type-safe Discord.js framework with fluent builder APIs.

Resources

License

Stars

Watchers

Forks