baff.gg Docs Open baff.gg Developer Portal

baff.js

The JavaScript library for baff.gg bots, with slash commands, components, embeds and collectors, and no dependencies.

baff.js is the JavaScript and TypeScript library for baff.gg bots. It wraps the REST API and the gateway, so you write interaction.reply('Pong!') instead of building requests by hand. If you've used discord.js, most of it will look familiar: baff.gg calls guilds servers, and names follow baff.gg's gateway (serverCreate, memberUpdate, ServerMember).

New to bots? Build your first bot sets up the application and token and writes a /ping command. This page is the full guide to the library.

What it does for you

  • Checks before it sends. Commands, embeds and components are validated as you build them, against the server's own limits, so a mistake throws an error that names the field.
  • Handles rate limits and reconnects. It waits out 429 answers, spaces message sends, retries safe requests, and reconnects the gateway with a growing delay.
  • Keeps a cache. Servers, channels, members and messages are there as server.channels.cache, message.member and so on, without a request.
  • Is typed and has no dependencies. TypeScript types ship in the package. It uses the runtime's own fetch, FormData and WebSocket, as ES modules or CommonJS.

Install

Shell
npm install baff.js

Or bun add baff.js, pnpm add baff.js, yarn add baff.js.

It needs Node.js 22 or newer, Bun or Deno, because the gateway uses the runtime's built-in WebSocket. On Node.js 20, start Node with --experimental-websocket, or install ws and pass it in: new Client({ WebSocket }).

The client

JavaScript
import { Client } from 'baff.js';

const client = new Client({ token: process.env.BOT_TOKEN, applicationId: process.env.APP_ID });

client.once('ready', () => console.log(`Logged in as ${client.user.username}`));

await client.login();

applicationId is the Application ID from the portal's General Information page. baff.gg doesn't tell a bot its own application id, so pass it in when you register commands or build install links. Build your first bot has the complete /ping example.

Slash commands

SlashCommandBuilder follows the registration rules and throws an error naming the problem when something is off, before any request is made.

JavaScript
const partner = new SlashCommandBuilder()
	.setName('partner')
	.setDescription('Manage partnered servers')
	.addSubcommand((sub) =>
		sub
			.setName('add')
			.setDescription('Partner a server')
			.addStringOption((option) => option.setName('server').setDescription('Server id, invite or name').setRequired(true))
			.addStringOption((option) => option.setName('note').setDescription('Why'))
	)
	.addSubcommand((sub) => sub.setName('list').setDescription('List partnered servers'));

Required options must come before optional ones, a command holds either subcommands or plain options, and names are 1 to 32 lowercase letters, numbers, - or _. Option types are string, integer, number, boolean, user, channel and role. baff.gg has no choices or autocomplete, so the builder has none either.

Read what was typed with interaction.options:

JavaScript
const server = interaction.options.getString('server', true); // throws when missing
const note = interaction.options.getString('note');           // string or null
const sub = interaction.options.getSubcommand();              // 'add' or 'list'

User, role and channel options arrive as ids: getUserId, getRoleId, getChannelId. getUser, getRole and getChannel give the cached object or null, and await options.fetchUser(name) loads it.

Register commands for one server with server.commands.set([...]). Prefix commands (!help) use PrefixCommandBuilder.

Replying

You have 3 seconds to answer or defer.

JavaScript
await interaction.reply({ content: 'Only you can see this', ephemeral: true });

await interaction.deferReply();        // shows "Bot is thinking..."
await interaction.editReply('Done');   // the real answer after a defer
await interaction.followUp('More');    // up to 15 minutes later

interaction.memberPermissions and interaction.appPermissions hold what the person and your bot may do in that channel, overrides already applied, so check them instead of fetching roles.

Two limits come from the API, not from the library. An ephemeral reply is not stored, so it cannot be edited or deleted afterwards. After the first answer, editReply and deleteReply on a command find the reply message in the channel by command name and author, so if one person runs the same command several times at once in the same channel, the wrong message can be picked.

Components and modals

JavaScript
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } from 'baff.js';

await interaction.reply({
	content: 'Ready for the raid?',
	components: [
		new ActionRowBuilder().addComponents(
			new ButtonBuilder().setCustomId('raid:join').setLabel("I'm in").setStyle(ButtonStyle.Success),
			new ButtonBuilder().setLabel('Raid guide').setURL('https://example.com/guide')
		),
		new ActionRowBuilder().addComponents(
			new StringSelectMenuBuilder().setCustomId('raid:role').addOptions({ label: 'Tank', value: 'tank' }, { label: 'Healer', value: 'healer' })
		)
	]
});

A click or a choice arrives as a new interaction with its own 3 seconds:

JavaScript
client.on('interactionCreate', async (interaction) => {
	if (interaction.isButton() && interaction.customId === 'raid:join') {
		await interaction.update({ content: `${interaction.user} joined`, components: [] });
	} else if (interaction.isStringSelectMenu()) {
		await interaction.reply({ content: `Role: ${interaction.values[0]}`, ephemeral: true });
	}
});

update edits the message the component is on. deferUpdate acknowledges the click and lets you edit with editReply within 15 minutes. User, role, mentionable and channel select menus (UserSelectMenuBuilder and friends) give you interaction.users, roles or channels.

To open a form, answer a command or a click with showModal, then read the submit:

JavaScript
import { ModalBuilder, TextInputBuilder } from 'baff.js';

await interaction.showModal(
	new ModalBuilder()
		.setCustomId('feedback')
		.setTitle('Send feedback')
		.addComponents(
			new TextInputBuilder().setCustomId('topic').setLabel('Topic').setMaxLength(60),
			new TextInputBuilder().setCustomId('details').setLabel('Details').setStyle('paragraph').setRequired(false)
		)
);

// later:
if (interaction.isModalSubmit()) {
	const topic = interaction.fields.getTextInputValue('topic');
}

Layouts with components v2

ContainerBuilder, SectionBuilder, TextDisplayBuilder, ThumbnailBuilder, MediaGalleryBuilder and SeparatorBuilder build a message entirely from components. Pass one as a top-level component and the IsComponentsV2 flag is added for you. Such a message cannot also have content or embeds.

JavaScript
import { ContainerBuilder, SectionBuilder, SeparatorBuilder, TextDisplayBuilder, ThumbnailBuilder, Colors } from 'baff.js';

await channel.send({
	components: [
		new ContainerBuilder()
			.setAccentColor(Colors.Baff)
			.addComponents(
				new SectionBuilder()
					.addTextDisplayComponents('**Patch 1.4** is out', 'New map: Harbor.')
					.setThumbnailAccessory(new ThumbnailBuilder().setURL('https://example.com/harbor.png')),
				new SeparatorBuilder(),
				new TextDisplayBuilder('Read the notes on the website.')
			)
	]
});

The component limits (5 rows, 40 components, 4000 characters of text, unique custom ids) are checked when the message is built.

Embeds

JavaScript
import { EmbedBuilder, Colors } from 'baff.js';

const embed = new EmbedBuilder()
	.setTitle('Server status')
	.setDescription('All systems are **running**.')
	.setColor(Colors.Baff)
	.addFields({ name: 'API', value: 'Online', inline: true }, { name: 'Gateway', value: '42 ms', inline: true })
	.setFooter({ text: 'Checked every minute' })
	.setTimestamp();

await channel.send({ embeds: [embed] });

EmbedBuilder checks the embed limits: titles up to 256 characters, descriptions up to 4096, 25 fields, 10 embeds and 6000 characters across all embeds of a message. Plain embed objects work too.

Messages can carry files as well: files: [new AttachmentBuilder(buffer, { name: 'chart.png' })].

Collectors

Collectors wait for messages or clicks:

JavaScript
// The next message from this person, or a rejection after 30 seconds.
const replies = await channel.awaitMessages({ filter: (m) => m.author.id === userId, max: 1, time: 30_000, errors: ['time'] });

// A click on a message's buttons.
const click = await message.awaitMessageComponent({ time: 60_000, filter: (i) => i.user.id === userId });

// Several clicks.
const collector = message.createMessageComponentCollector({ time: 60_000 });
collector.on('collect', (interaction) => interaction.deferUpdate());
collector.on('end', (collected, reason) => console.log(collected.size, reason));

When a collector ends for a reason listed in errors, awaitMessages rejects with a CollectorError carrying collected and reason. awaitMessageComponent rejects on timeout.

HTTP interactions

With an Interactions Endpoint URL set in the portal, baff.gg POSTs commands and clicks to your URL instead of the gateway (see Interactions). createInteractionsHandler verifies the signature with your app's Signing Secret, answers the ping, and passes each interaction to your interactionCreate listener:

JavaScript
import { createServer } from 'node:http';
import { Client, createInteractionsHandler, toNodeListener } from 'baff.js';

const client = new Client({ token: process.env.BOT_TOKEN, applicationId: process.env.APP_ID });

client.on('interactionCreate', async (interaction) => {
	if (interaction.isChatInputCommand()) await interaction.reply('Pong!');
});

const handler = createInteractionsHandler({ secret: process.env.SIGNING_SECRET, client });
createServer(toNodeListener(handler)).listen(3000);

The first answer is returned as the HTTP response, so it works on hosts that stop running after they respond. Later calls (followUp, editReply) go through the API. The handler has the shape (request: Request) => Promise<Response>, so on Cloudflare Workers, Bun or Deno use it directly: export default { fetch: handler }. You do not call login() for an HTTP-only bot.

To verify signatures in your own code:

JavaScript
import { verifyKey } from 'baff.js';

const ok = verifyKey(rawBody, signatureHeader, timestampHeader, process.env.SIGNING_SECRET);

verifyKey computes the hex HMAC-SHA256 of timestamp + "." + body, compares in constant time, and rejects timestamps more than 5 minutes from now. Pass the body exactly as received. The signature headers are X-Bafflux-Signature and X-Bafflux-Timestamp; the library also accepts the X-baff.gg- spelling.

Servers, members and messages

JavaScript
const server = await client.servers.fetch(serverId);
const general = server.channels.cache.find((channel) => channel.name === 'general');

await general.send('Hello');
const history = await general.messages.fetch({ limit: 50, before: someMessageId });

const members = await server.members.fetch();
await server.members.ban(userId, { reason: 'spam' });
await members.get(userId).timeout(10 * 60_000);
await server.roles.create({ name: 'Mods', color: '#2ec4b6', permissions: ['KickMembers'] });

On login, baff.js loads every server the bot is in so caches are filled when ready fires. fetchServersOnReady: false skips that and fetchMembersOnReady: true also loads member lists. After a reconnect it reloads the servers and emits reconnected, since the gateway does not replay missed events.

client.on('raw', ({ t, d }) => ...) gives every gateway packet, including events this version has no typed event for.

Permissions are bitfields:

JavaScript
import { PermissionsBitField } from 'baff.js';

new PermissionsBitField(['ViewChannel', 'SendMessages']).has('SendMessages');
client.generateInvite({ permissions: ['ViewChannel', 'SendMessages', 'AddReactions'] });

member.permissions is server-wide, from role bits. It does not apply channel overrides; interaction.memberPermissions does.

Errors and rate limits

Everything the library throws is a BaffError with a stable code. Failed API calls are BaffAPIError with status, code, field and the response body, matching Errors:

JavaScript
import { BaffAPIError } from 'baff.js';

try {
	await channel.send('Hello');
} catch (error) {
	if (error instanceof BaffAPIError && error.code === 'missing_permission') {
		// the bot lacks Send Messages here
	}
}

The REST client waits out 429 answers, spaces message sends to the 5 per 5 seconds baff.gg allows, queues requests per route, and retries GET, PUT and DELETE after network errors and 5xx answers. A slowmode answer is a rule of the channel, not a throttle, so it is thrown immediately as a BaffRateLimitError. Set client.on('debug', console.log) to see rate limit waits.

An exception thrown inside an event listener is emitted as the error event; without a listener it is rethrown.

What bots cannot do

baff.js only wraps routes a bot token may call. Friends, creating servers, joining by invite, reporting, forwarding messages, voting in polls and the portal are refused for bots (see Authentication), so there are no methods for them. Stage, soundboard and AutoMod routes are not wrapped in this version. For voice, channel.join() returns the LiveKit address and token and you connect with LiveKit's SDK.

Versioning

baff.js follows semantic versioning. While it is below 1.0, a minor version can change types and method names; the changelog in the package lists every change. New fields the server adds appear in raw events and in toJSON() before they get a typed property.

Next steps

  • Commands: the registration rules behind SlashCommandBuilder.
  • Interactions: what happens on the wire when someone uses a command, and the 3 second rule.
  • Message Components: buttons, select menus, layouts and forms in full.
  • Embeds: every embed field and its limit.
  • Gateway: the events behind client.on(...).
  • Messages: sending, editing, replies, reactions and pins.
  • Webhooks: posting from scripts, with WebhookClient.
  • Voice for bots: joining voice channels.
  • Use the API directly: what baff.js does underneath, from any language.