kore.bun
v2.0.5
Published
A complete, ultra-configurable, strictly-typed Discord bot framework for Bun. Built on top of discord.js and Prisma.
Maintainers
Readme
kore.bun
A complete, ultra-configurable, strictly-typed Discord bot framework for Bun. Bring your own Prisma client (or none at all) and get full type inference everywhere -- kore.bun never imports @prisma/client itself.
This version breaks the previous
kore.bunAPI. If you are upgrading from v1.x, please simply redo your project. Thank you for your understanding
Table of contents
- kore.bun
Features
- Zero-magic Prisma integration -- pass your generated
PrismaClienttype once viacreateKore<PrismaClient>()and everydefineCommand/defineEvent/... helper is typed against it automatically. - File-based loaders for commands, events, buttons, select menus and modals, fully path-configurable.
- Built-in interaction router with access control (owner-only, allow-lists, role/permission checks, invoker-restriction), cooldowns (in-memory or Prisma-backed), auto-defer safety, and graceful error panels.
- Discord Components V2 UI kit -- panels, buttons, selects, modals, pagination, all typed.
- Command registration with diffing -- hashes your command tree and skips registration when nothing changed.
- Health server (
/health,/ready) viaBun.serve, with pluggable health checks. - Migration guard -- refuse to boot with pending migrations, pluggable check function.
- Sharding helpers --
createShardingManager, cross-shard IPC, shard stat aggregation. - A real CLI (
kore.bun) --init,dev,build,doctor,commands ...-- fully colored, zero external dependencies (raw ANSI). - Strict TypeScript throughout.
Requirements
- Bun
>= 1.1.0 discord.js^14.27.0andzod^4.x(installed as dependencies of kore.bun)- Optional:
@prisma/client>= 7.10.0plus a matching driver adapter (e.g.@prisma/adapter-pg+pgfor Postgres) if you want persistence -- kore.bun treats Prisma as a peer dependency and never bundles it
Install
bun add kore.bun discord.js zod
# optional, only if you want Prisma-backed persistence:
bun add @prisma/client @prisma/adapter-pg pg
bun add -d prisma @types/pgQuick start
bunx kore init my-bot
cd my-bot
bun install
cp .env.example .env # fill in DISCORD_TOKEN, DISCORD_ID, DATABASE_URL
bunx prisma generate && bunx prisma migrate dev
bun run devProject structure
kore init scaffolds a project shaped like this:
my-bot/
src/
index.ts # entrypoint -- builds the KoreClient and starts it
config.ts # defineConfig(...)
kore.ts # createKore<PrismaClient>() -- your typed define* helpers
prisma.ts # PrismaClient + driver adapter singleton
commands/ # slash commands (file-based, auto-loaded)
events/ # discord.js events (file-based, auto-loaded)
buttons/ # button handlers (file-based, auto-loaded)
selectMenus/ # select menu handlers (file-based, auto-loaded)
modals/ # modal handlers (file-based, auto-loaded)
prisma/
schema.prisma
.env.exampleEvery directory under paths in your config is discovered recursively and hot-loaded on boot -- no manual registration arrays to maintain.
The typing pattern
Create src/kore.ts once, binding every helper to your own generated Prisma type:
// src/kore.ts
import { createKore } from 'kore.bun'
import type { PrismaClient } from '@prisma/client'
export const { defineCommand, defineEvent, defineButton, defineSelectMenu, defineModal } = createKore<PrismaClient>()From then on, client.prisma is fully typed everywhere without a single manual annotation:
// src/commands/ping.ts
import { SlashCommandBuilder } from 'discord.js'
import { defineCommand } from '../kore'
import { infoPanel, respond } from 'kore.bun'
export default defineCommand({
data: new SlashCommandBuilder().setName('ping').setDescription('Replies with pong.'),
async execute(client, interaction) {
await respond(interaction, { components: [infoPanel('Pong!', `Latency: ${Math.round(client.ws.ping)}ms`)] })
},
})Bootstrapping
// src/index.ts
import { KoreClient, createEnv } from 'kore.bun'
import { z } from 'zod'
import config from './config'
import { prisma } from './prisma'
const env = createEnv({ DATABASE_URL: z.url() })
const client = new KoreClient({
config,
token: env.DISCORD_TOKEN,
applicationId: env.DISCORD_ID,
prisma,
})
await client.bootstrap()
await client.start()bootstrap() loads every command/event/button/select-menu/modal, hydrates cooldowns, and registers slash commands (diffed against a local cache so unchanged trees are skipped). start() logs the client in.
Configuration
defineConfig validates and normalizes everything with zod. Every field is optional with sane defaults:
// src/config.ts
import { defineConfig } from 'kore.bun'
export default defineConfig({
devGuildId: '123456789012345678',
ownerIds: ['123456789012345678'],
intents: ['Guilds', 'GuildMembers'],
paths: {
root: 'src',
commandsDir: 'commands',
eventsDir: 'events',
buttonsDir: 'buttons',
selectMenusDir: 'selectMenus',
modalsDir: 'modals',
},
logging: { level: 'info', fileEnabled: true, directory: 'logs' },
health: { enabled: true, port: 3000 },
migrations: { allowPending: false },
cooldown: { defaultSeconds: 0 },
interactions: { ackSafetyMarginMs: 2000, shutdownDrainMs: 10_000 },
autocomplete: { debounceMs: 250, cacheTtlMs: 15_000 },
commands: { cachePath: '.cache/commands.json' },
})Prisma integration
kore.bun never depends on @prisma/client -- you own the client, the schema, and the migrations. All Prisma helpers live under the kore.bun/prisma subpath and infer their types straight from whatever delegate/client you pass in:
// src/prisma.ts
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! })
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
new PrismaClient()with no options will throw. SwapPrismaPgfor the adapter matching your database (@prisma/adapter-libsql,@prisma/adapter-planetscale, etc.).
// src/index.ts
import { createPrismaCooldownStore, createPrismaHealthCheck, createPrismaMigrateStatusCheck } from 'kore.bun'
import { prisma } from './prisma'
const cooldownStore = createPrismaCooldownStore(prisma.cooldown)
const healthCheck = createPrismaHealthCheck(prisma)
const migrationCheck = createPrismaMigrateStatusCheck(process.env.DATABASE_URL!)UI kit & builders
kore.bun ships a full Discord Components V2 kit. Everything is a plain function that returns a discord.js builder instance, so you can keep chaining .add*Components(...) on top of what kore.bun gives you. Every export below is available directly from kore.bun (re-exported from kore.bun/ui), fully typed, with zero extra imports needed beyond discord.js itself if you want to go lower-level.
Low-level builders (src/components/builders.ts)
These wrap the raw Components V2 builders with sane defaults:
import { COLORS, container, textDisplay, separator, section, thumbnail, mediaGallery, fileComponent } from 'kore.bun'| Export | Signature | Description |
| --- | --- | --- |
| COLORS | { primary, success, danger, warning, neutral } | Preset accent colors (hex numbers) used by every panel helper. |
| textDisplay(content) | (content: string) => TextDisplayBuilder | Markdown-capable text block. |
| separator(spacing?, divider?) | (spacing?: SeparatorSpacingSize, divider?: boolean) => SeparatorBuilder | Visual divider; defaults to Small spacing with a divider line. |
| container(accentColor?) | (accentColor?: number \| RGBTuple) => ContainerBuilder | Root Components V2 container, optionally accent-colored. |
| section() | () => SectionBuilder | Empty section builder for text + accessory layouts. |
| thumbnail(url, description?) | (url: string, description?: string) => ThumbnailBuilder | Small image accessory. |
| mediaGallery(urls) | (urls: readonly string[]) => MediaGalleryBuilder | Gallery built from a list of image URLs. |
| fileComponent(attachmentUrl) | (attachmentUrl: string) => FileBuilder | Attaches a file component by URL/attachment reference. |
Custom IDs (src/components/customId.ts)
Buttons, selects and modals all encode extra state in their customId using a :-separated scheme:
import { buildCustomId, parseCustomId } from 'kore.bun'
buildCustomId('confirm', 'user123', 2) // -> "confirm:user123:2"
parseCustomId('confirm:user123:2') // -> { id: 'confirm', args: ['user123', '2'] }The router calls parseCustomId for you before invoking your button/select/modal handler, so execute(client, interaction, args) already receives args as a string[].
Panels (src/ui/panels.ts)
Panels are the standard way to reply to interactions -- every one of them returns a ContainerBuilder ready to hand to respond():
import { infoPanel, successPanel, errorPanel, warningPanel, listPanel, keyValuePanel, sectionedPanel, titledPanel } from 'kore.bun'| Function | Description |
| --- | --- |
| titledPanel(title, message, color?) | Base building block -- a ## title heading followed by a message, in any color. |
| infoPanel(title, message) | Primary-colored informational panel. |
| successPanel(title, message) | Green panel, prefixes the title with ✅. |
| errorPanel(message, title?) | Red panel, prefixes the title with ⚠️ (title defaults to "Error"). |
| warningPanel(title, message) | Yellow panel, prefixes the title with ⚠️. |
| listPanel(title, items, color?) | Renders items as a bulleted list under a heading; shows _Nothing to show._ when empty. |
| keyValuePanel(title, entries, color?) | Renders a Record<string, string \| number> as **key:** value lines. |
| sectionedPanel(title, sections, color?) | Heading followed by multiple text blocks, each separated by a divider. |
await respond(interaction, {
components: [successPanel('Ban applied', `${target.tag} has been banned.`)],
ephemeral: true,
})Buttons (src/ui/buttons.ts)
import { buttonRow, confirmCancelRow, paginationRow, linkButton, type ButtonDefinition } from 'kore.bun'| Function | Description |
| --- | --- |
| buttonRow(definitions: ButtonDefinition[]) | Builds an ActionRowBuilder<ButtonBuilder> from { id, label, style?, args?, disabled?, emoji? } definitions; id/args are packed into the custom ID via buildCustomId. |
| confirmCancelRow(token, confirmId?, cancelId?) | Ready-made Confirm (success) / Cancel (danger) row, both carrying token as an arg. |
| paginationRow(baseId, page, totalPages) | ◀ / page / totalPages / ▶ row, auto-disabling the arrows at the bounds. |
| linkButton(url, label) | A ButtonStyle.Link button (not part of a row -- add it yourself). |
Select menus (src/ui/selects.ts)
import { stringSelectRow, userSelectRow, roleSelectRow, channelSelectRow, type SelectOption, type StringSelectDefinition } from 'kore.bun'| Function | Description |
| --- | --- |
| stringSelectRow(definition: StringSelectDefinition) | { id, placeholder, options, minValues?, maxValues?, args? } -> ActionRowBuilder<StringSelectMenuBuilder>. |
| userSelectRow(id, placeholder, minValues?, maxValues?) | Ready-made user select row. |
| roleSelectRow(id, placeholder, minValues?, maxValues?) | Ready-made role select row. |
| channelSelectRow(id, placeholder, minValues?, maxValues?) | Ready-made channel select row. |
Modals (src/ui/modals.ts)
buildModal turns a declarative field list into a fully-built ModalBuilder, using the new LabelBuilder-wrapped input components. Supported field types: input, string, user, role, channel, file, mentionable, checkbox, checkboxgroup, radiogroup.
import { buildModal, type ModalSpec, type ModalFieldDefinition } from 'kore.bun'
const modal = buildModal({
customId: 'feedback',
title: 'Send feedback',
fields: [
{ type: 'input', customId: 'message', label: 'Your message', required: true, maxLength: 500 },
{
type: 'radiogroup',
customId: 'severity',
label: 'Severity',
options: [
{ label: 'Minor', value: 'minor', default: true },
{ label: 'Major', value: 'major' },
],
},
],
})Every field shares customId, label, description?, required?; select-style fields (string, user, role, channel, mentionable) additionally accept minValues?, maxValues?, placeholder?, disabled? and a defaults/options payload matching their Discord counterpart.
Pagination (src/ui/pagination.ts)
import { paginate, type PaginateOptions, type PaginatedResult } from 'kore.bun'
const { container: view, page, totalPages } = paginate({
title: 'Leaderboard',
items: rows,
page: currentPage,
pageSize: 10,
render: (row, index) => `**${index + 1}.** ${row.name} -- ${row.score}`,
buttonBaseId: 'leaderboard',
})
await respond(interaction, { components: [view] })paginate() slices items for the given page/pageSize, renders each visible item with render, and appends a paginationRow wired to buttonBaseId -- your button handler just needs to read args (['prev' | 'next' | 'noop', currentPage]) and call paginate() again with the new page.
Sending it all: respond()
import { respond, type RespondOptions } from 'kore.bun'
await respond(interaction, { components: [infoPanel('Hello', 'World')], ephemeral: true })respond() is Components-V2-aware -- it auto-picks reply/editReply/followUp depending on the interaction's state and always sets MessageFlags.IsComponentsV2 (plus Ephemeral when requested), so you never juggle flags manually.
Access control & cooldowns
Commands and components accept an optional access object, enforced automatically by the built-in router:
export default defineCommand({
data: new SlashCommandBuilder().setName('ban').setDescription('Bans a member.'),
access: {
requiredPermissions: ['BanMembers'],
cooldownSeconds: 10,
},
async execute(client, interaction) {
// ...
},
})Supported checks: ownerOnly, allowedUserIds, allowedRoleIds, requiredPermissions, restrictToInvoker (components only), and cooldownSeconds. Cooldowns default to an in-memory store; swap in createPrismaCooldownStore(prisma.cooldown) for persistence across restarts.
Health checks & migration guard
import { startHealthServer } from 'kore.bun'
import { createPrismaHealthCheck, createPrismaMigrateStatusCheck } from 'kore.bun/prisma'
import { assertMigrationsApplied } from 'kore.bun'
assertMigrationsApplied(config.migrations.allowPending, createPrismaMigrateStatusCheck(env.DATABASE_URL))
startHealthServer({ client, checks: [createPrismaHealthCheck(prisma)] })startHealthServer exposes /health and /ready via Bun.serve, aggregating your custom checks alongside gateway readiness. assertMigrationsApplied refuses to boot when pending migrations are detected, unless migrations.allowPending is true.
The CLI
The kore.bun binary ships with the package (installed automatically as a dependency, or run directly via bunx kore.bun):
kore.bun init [name] # Scaffold a new project in the current directory
kore.bun dev # bun run --watch src/index.ts
kore.bun build # Type-check and bundle for production
kore.bun doctor # Diagnose your local environment and project setup
kore.bun commands clear-global # Clear all globally registered slash commands
kore.bun commands clear-guild <id> # Clear all slash commands for a specific guild
kore.bun commands list-global # List all globally registered slash commands
kore.bun commands list-guild <id> # List all slash commands for a specific guild
kore.bun commands reset-cache # Delete the local command registration cache
kore.bun version / kore.bun help # Print version / usageEvery command accepts --token and --app-id overrides (defaulting to DISCORD_TOKEN/DISCORD_ID). Output is colored with hand-rolled ANSI escapes -- no chalk, no commander, no ora -- and respects NO_COLOR / FORCE_COLOR.
Sharding
// src/shard.ts
import { createShardingManager } from 'kore.bun/shard'
createShardingManager({ token: process.env.DISCORD_TOKEN!, entry: './src/index.ts' })createShardingManager wires up shard lifecycle logging and relays IPC messages broadcast via broadcastToShards/onShardMessage between shards automatically.
License
WTFPL -- do whatever the fuck you want with this code.
