A Discord.JS alternative focused on minimalism and developer experience.
- About
- Why SimplyJS
- Installation
- Quick Start
- Usage
- Examples
- Advanced / Internals
- Development
- Status and known limitations
- Contributing
- License
SimplyJS is a TypeScript-first Discord library meant for bots that don't need every corner of the Discord API surface. It's currently alpha software at 1.3.0-alpha, so the public API can still shift between releases.
Most Discord libraries grow to cover every possible use case, which means a lot of surface area you never touch just to get a bot running. SimplyJS goes the other way: a small, typed core (gateway, REST, caches, structures) that covers the common paths well, and gets out of your way for everything else. No enums to memorize instead of string keys, no hidden magic in the event pipeline, and no dependencies beyond ws. If you outgrow it, the internals are small enough to read in an afternoon (see Advanced / Internals).
Requirements: Node.js >= 20 and a Discord bot token.
npm install simplyjsimport { Client, ClientEvents } from "simplyjs";
const client = new Client({
token: process.env.TOKEN!,
// Intents tell Discord which kinds of events this bot wants to receive.
// Only enable what you need. Excess intents can mean extra RAM usage
// and events you don't care about.
intents: ["Guilds", "GuildMessages", "MessageContent"]
});
client.login();
client.on(ClientEvents.Ready, (user) => {
console.log(`Logged in as ${user.username}`);
});
client.on(ClientEvents.MessageCreate, async (message) => {
if (message.content === "!ping") {
await message.reply("Pong!");
}
});
process.on("SIGINT", async () => {
await client.destroy();
process.exitCode = 0;
});message.reply() attaches to the triggering message; channel.send() sends a standalone message instead.
client.on(ClientEvents.MessageCreate, async (message) => {
if (!message.content.startsWith("!")) return;
const [command, ...args] = message.content.slice(1).split(/ +/);
if (command === "announce" && message.channel) {
await message.channel.send(args.join(" ") || "📢");
}
});NOTE
message.reply()pings the original author by default, matching Discord's own client behavior. Pass{ ping: false }to suppress it:await message.reply("Got it, no ping!", { ping: false });
message.user.send() opens or reuses a DM channel automatically.
client.on(ClientEvents.MessageCreate, async (message) => {
if (!message.content.startsWith("!dm ")) return;
const text = message.content.slice(4);
try {
await message.user.send(text || "Hi!");
await message.reply("Check your DMs!");
} catch {
await message.reply("I couldn't DM you - do you have DMs disabled?");
}
});WARNING
DMs can fail if the user has them closed or has blocked the bot - always wrapsend()to a user in atry/catch.
EmbedBuilder validates as you build, not just when you send - each setter enforces the relevant Discord field limit immediately.
import { EmbedBuilder } from "simplyjs";
const embed = new EmbedBuilder()
.setTitle(`${message.user.username}`)
.setColor("#5865F2")
.addFields([
{ name: "ID", value: message.user.id, inline: true },
{ name: "Bot?", value: message.user.bot ? "Yes" : "No", inline: true }
])
.setFooter({ text: `Requested in #${message.channel?.name ?? "unknown"}` })
.setTimestamp(new Date());
await message.reply({ embeds: [embed] });| Limit | Max |
|---|---|
setTitle() |
256 characters |
setFooter() |
2048 characters |
Total embed size (EmbedBuilder.validate()) |
6000 characters |
NOTE
If you're coming fromdiscord.js: there's no named-color constant support (hex string or decimal number only), fields can be set via plain property assignment (embed.description = "...") as well as setters, and validation errors throw synchronously as soon as a limit is exceeded rather than surfacing later as a Discord API error.EmbedBuilder.from(embed)hydrates a builder from an existing payload if you need to edit one you fetched.
Components v2 replaces a message's content and embeds with a component tree you lay out yourself. You do not set the flag or check the rules - passing v2 components to any send path is enough:
import { readFile } from "node:fs/promises";
import {
ContainerBuilder,
SectionBuilder,
SeparatorBuilder,
TextDisplayBuilder,
ThumbnailBuilder
} from "simplyjs";
const container = new ContainerBuilder()
.setAccentColor("#5865F2")
.addComponents(
new SectionBuilder()
.addComponents(new TextDisplayBuilder().setContent("## Welcome\nGlad you made it."))
.setAccessory(new ThumbnailBuilder().setMedia("attachment://banner.png")),
new SeparatorBuilder(),
new TextDisplayBuilder().setContent("-# Sent by SimplyJS")
);
await message.channel.send({
components: [container],
attachments: [{ name: "banner.png", data: await readFile("./banner.png") }]
});TextDisplay, Thumbnail, Section, MediaGallery, File, Separator, and Container all have builders, and each one is its wire payload, so a tree can be sent, logged, or cloned as-is. Media setters take a bare url string as shorthand for the full media object, and MediaGalleryBuilder.addItems() takes either.
The rules that no single component can check for itself are enforced when you send:
| Checked on send | Behavior |
|---|---|
IS_COMPONENTS_V2 |
Set for you when the payload uses a v2-only component. An action-row-only message stays v1 |
content / embeds / sticker_ids / poll |
Rejected on a v2 message, which is what Discord does - with an error naming the v2 replacement |
| Component count | 40 max, counting nested children |
| Text length | 4000 characters across every TextDisplay, which is a message-wide budget rather than a per-component one |
attachment:// references |
Cross-checked against the message's own attachments, so a typo'd filename throws locally instead of returning a 400 |
NOTE
Editing a message that is already v2 keeps it held to the v2 rules even when the edit alone would not look like one -message.update()and a component interaction'supdate()both read the flags of the message they are editing.interaction.editReply()cannot: it addresses the original response by token and never sees its flags.
Resolve a @mention or raw ID against the guild's member cache, falling back to a fetch:
async function resolveMember(client: FullClient, guildId: string, input?: string) {
const id = (/\d+/.exec(input ?? "") ?? [])[0];
if (!id) return null;
const guild = client.guilds.get(guildId);
if (!guild) return null;
return guild.members.get(id) ?? await guild.members.fetch(id).catch(() => null);
}guild.members.fetch() is overloaded: pass an ID for one member, or pagination options for a page of them. fetchAll() pages through the whole guild, and search() matches a username or nickname prefix - both need the privileged GuildMembers intent:
const page = await guild.members.fetch({ limit: 100 }); // one page, ordered by ascending user ID
const everyone = await guild.members.fetchAll(); // one request per 1000 members
const matches = await guild.members.search("mus", 5); // prefix match on username/nicknamefetchGateway() asks over the gateway instead of REST, using RequestGuildMembers (op 8). It is not rate limited per 1000 members, and it is the only way to fetch members together with their presences or to look up a batch of user IDs in one call. It resolves once the last chunk of the response arrives:
const everyone = await guild.members.fetchGateway(); // whole guild, one request
const some = await guild.members.fetchGateway({ userIds: ["1", "2"] }); // batch lookup, no intent needed
const withPresences = await guild.members.fetchGateway({ presences: true });query and userIds are mutually exclusive, userIds is capped at 100, and the manager throws rather than sending a request Discord would silently drop for a missing intent. Every chunk is also emitted as GuildMembersChunk as it lands, so a very large fetch can be streamed instead of awaited:
client.on(ClientEvents.GuildMembersChunk, ({ members, chunkIndex, chunkCount }) => {
console.log(`chunk ${chunkIndex + 1}/${chunkCount}: ${members.length} members`);
});Moderation actions are methods directly on the structure:
const member = await resolveMember(client, message.guildId!, args.shift());
if (!member) return message.reply("Couldn't find that member");
try {
await member.kick(args.join(" ") || undefined);
await message.reply(`Kicked **${member.user.username}**`);
} catch {
await message.reply("Something went wrong - do I have the Kick Members permission?");
}guild.emojis.create() and guild.stickers.create() upload from raw file bytes - the type is read from the contents, so you never pass an extension or a path, and nothing is read from disk for you:
import { readFile } from "node:fs/promises";
const emoji = await guild.emojis.create({
name: "blobwave",
image: await readFile("./blobwave.png") // PNG, GIF, JPEG, or WebP
});
const sticker = await guild.stickers.create({
name: "wave",
description: "a waving blob",
tags: ["wave", "hello"], // a single string works too
file: await readFile("./wave.png") // PNG, APNG, GIF, or Lottie JSON
});image also accepts a data URI if you already encoded one yourself, and a Lottie sticker can be handed over as the animation object rather than bytes:
await guild.stickers.create({
name: "spin",
description: "a spinning blob",
tags: "spin",
file: { v: "5.5.7", layers: [] } // serialized for you
});Both throw before spending a request when the file is not a format the endpoint accepts.
The same is true of the image fields that travel inline in a JSON body - guild.modify()'s icon, splash, discovery splash, and banner, guild.roles.create() / role.modify()'s icon, a scheduled event's image, a webhook's avatar, and guild.soundboardSounds.create()'s sound - all take bytes and are encoded on the way out. On an edit, leaving a field out keeps the current image and passing null clears it:
await guild.modify({ icon: await readFile("./icon.png") });
await guild.modify({ banner: null }); // removes the bannerThreadable channels (text, announcement, forum) expose a threads manager. Threads are channels, so anything it creates or lists lands in guild.channels rather than a separate collection:
const thread = await channel.threads.create({ name: "bug-triage", autoArchiveDuration: 1440 });
await thread.send("Starting here.");
// a thread hanging off an existing message
await channel.threads.createFromMessage(message.id, { name: "spinoff" });
// a forum post carries its first message in the same request
await forum.threads.createForumPost({
name: "Read me first",
message: { content: "Rules and guidelines", embeds: [ embed ] },
appliedTags: [ tagId ],
});
const active = await channel.threads.fetchActive();
const { threads, hasMore } = await channel.threads.fetchArchived({ limit: 25 });Load command objects into a Map and dispatch them from a shared MessageCreate listener - the same pattern most prefix-command bots converge on:
// commands/ping.ts
export default {
name: "ping",
async execute(client, message, args) {
await message.reply("Pong!");
}
};// index.ts
import * as Commands from "./commands";
client.commands = new Map();
for (const command of Object.values(Commands)) {
client.commands.set(command.name, command);
}
const PREFIX = "!";
client.on(ClientEvents.MessageCreate, async (message) => {
if (!message.content.startsWith(PREFIX)) return;
const [name, ...args] = message.content.slice(PREFIX.length).split(/ +/);
const handler = client.commands.get(name);
if (!handler) return;
try {
await handler.execute(client, message, args);
} catch (error) {
console.log(error);
await message.reply("Something went wrong!");
}
});Splitting event handling into one file per event keeps things tidy as a bot grows - createEvent() pairs a handler with the event it's bound to so it stays type-safe:
// events/ready.ts
import { ClientEvents } from "simplyjs";
import { createEvent } from "./types.js";
export default createEvent(ClientEvents.Ready, (client, user) => {
console.log(`[ready] Logged in as ${user.username}`);
});// index.ts
import * as Events from "./events";
for (const event of Object.values(Events)) {
client.on(event.name, (...args: any[]) => event.execute(client, ...args));
}A collector is a temporary, filtered listener. It collects every event that passes its filter until max, time, or idle stops it - or until you call .stop() - and detaches itself when it ends, so the "nobody ever clicked" path doesn't leave a listener behind:
import { createCollector, awaitEvent, ClientEvents } from "simplyjs";
client.on(ClientEvents.SlashCommandUsed, async (interaction) => {
if (interaction.claimed) return;
// collect several
const collector = createCollector(client, ClientEvents.MessageCreate, {
filter: (message) => message.channelId === interaction.channelId && !message.user.bot,
max: 5,
time: 30_000
});
collector.on("collect", (message) => console.log(`collected: ${message.content}`));
// `collected` is an array of argument *tuples*, since a collector doesn't know that this
// event happens to carry exactly one argument
collector.on("end", (collected, reason) => console.log(`got ${collected.length} (${reason})`));
// or wait for exactly one thing - `awaitEvent` builds a `max: 1` collector, hands back the
// event's arguments, and stops the collector in a `finally`. It rejects if `time` runs out.
// `editReply` hands back the message it wrote, which the filter below needs the id of
await interaction.deferReply();
const prompt = await interaction.editReply({ content: "Still there?", components: [row] });
const [button] = await awaitEvent(client, ClientEvents.ButtonUsed, {
filter: (button) => button.user.id === interaction.user.id && button.message.id === prompt.id,
time: 15_000
});
});Collectors do not survive a restart - a prompt that has to keep working after one needs a registered handler instead (see examples/12-button-args).
An interaction only gets one response, so when a collector and a registered handler both want the same click, exactly one of them has to answer it - otherwise the loser comes back 40060 Interaction has already been acknowledged. Collectors are offered every interaction before it's emitted, and the one they take is marked claimed, which is what a registered handler checks to stand down:
client.on(ClientEvents.ButtonUsed, async (interaction) => {
if (interaction.claimed) return; // a collector is handling this one
const button = client.buttons.get(interaction.customId);
if (!button) return await interaction.reply(`Unknown button "${interaction.customId}"`);
await button.execute(client, interaction);
});Claimed or not, the interaction is still emitted, so logging and metrics listeners see every one - claimed tells a responder to stand down, it doesn't hide events. If two responders do collide anyway, the second one now throws locally (naming the method that already answered) instead of sending a request Discord will reject.
Collectors on the client share one listener per event however many of them are alive, so a collector per command invocation is fine. Two knobs cover the rest:
const client = new Client({
token: process.env.TOKEN!,
intents: ["Guilds", "GuildMessages"],
// merged underneath every collector's own options - a policy like "nothing outlives
// fifteen minutes" in one place instead of a `time:` at every call site
collectorDefaults: { time: 15 * 60 * 1000 }
});
// how many collectors with no `time`, `idle` or `max` may pile up on one event before a
// warning says they're accumulating, naming the event and where the last one was created.
// Defaults to 10; set it to 0 to silence the warning
client.collectors.maxUnbounded = 25;Every dispatch event (GUILD_CREATE, MESSAGE_CREATE, etc.) has a built-in handler that updates caches/structures before emitting the public client event. Pass an override through the ws option to replace that event's entry entirely:
import { Client, GatewayEvents } from "simplyjs";
const client = new Client({
token: process.env.TOKEN!,
intents: ["Guilds", "GuildMessages"],
ws: {
eventOverrides: {
[GatewayEvents.MessageCreate]: (client, data) => {
console.log("raw MESSAGE_CREATE payload:", data);
}
}
}
});WARNING
This is a full replacement, not a "run before/after" hook - the built-in handler that upserts the message into cache and emitsClientEvents.MessageCreatenever runs once you override it.CreateDispatch()builds one handler map at construction time and doesn't support layering. OverridingINTERACTION_CREATEhas one extra obligation: hand the interaction toclient.collectors.dispatchInteraction(interaction, event, emit)rather than emitting it yourself, or collectors never get their first refusal andinteraction.claimedis never set.
import { ActivityType } from "simplyjs";
client.on(ClientEvents.Ready, (user) => {
const statuses = [
{ type: ActivityType.PLAYING, name: "with SimplyJS" },
{ type: ActivityType.LISTENING, name: "some tunes" },
{ type: ActivityType.WATCHING, name: "the matrix" }
];
let i = 0;
setInterval(() => {
const status = statuses[i];
client.setStatusMessage(status.type, status.name);
i = (i + 1) % statuses.length;
}, 5_000).unref();
});Full end-to-end projects live in examples/:
| Folder | What it shows |
|---|---|
1-ping |
Smallest possible bot - login, Ready, one !ping command |
2-rotating-status |
Rotating presence/status on an interval |
3-prefix-commands |
Single-file prefix command bot |
4-prefix-handler |
Multi-file command registry loaded into a Map |
5-sending-dms |
Sending DMs and handling closed-DM failures |
6-embeds |
EmbedBuilder usage, including error-style embeds |
7-fetching-and-moderation |
Member resolution, kicks, bans, timeouts, role management |
8-event-handler |
One-file-per-event handler structure |
9-slash-commands-basics |
Registering and responding to a single slash command |
10-slash-command-handler |
Multi-file slash command registry, same pattern as 4-prefix-handler |
11-buttons-and-selects |
Responding to button and select menu interactions |
12-button-args |
Encoding state in customId to avoid needing collectors |
13-all-handlers |
Commands, buttons, selects, and event handlers wired together |
14-collectors |
createCollector/awaitEvent for temporary, filtered event listeners, and how they coexist with registered handlers |
Client is the composition root - on construction it resolves your intents into a bitfield and starts the gateway (WSClient) and REST (Rest) clients, and owns the top-level guild/user caches plus the collector manager (client.collectors) that holds every live collector and decides which of them answers an interaction.
Gateway messages flow through a fixed pipeline: WSClient (src/WSClient.ts) owns the raw socket, runs the Hello → Identify → heartbeat handshake, and hands every DISPATCH payload to a dispatcher built by CreateDispatch() (src/EventDispatcher.ts), which routes each gateway event to a handler in src/Events/. Handlers update the relevant cache/structure and then emit the public-facing event via Client.emit(...). Structures (Guild, Channel, Message, etc.) are thin wrappers around the raw API objects that expose the methods you call, like message.reply() or member.kick(), all routed back through client.rest (src/Rest.ts), which authenticates every request, retries 429s/transient 5xxs, and tracks rate limits per route via a TTLCache (src/DataStructures/TTLCache.ts). Requests sharing a bucket are queued and sent one at a time, and an exhausted bucket (X-RateLimit-Remaining: 0) is waited out before sending rather than after being rejected, so a burst of concurrent calls paces itself instead of stampeding into a wall of 429s.
Non-obvious design notes:
HINT
Intents are more flexible than they look. You can pass a rawnumber, an array ofGatewayIntentsvalues, or plain key names like"Guilds".ResolveIntents/HasIntentinsrc/Intents.tsnormalize any of these into a bitfield. There's no event-to-intent gating yet, so a missing intent currently drops events silently rather than failing loudly.
NOTE
Permissions and intents are bigint bitfields, not enums. The genericBitFieldclass (src/DataStructures/BitField.ts) backs things likeRole.permissions. Raw Discord permission flag values live inConstants.ts.
NOTE
There are no TypeScriptenums in this codebase. Every constant-like map (opcodes, intents, events, statuses, activity types) is anas constobject instead, withObjectValues<typeof X>(src/Types/HelperTypes.ts) deriving the value union - a deliberate pattern applied consistently acrosssrc/Types/*.ts.
HINT
Readydoesn't fire the moment the gateway says it should.src/Events/Ready.tscollects every guild ID from theREADYpayload (including ones markedunavailable), then waits for a matchingGuildCreatefor each one. Only once every guild has arrived, or 15 seconds have passed, does the library emit its own publicReadyevent - so handlers never fire before caches are actually populated.
WARNING
Timers are required to call.unref(). This is enforced by a custom ESLint rule,local/require-unref-on-timers(eslint.config.ts). The polling loops inClient.login()/Client.destroy()are the reference examples if you're adding a new timer.
Structures and caches also split along ownership: APIClientStructure<T> holds a reference to client only, APIGuildStructure<T> holds both client and guild (src/Contracts/DiscordStructure.ts), and caches mirror the split via GlobalCache/GuildScopedCache (src/Contracts/CacheStructure.ts). See CODE_STYLE_AND_RULES.md for the full reasoning behind these patterns.
npm run check # eslint + tsc --noEmit
npm run build # check, then rm -rf dist/ and tsup (emits ESM + CJS + .d.ts to dist/)
npm test # vitest run (tests live in src/Tests/**/*.ts)
npm run lint # eslint .
npm run lint:fix # eslint . --fix
npm run linecount # top 10 largest .ts files by line countThe project is alpha software; gateway resiliency and Discord API coverage are still being built out (tracked in TODO.md). Notably:
WSClienthandlesGatewayOpCodes.Reconnect/InvalidSession, trackssession_id/resume_gateway_urlfromREADY, and resumes instead of re-identifying when possible; heartbeat ACKs are tracked and an unacked heartbeat triggers a reconnect. Reconnects are close-code aware: fatal codes (bad token, disallowed intents, bad shard/API version) stop retrying and emitWSEvents.Disconnect, session-invalidating codes re-identify instead of resuming, and everything else retries with exponential backoff + jitter up tomaxReconnectAttempts(default 10). A successful resume is observable asClientEvents.Resumed;ClientEvents.Readyis not emitted again, since the client was already ready and the gateway replays whatever was missed.- Gateway event coverage is partial. Dispatch handlers exist for guilds, channels, threads (including membership), members, roles, messages, reactions, emojis, stickers, soundboard sounds, invites, integrations, presences, user profile updates, and typing indicators, but events like
VoiceStateUpdate, stage instances, voice channel effects, entitlements, and subscriptions are not yet handled. channel.messagesholds no cache, unlike every other manager. A channel's message history is unbounded, sofetch(id)andfetch({ limit, before, after, around })always hit the API and hand back freshMessageinstances — there is nothing to read synchronously and nothing to go stale. It is available on text, voice, and thread channels; announcement and stage channels do not have it yet.before,after, andaroundare mutually exclusive.guild.integrationsis not seeded fromGUILD_CREATE— Discord does not send integrations there — so it starts empty and only fills fromIntegration*gateway events or an explicitguild.integrations.fetchAll().GuildIntegrationsUpdatesays only that something changed in a guild, so treat it as a signal to refetch.guild.webhooksis likewise never seeded fromGUILD_CREATE, andWebhooksUpdatetells you only which channel changed, not which webhook or how — callguild.webhooks.fetchChannel(channelId)to resync. AWebhookfetched without a token (anything the bot's own application did not create) cannot be executed, sowebhook.send()throws for those — as dofetchMessage(),editMessage(), anddeleteMessage(), which have no bot-authenticated route to fall back on.webhook.send()returns aWebhookMessage, not aMessage. The message belongs to the webhook rather than to the bot, so editing and deleting it go through the webhook's token instead of the channel — which is also why they need no permissions. Itsreply(),pin(), andreact()are still ordinary channel operations and need the bot's own permissions as usual.guild.soundboardSoundsis keyed by sound id, notid— Discord's soundboard sound object usessound_id, which theSoundboardSoundstructure surfaces assoundId. The cache is seeded fromGUILD_CREATEand kept current by theSoundboardSound*events, which need theGuildExpressionsintent.SoundboardSoundsUpdateupserts every sound it carries but never evicts: Discord does not document that payload as a guaranteed full-list replacement, soGUILD_SOUNDBOARD_SOUND_DELETEis treated as the only authoritative removal signal.guild.membersfills fromGUILD_CREATE, the member gateway events, and both fetch paths above. PreferfetchGateway()overfetchAll()for a full member list:fetchAll()costs one heavily rate limited REST request per 1000 members, while the gateway streams the same members back asGUILD_MEMBERS_CHUNKdispatches. Chunks are matched to their request by anoncethe manager generates, so concurrentfetchGateway()calls do not cross-talk; a request whose chunks never arrive rejects on itstimetimeout (30s by default) rather than hanging.channel.threadshas no cache of its own, since threads are channels:create(),createForumPost(),createFromMessage(), and thefetchActive()/fetchArchived()/fetchArchivedPrivate()/fetchJoinedArchivedPrivate()listings all upsert intoguild.channels. Discord has no per-channel active listing, sofetchActive()requests the guild-wide one and narrows the result.beforeis an archive timestamp on the archived listings but a thread ID on the joined-private one. Active threads are seeded intoguild.channelsfromGUILD_CREATE, so they are readable on connect without a fetch; archived threads still require one.thread.membersis only ever complete for the current user without the privilegedGuildMembersintent, andThreadMembersUpdatecaps itsaddedlist at 50 either way, so callthread.members.fetchAll()when you need the full membership of a busy thread. Threads themselves live inguild.channelsalongside regular channels, not in a separate collection.- File uploads take raw bytes, never a path — this library never touches the disk, so you read the file and it identifies the type from the contents rather than trusting an extension. That covers
guild.emojis.create(),guild.stickers.create(), and every inline image field (guild icon/splash/discovery splash/banner, role icons, scheduled event images, webhook avatars, and soundboard sounds); an already-encoded data URI is still accepted everywhere. Application-owned emojis have no create path at all. PresenceUpdateandguild.presencesrequire the privilegedGuildPresencesintent, which must also be enabled for the application in the Discord developer portal. Without it the event never fires and the cache stays empty. Offline users are not retained, somember.presenceisundefinedfor anyone offline, unseen, or when the intent is off.- Interactions are supported — slash and context menu commands, autocomplete, buttons, select menus, and modals all have typed structures (
src/Structures/Interactions/) and builders (src/Builders/), and commands are registered withclient.registerPublicCommands()/client.registerGuildCommands(). An interaction gets exactly one initial response, which the library now tracks: collectors are offered every interaction before it is emitted and the ones they take are markedinteraction.claimed, so a registered handler opens withif (interaction.claimed) return;, and a second response throws locally rather than coming back as a40060.ephemeralbelongs only on the responses that send a new message —reply(),deferReply(), andfollowUp(); a response's visibility is fixed when the interaction is first answered, soeditReply()and a component'supdate()reject the key rather than accepting and ignoring it. Components v2 is built out:TextDisplay,Thumbnail,Section,MediaGallery,File,Separator, andContainerall have builders, and the send path setsIS_COMPONENTS_V2for you, rejects the v1/v2 combinations Discord refuses, enforces the message-wide limits, and resolvesattachment://references against the payload's own attachments so a mismatched filename throws locally instead of coming back as a 400. One gap remains — monetization is unmodeled:interaction.entitlementsis still a rawJSONObject[], and whileSKUButtonBuildercan render a purchase button, there is no entitlement or SKU API and no way to observe the result. - Large portions of
src/still lack JSDoc coverage (tracked file-by-file indocs.md).
Before opening a PR:
- Read
CODE_STYLE_AND_RULES.md— it documents the actual patterns this codebase expects (event handler shape,as const+ObjectValuesinstead of enums, getter-vs-method rules,.unref()on timers, etc.), and PRs that don't follow it will need rework. - Run
npm run check(lint + typecheck) andnpm testlocally — the CI workflows (.github/workflows/node.js.yml,bun.yml,deno.yml) build and test the project on Node 20/22/24, Bun, and Deno for every push/PR tomain, so failures there will block merge anyway. - If you're adding a new gateway event handler, cache, or structure, make sure it's exported from the right barrel file (
src/Events/index.ts,src/index.ts, etc.) — see section 11 ofCODE_STYLE_AND_RULES.md. - If you fix or add something meaningful, add an entry to
CHANGELOG.mdand check off (or add) the matching item inTODO.md.
There's no formal CONTRIBUTING.md or PR template yet, so use your judgment and keep changes scoped. Keep commits scoped to one logical change, using this repo's loose type(scope): Description convention:
| Type | Description |
|---|---|
feat |
A new feature |
fix |
A bug fix |
refactor |
A code change that neither fixes a bug nor adds a feature |
test |
Adding or updating tests |
style |
Changes that don't affect meaning (whitespace, formatting, etc.) |
docs |
Documentation changes |
chore |
Tooling, config, or maintenance work |
MIT. See LICENSE.