██████╗ █████╗ ██████╗ █████╗ ███╗ ███╗███████╗██╗
██╔════╝██╔══██╗██╔══██╗██╔══██╝████╗ ████║██╔════╝██║
██║ ███████║██████╔╝███████║██╔████╔██║█████╗ ██║
██║ ██╔══██║██╔══██╗██╔══██║██║╚██╔╝██║██╔══╝ ██║
╚██████╗██║ ██║██║ ██║██║ ██║██║ ╚═╝ ██║███████╗███████╗
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝
make it simple — make it perfect.
Caramel is a modular Discord bot built with the Sapphire Framework and TypeScript, backed by PostgreSQL and Redis.
It is deliberately small. Four modules ship today — moderation, vanity tracker, tickets and a member counter — and each one can be set up, enabled, disabled or factory reset per guild without touching the others. New modules are added on top of that seam rather than bolted onto a monolith.
Moderation — Seventeen commands covering warn, mute, timeout, ban, softban, tempban, silent ban, kick and their reversals, plus channel slowmode and lockdown. Every action is recorded as a numbered case. Escalation thresholds turn repeated sanctions into automatic ones (N warns → mute, N mutes → ban), and a three-tier permission system decides who may run what: native Discord permissions, per-role and per-member overrides, then Bot Commanders.
Vanity Tracker — Watches custom statuses for a configured keyword and grants or removes a reward role accordingly. Role changes are queued through BullMQ so presence traffic never blocks the gateway.
Tickets — A panel users open tickets from, supporter claiming, message transcripts, and scheduled auto-close for inactive threads.
Member Counter — Publishes one message showing online members, total members and members in voice, then keeps editing it in place. Voice is exact and free — it comes straight off the gateway — so it is redrawn as people join and leave, a second or two behind the call rather than a tick behind it. Online and total are Discord's approximate counts, which Discord only recomputes every few minutes; those are re-fetched once a minute, one request regardless of how large the server is. A tick where nothing changed makes no edit at all.
| Layer | Technology |
|---|---|
| Runtime | Node.js + TypeScript |
| Framework | Sapphire Framework |
| Database | PostgreSQL via Prisma |
| Cache | Redis (ioredis) |
| Queue | BullMQ |
| Logger | Pino |
src/
├── index.ts Bootstrap: Prisma, Redis, workers, stats server, login
├── structures/ CaramelClient (custom Sapphire client)
├── commands/ Thin entry points — slash registration and delegation
│ ├── mod/ Moderation commands
│ ├── config/ language, prefix, mention, module
│ └── admin/ bot-commander
├── command-helpers/ Business logic — the real weight lives here
│ ├── mod/ sanctionFlow, permissionGuard, perms, thresholds
│ ├── config/module/ Per-module setup wizards and management
│ └── admin/
├── interaction-handlers/ Buttons and select menus
├── listeners/ Ready, PresenceUpdate, command errors, messages, tickets
├── workers/ BullMQ: Vanity, Mute, TempBan, SilentBan, Ticket
├── services/ SilentBanService
├── validators/ Module pre-enable validation
├── lib/
│ ├── layouts/ Discord Components V2 factories
│ ├── i18n/ en-US and es-ES strings
│ ├── constants/ Shared emojis
│ ├── structures/ CaramelUserError, CaramelSystemError
│ └── utils/ ModUtils, vanity, ticket helpers, queues
├── database/ Prisma client, Redis connection, CacheManager
└── api/ Stats HTTP endpoint and uptime tracking
prisma/
├── schema.prisma Database schema
└── migrations/ Migration history
docker-compose.yml Local PostgreSQL 15 + Redis
Two ideas hold the codebase together:
- Commands stay thin. A file in
commands/registers the slash command and delegates. Validation, execution and response building live incommand-helpers/. - Redis is the hot path.
Ready.tswarms the cache from PostgreSQL at startup; after that, config reads are Redis hits. Every write toGuildConfigmust be followed byCacheManager.syncGuild().
- Node.js v18+
- pnpm v10+ (
corepack enablepicks up the pinned version) - Docker and Docker Compose
- A Discord Application with a bot token
git clone https://github.com/CaramelHQ/Caramel.git
cd Caramel
cp .env.example .envFill in .env. At minimum you need DISCORD_TOKEN; the database and Redis values already point at what Compose provisions. Set DEVELOPMENT_GUILD_IDS to your test server's ID so slash commands appear there instantly instead of waiting on global propagation.
docker compose up -dThis starts PostgreSQL and Redis and waits for both to report healthy. Host ports are deliberately non-default so they don't collide with a local PostgreSQL or Redis:
| Container | Service | Host port | Internal port |
|---|---|---|---|
caramel-postgres |
PostgreSQL | 5433 | 5432 |
caramel-redis |
Redis | 6380 | 6379 |
The bot runs on your machine, not in Docker, so you can restart it freely:
pnpm install
npx prisma generate
npx prisma migrate deploy
pnpm run devpnpm run dev runs the bot through tsx watch, so it restarts on every save. The DATABASE_URL and REDIS_URL in .env already point at the two containers.
In VS Code, press F5 instead — .vscode/launch.json ships two configurations, Bot (debug) with working breakpoints and Bot (watch) with hot-reload. Both bring the infrastructure up first, and Ctrl+Shift+F5 restarts the bot.
Stopping the infrastructure:
docker compose down # stop PostgreSQL and Redis
docker compose down -v # ...and wipe their data volumesProduction is the exception — there the bot does run in a container, built from the Dockerfile and wired up in docker-compose.prod.yml.
| Command | Description |
|---|---|
pnpm run dev |
Start with hot-reload (tsx) |
pnpm run build |
Compile TypeScript to dist/ |
pnpm run start |
Run the production build |
| Database command | Description |
|---|---|
npx prisma generate |
Regenerate Prisma client |
npx prisma migrate deploy |
Apply pending migrations |
npx prisma migrate dev --name <name> |
Create a new migration |
npx prisma studio |
Open the database GUI |
There are no automated tests. pnpm run build is the minimum check before calling a change done — tsc catches broken references across the whole tree.
Nothing is active until you configure it. Each module is set up and enabled per guild:
/module setup name:Moderation
/module enable name:Moderation
/module settings name:Moderation
/module disable name:Moderation
/module reset name:Moderation
setup opens a modal, creating any missing roles or channels for you. reset removes the resources the bot created and clears the module's configuration.
| Command | Description |
|---|---|
warn |
Warn a member |
mute |
Mute a member with the muted role |
unmute |
Remove a mute |
timeout |
Discord native timeout |
untimeout |
Remove a timeout |
kick |
Kick a member |
ban |
Ban a member |
unban |
Unban a user |
softban |
Ban and unban to clear recent messages |
tempban |
Ban for a set duration |
silentban |
Add, remove or list silent bans |
slowmode |
Set channel slowmode |
lockdown |
Toggle channel lockdown |
case |
View a specific case |
remove-case |
Delete a case |
permission |
Manage per-action moderation permissions |
threshold |
Manage automatic escalation rules |
| Command | Description |
|---|---|
module |
Set up, enable, disable or reset a module |
language |
Change the server language |
prefix |
Change the server prefix |
mention |
Customize the bot's mention response |
| Command | Description |
|---|---|
bot-commander |
Grant or revoke Bot Commander status |
Almost always a port mismatch. Compose maps PostgreSQL to 5433 and Redis to 6380 on the host; the common mistake is using 5432 and 6379.
docker compose psBy default commands register globally, and global registration can take up to an hour to propagate. While developing, set DEVELOPMENT_GUILD_IDS in .env to a comma-separated list of guild IDs — commands register to those guilds instead and show up immediately. The bot must already be a member of every guild you list.
The startup log prints every command that loaded, so you can tell a broken command apart from one you are simply waiting on Discord to publish.
MIT