@treecordit/discord
v0.1.2
Published
Treecord's Discord bot core for Bun: TreeClient with slash-command routing, TreeCommand/SubCommand/SubCommandGroup builders, dev/admin guards, a folder command loader with hash-diffed pushes, a pluggable config store (Redis default), a generic per-interac
Readme
@treecordit/discord
Treecord's Discord bot core for Bun — the shared engine extracted from the Treecord
commission and internal bots, packaged for reuse. It runs a whole bot from the file system:
commands, events, and component (button/select/modal) handlers each load from their
folder, so index.ts is just env + directories. On top of that you get hash-diffed command
pushes (so you don't spam the Discord API), dev/admin/beta guards, reusable checks for
gating, an opt-in plugin system (premium, linked roles, social pollers), a pluggable config
store (Redis by default), a generic per-interaction context hook, branded length-safe
embeds, pagination, and a colored logger.
Proprietary. The published package contains an obfuscated build + type declarations only — never the source. Client repos can depend on it (and even be handed over) without exposing the implementation.
Dual distribution (private full types, public any)
A typed library can't hide its types and stay usable — the .d.ts is the contract consumers
compile against. So this package ships two builds with the same name, version, and (obfuscated)
runtime — only dist/index.d.ts differs:
| build | registry | types | who |
| ----------- | --------------------------------- | ------------------------------------------------- | ----------------------- |
| private | GitHub Packages (needs GHCR token) | full — real generics, autocomplete, TCtx flow | you / your CI |
| public | npm registry.npmjs.org (tokenless) | every export is any — runs identically | client hand-off repos |
Both are published from one source on each release (release:private + release:public). The
runtime is obfuscated, and the only types that reach the public are useless.
Install (in a consuming project)
discord.js and zod are peer dependencies; Bun ≥ 1.1 is required (the client uses
Bun.nanoseconds, Bun.hash, and — for the default config store — Bun's RedisClient).
Which build you get is decided by the .npmrc — npm/bun can't try the private registry and fall
back to public on a 401, so the choice is explicit. Copy consumer/select-registry.mjs (+ the two
.npmrc templates) from this repo into your project and run:
node select-registry.mjs # GHCR_TOKEN set → private full types; unset → public `any`
bun add @treecordit/discord discord.js zodQuickstart
A whole bot in one file (plus a folder of commands):
// src/index.ts
import { GatewayIntentBits } from 'discord.js'
import { TreeClient } from '@treecordit/discord'
import { join } from 'node:path'
const client = new TreeClient({
token: process.env.DISCORD_TOKEN!,
guildId: process.env.DISCORD_GUILD_ID, // omit to register commands globally
developerIds: ['1234567890'],
brand: 'Treecord', // TreeEmbed footer prefix
handlersDir: import.meta.dir, // auto-loads ./commands, ./events, ./components
intents: [GatewayIntentBits.Guilds],
})
await client.start() // logs in, loads everything, pushes commands, routes interactionshandlersDir is the whole configuration surface: point it at a folder with commands/, events/,
and components/ subfolders (any subset). Need a folder elsewhere? Override any one with
commandsDir / eventsDir / componentsDir.
// src/commands/ping.ts
import { TreeCommand, TreeEmbed } from '@treecordit/discord'
export default new TreeCommand('ping', 'Check the bot is alive', async ({ interaction }) => {
await interaction.editReply({
embeds: [new TreeEmbed().setNamedColor('success').setTitle('Pong!')],
})
})That's it — start() deferred the reply for you, enforced any guards, and only pushed to Discord
if ping's definition changed since last boot. See docs/CONVENTIONS.md for
the full command-file layout and copy-paste templates, and example/ for a runnable
starter.
Guards
Every command and subcommand can be flagged. Guards propagate from a parent command down to its subcommands, and denied invocations get a stock ephemeral embed (logged):
new TreeCommand('config', 'Server configuration', handler).setAdminOnly(true)
new TreeCommand('eval', 'Run code', handler).setDeveloperOnly(true) // devs = developerIds
subcommand.setBeta(true) // prefixes the description with [BETA]developerIds come from the client options (or configureBot). Admins are resolved from the
member's Administrator permission (developers are always admins). A command may also declare a
public boolean option; passing public: true opts that one invocation out of an ephemeral reply.
Per-interaction context (ctx)
Attach anything to each invocation — a loaded user record, a db handle, a request logger — with a
context hook. Its return type flows into every handler as ctx:
interface Ctx { user: DataUser }
const client = new TreeClient<Ctx>({
// ...
resolveContext: async (interaction) => ({ user: await DataUser.get(interaction.user.id) }),
})
// in a command:
export default new TreeCommand<Ctx>('me', 'Your profile', async ({ interaction, ctx }) => {
await interaction.editReply(`You are ${ctx.user.username}`)
})Without a resolveContext, ctx is undefined (and TreeClient's default TCtx is undefined).
The same ctx is resolved for component interactions too.
Events
Each file in events/ default-exports a TreeEvent. The event name is explicit, so the handler
args are fully typed from discord.js — and several files can handle the same event:
// events/guildMemberAdd.ts
import { TreeEvent } from '@treecordit/discord'
export default new TreeEvent('guildMemberAdd', async (client, member) => {
await member.send(`Welcome to ${member.guild.name}!`)
})Pass { once: true } as the third argument for one-shot events. Your own client.on(...) listeners
still work alongside the loaded ones.
Components (buttons, selects, modals)
Each file in components/ default-exports a TreeButton, TreeSelectMenu, or TreeModal. Handlers
are matched by customId prefix (longest match wins); parse any trailing data yourself from rest:
// components/repost.ts — matches customId "repost~<videoId>"
import { TreeButton } from '@treecordit/discord'
export default new TreeButton(
'repost~',
async ({ interaction, rest }) => {
const videoId = rest // everything after the prefix
await interaction.reply({ content: `Reposting ${videoId}…`, ephemeral: true })
},
{ defer: 'update', developerOnly: true }, // optional: auto-ack + guards
)Components get the same dev/admin guards, checks, and ctx as commands. They aren't auto-deferred
by default (buttons often update rather than reply); set defer: 'reply' | 'update' to opt in.
Checks and opt-in modules (premium, linked roles, social)
A check is a reusable gate (context) => pass() | deny({...}) you attach to any command or
component. It's the extension point for premium/entitlement gating, linked-role requirements,
cooldowns — anything that decides "may this proceed?" without baking policy into the core.
import { premiumCheck } from '@treecordit/discord'
// Premium: requires an active Discord entitlement (monetization). No API call —
// interactions carry their entitlements. Pass { skuId } to require a specific product.
new TreeCommand('vip', 'Members-only', handler).addCheck(premiumCheck({ skuId: env.SKU_ID }))Plugins are opt-in feature bundles you pass to plugins: [...] (leave them out and none of it
runs). They can register handlers/checks during setup and start background work during onReady:
import { linkedRoles, pollingPlugin } from '@treecordit/discord'
import { ApplicationRoleConnectionMetadataType as MetaType } from 'discord.js'
const client = new TreeClient({
// ...
plugins: [
// Linked roles: registers your role-connection metadata with Discord on ready.
linkedRoles({
metadata: [
{ key: 'level', name: 'Level', description: 'Server level', type: MetaType.IntegerGreaterThanOrEqual },
],
}),
// "Social" / background pattern: run something on an interval.
pollingPlugin({
name: 'youtube-alerts',
intervalMs: 60_000,
onTick: async (bot) => { /* fetch new uploads, post them */ },
}),
],
})Write your own plugin by returning a { name, setup?, onReady? } object — that's the whole
interface. (The linked-role user-facing OAuth step is a small web endpoint you host; see
docs/CONVENTIONS.md.)
Config store (per-bot variables)
Commands can declare variableKeys; those keys are read from the active ConfigStore before each
run and passed in as configs:
new TreeCommand('welcome', 'Post the welcome message', async ({ interaction, configs }) => {
await interaction.editReply(configs.welcomeMessage ?? 'Welcome!')
}, ['welcomeMessage']) // ← variableKeysThe default store is Bun's Redis client (lazily connected from REDIS_URL; a bot that never reads
config never connects). Swap it for anything — Postgres, an HTTP API, an in-memory map:
import { setConfigStore, type ConfigStore } from '@treecordit/discord'
const memory = new Map<string, string>()
setConfigStore({
async get(k) { return memory.get(k) ?? null },
async set(k, v) { memory.set(k, v) },
async getMany(keys) { return Object.fromEntries(keys.map((k) => [k, memory.get(k) ?? null])) },
} satisfies ConfigStore)Environment helpers
baseBotEnv / redisBotEnv are zod shapes for the variables every bot needs. Spread them into
your own @t3-oss/env-core schema (or a plain z.object) and add your own:
import { createEnv } from '@t3-oss/env-core'
import { baseBotEnv, redisBotEnv } from '@treecordit/discord'
import { z } from 'zod'
export const env = createEnv({
server: { ...baseBotEnv, ...redisBotEnv, YOUTUBE_API_KEY: z.string() },
runtimeEnv: process.env,
emptyStringAsUndefined: true,
})Publishing (maintainers)
One tag publishes both builds (the release workflow runs both scripts):
git tag v0.x.y && git push --tags
# or locally:
bun run release:private # full types → GitHub Packages
bun run release:public # all-`any` → public npm (needs the @treecordit npm org)- The tarball includes only
dist/index.js+dist/index.d.ts(filesin package.json): obfuscated runtime + a single declaration file. No sources, no per-file declarations, no sourcemaps. release:publicswaps in the loosedist/index.d.ts(viascripts/gen-loose-dts.mjs, derived from the full one so it can't drift), publishes, then rebuilds the full types locally.- The
.d.tskeepsdiscord.jsandzodasimports (peers the consumer already has) rather than inlining them. - Honest caveat: obfuscation raises the effort to read the code dramatically, but no JS obfuscation is irreversible — treat the license (UNLICENSED) and package access (restricted) as the real protection, with obfuscation as the deterrent layer.
What this package is not
Persistence (users, stats, leaderboards), external integrations (YouTube, social platforms), and domain commands stay per-project — they're coupled to each bot's schema. The primitives here are what they're built from.
