Small utilities for Discord bots using discord.js v14 and Components V2.
Requires Node.js 18+ and discord.js 14.19.2 or newer within v14.
npm install huruhuru discord.js@^14.19.2
discord.js is a peer dependency. Yarn, pnpm, and Bun can also install the package.
import { ButtonBuilder, ButtonStyle, MessageFlags } from "discord.js";
import { createSimpleContainer, makeRow, newAccentColor } from "huruhuru";
const container = createSimpleContainer("Hello world", newAccentColor());
const button = new ButtonBuilder()
.setCustomId("hello")
.setLabel("Hello")
.setStyle(ButtonStyle.Primary);
const row = makeRow([button]);
container.addActionRowComponents(row);
await ctx.reply({
components: [container],
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] },
});
newAccentColor() returns a random RGB tuple. createSimpleContainer(content, accentColor?)
creates a text container; omitting the color chooses a random one. makeRow(components)
builds an action row from button, select-menu, or modal text-input builders.
CommandContext wraps a ChatInputCommandInteraction or a Message and provides
reply, editReply, fetchReply, deleteReply, and followUp.
import { CommandContext } from "huruhuru";
const ctx = new CommandContext(interactionOrMessage);
if (ctx.isInteraction()) {
await ctx.deferReply();
await ctx.editReply("Done");
} else {
await ctx.reply("Done");
}
After deferring an interaction, use editReply to finish its response.
Messages cannot defer. Use replied and deferred to inspect response state.
For message contexts, @original refers to the first reply created by the context.
Deleting it never retargets @original to a follow-up. Operations on missing or
untracked replies throw an error. A failed deletion preserves tracking for retries.
Use a reply ID or message object with editReply/deleteReply, or an ID with fetchReply,
to target a tracked follow-up explicitly.
import { paginate } from "huruhuru";
const pagination = await paginate(
ctx,
["{{number}}. one", "{{number}}. two", "{{number}}. three"],
null,
1,
2,
"List — {{page}}/{{pages}}",
{
accentColor: [100, 150, 255],
idle: 60_000,
onError: (error) => console.error("Pagination failed:", error),
},
);
await pagination.nextPage();
await pagination.prevPage();
await pagination.stop();
Signature:
paginate(ctx, items, topSection?, currentPage = 1, pageSize = 10, header?, options?)
items: a readonly array of SectionBuilder, TextDisplayBuilder, or values with toString().
Mixed item types are supported. Inputs are copied, so their builders and templates can be reused.topSection: an optional item repeated on every page. null/undefined omit it; 0 and false are valid values.currentPage: the initial page, starting at 1; it must exist after pages have been split.pageSize: a positive integer specifying the maximum items per page.header: optional text displayed above the items.options: optional accent color, time, idle, and onError callback.Pages automatically become smaller when needed to stay within Discord's limits of 40 nested components and 4,000 text characters per message. Headers, repeated top items, separators, and navigation buttons count toward those limits. An item that cannot fit on a page throws before a message is sent. Empty lists produce one page.
Templates are applied on every render:
| Placeholder | Value |
|---|---|
{{number}} |
Global item number, starting at 1; 0 for headers and top items |
{{page}} |
Current page, starting at 1 |
{{pages}} |
Total pages after automatic splitting |
The previous/next buttons disable at the first/last page. Only the command author can navigate, and pagination buttons use instance-specific IDs to avoid accessory-button collisions. Registration edits an existing or deferred reply, clearing legacy content and embeds when switching to Components V2. Public-message updates use the saved message; button updates use the button interaction, so they do not depend on the original command's interaction token.
Collectors stop after 14 minutes or 5 minutes of inactivity by default. Override those
values with positive integer millisecond durations (up to 2,147,483,647) in options. On manual stop or timeout,
buttons disable and subsequent updates/navigation do nothing. Message/channel/guild deletion
stops collection without attempting another edit. Updates are serialized; failed page edits
restore the previous page number.
Awaited calls reject on errors. Background collector errors go to onError, or emit a Node
warning if no handler is provided. currentPage, maxPage, and items are readonly; use the
navigation methods to change pages. isStopped reports whether the collector has ended.
import { TextDisplayBuilder } from "discord.js";
import { paginateMap } from "huruhuru";
await paginateMap(
ctx,
users,
(user) => new TextDisplayBuilder().setContent("{{number}}. " + user.name),
null,
1,
10,
"Users — {{page}}/{{pages}}",
);
Signature:
paginateMap(ctx, items, mapper, topSection?, currentPage = 1, pageSize = 10, header?, options?)
The mapper receives one argument, the input item, and returns a SectionBuilder or
TextDisplayBuilder. If provided, topSection is passed through the same mapper.
Sections must include a button or thumbnail accessory, as required by Discord.
formatPaginationItem(item, currentPage, maxPage, index) formats and returns a copy of a
section or text display. index is the item number to display, starting at 1 (0 for top items).
npm ci
npm run check
npm test
npm run docs
check validates the library and compile-time API regression cases. test builds the
package and runs offline tests using Node's test runner. CI performs these checks on
Node.js 18, 20, 22, and 24 with both the minimum and development Discord versions.
docs regenerates the API site and preserves its custom domain.
The publish hook runs type checks and tests before packaging the compiled dist directory.
GPL-3.0