Open source Discord bot built for Val Town. Ships with slash commands, component interactions, webhook logging, linked roles, KV and blob persistence layers — all running on Val Town's serverless Deno isolates.
- Features
- Deployment
- 1. Fork the Project
- 2. Create a Discord Application
- 3. Set Environment Variables
- 4. Configure the Interactions Endpoint
- Admin Requests
- 5. Discover Commands
- 6. Register Commands
- 7. Invite the Bot
- 8. Set Up Cron Jobs
- 9. Configure Your Server
- 10. Optional: Linked Roles
- 11. Optional: Webhook Logging
- 12. Optional: Legal Pages
- Restricting Bot Access
- Environment Variables
- Architecture
- HTTP Endpoint
- Commands
- Component Handlers
- Cron Jobs
- KV Store
- Blob Storage
- Guild Configuration
- Admin System
- Linked Roles
- Webhook Logging
- Webhook Sending
- Command Registration
- Helpers
- Adding a New Command
- Adding a Component Handler
- Modal Dialogs
- Select Menus
- Pagination with Buttons
- Testing
- Project Structure
- License
- Slash command framework —
defineCommand()with auto-registration, subcommands, autocomplete, and cooldowns - Component handling — Buttons, select menus, and modals via
defineComponent()with exact and prefix matching - Context menu commands — User and message right-click actions
- Per-guild configuration — Admin roles, command enable/disable, all persisted in KV
- Giveaway system — Button entry, auto-end via cron, winner picking, reroll
- Poll system — Button voting with live counts, auto-end, vote switching
- Reminders — Personal time-delayed reminders with exactly-once cron delivery
- Scheduled messages — Admin-only delayed message posting
- Ticket system — Thread-based support tickets with close reasons, join requests, and auto-expiry via cron
- Livestream notifications — Track Twitch, YouTube, and Kick streamers with automatic go-live embeds via cron
- React-roles — Self-assignable role panels with button toggling
- Tags — Per-guild text snippets with role/user-gated viewing
- Templates — Reusable embed builder with role/user-gated send permissions and modal editing
- Paste — Server pastebin with short codes, public/private retrieval, and role/user-gated access
- Stash — Personal cross-server clipboard for text snippets
- Backup — Guild data export/import for tags, templates, and counters
- Dice roller — Standard TTRPG notation (
2d20+5) with secret rolls, announce, and reveal (/roll) - Per-game toggles — Admins can enable/disable individual casino games
- Daily coin rewards — Configurable daily
/games dailycommand with cooldown - Linked roles — Discord OAuth2 verification with pluggable verifiers (Steam, GitHub, Patreon, account age)
- Webhook logging — Batched Discord webhook logger with log levels, auto-flush, and per-path muting
- Webhook sending — Message chunking, embed batching, rate-limit handling, fallback support
- KV persistence — SQLite-backed key-value store with atomic operations, optimistic concurrency, and time-based queries
- Blob persistence — Cloudflare R2-backed blob storage for larger data (pastes, templates, backups, stash)
- Production hardened — Retry logic, rate-limit respect, timing-safe comparisons, exactly-once delivery, panel update throttling
- Legal pages — Built-in Terms of Service and Privacy Policy
- Health check —
GET /returns{ status: "ok" }
This guide walks you through remixing Catnip on Val Town and getting it running in your Discord server.
Go to the Catnip project on Val Town and click Fork (or Remix) to create your own copy. This gives you a full clone of the codebase under your Val Town account that you can customize.
- Go to the Discord Developer Portal
- Click New Application and give it a name
- Note the following values from the portal — you'll need them for environment
variables:
- General Information →
APPLICATION IDandPUBLIC KEY - Bot → Click Reset Token to generate a
BOT TOKEN
- General Information →
- Under Bot, make sure Public Bot is toggled to your preference (on = anyone can invite, off = only you)
- Under Bot → Privileged Gateway Intents, no intents are required — the bot is interactions-only and does not use the gateway
In your Val Town project, go to Settings → Environment Variables and add:
| Variable | Required | Value |
|---|---|---|
DISCORD_APP_ID | Yes | Application ID from the portal |
DISCORD_PUBLIC_KEY | Yes | Public Key from the portal |
DISCORD_BOT_TOKEN | Yes | Bot token from the portal |
DISCORD_APP_OWNER_ID | Recommended | Your personal Discord user ID (grants global admin bypass) |
ADMIN_PASSWORD | Recommended | A strong password for admin HTTP endpoints |
To find your Discord user ID: enable Developer Mode in Discord settings (App Settings → Advanced → Developer Mode), then right-click your name and click Copy User ID.
- Find the URL of your
interactions.http.tsval — it will look likehttps://<your-username>-catnip-interactionshttp.web.val.run - In the Discord Developer Portal, go to General Information
- Set Interactions Endpoint URL to your val's URL
- Discord will send a verification ping — if your environment variables are set correctly, it will succeed and save
Steps 5, 6, and 10 require authenticated HTTP requests to your val. All admin endpoints use the same format:
GET https://YOUR_VAL_URL?discover=true Authorization: Bearer <your-admin-password>
Replace the URL query parameter for each endpoint: ?discover=true,
?register=true, ?register-metadata=true.
The bot needs to know what commands and components exist. Make an
admin request with ?discover=true.
This scans discord/interactions/commands/ and
discord/interactions/components/ and saves the file list to KV. You need to
re-run this whenever you add or remove command/component files.
Register the bot's slash commands with Discord by making an
admin request with ?register=true.
This registers global commands (/ping, /help, /commands, /server)
with Discord's API. Global commands can take up to an hour to propagate.
Alternatively, once global commands are available, use /commands sync
from Discord to register commands across all configured guilds, or
/commands register all to register for the current guild only.
Build an invite URL using the Discord Developer Portal:
- Go to OAuth2 → URL Generator
- Select scopes:
botandapplications.commands - Select bot permissions:
- Send Messages — for reminders, giveaways, polls, scheduled messages
- Embed Links — for rich embed responses
- Manage Roles — for react-roles (role assignment)
- Use External Emojis — for react-role emoji support
- Read Message History — for updating giveaway/poll panels
- Copy the generated URL and open it in your browser to invite the bot to your server
Alternatively, construct the URL manually:
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=268504128
Or use the auth-gated /invite route — see
Restricting Bot Access.
Features like reminders, giveaways, polls, and scheduled messages require cron jobs to process due items. In Val Town, each cron file is a separate val that runs on a schedule.
For each of these files, set the schedule to every 1–5 minutes in Val Town:
| Cron Val | Purpose | Required For |
|---|---|---|
services/giveaways.cron.ts | Auto-end expired giveaways | /giveaway |
services/polls.cron.ts | Auto-end expired polls | /poll |
services/reminders.cron.ts | Deliver due reminders | /remind |
services/scheduled-messages.cron.ts | Deliver due messages | /schedule |
services/livestreams.cron.ts | Post go-live notifications | /livestream |
If you don't use a feature, you can skip its cron job. The commands will still work — items just won't auto-process until the cron is set up.
Once the bot is in your server and global commands have propagated:
- Set admin roles (optional):
/server admin add role:@Moderator— lets users with that role manage the bot without needing Discord Administrator permission - Enable guild commands:
/server commands enable command:giveaway— enables feature commands one at a time. Repeat for each command you want (e.g.remind,poll,tag,react-roles,schedule,roll, etc.) - View config:
/server info— shows current admin roles and enabled commands
Guild commands are registered with Discord immediately when enabled and only appear in servers that have enabled them.
If you want to use Discord's Linked Roles feature:
- Set
DISCORD_CLIENT_SECRETin your Val Town environment variables (found in the Discord Developer Portal under OAuth2 → Client Secret) - In the portal under General Information, set Linked Roles Verification
URL to
https://YOUR_VAL_URL/linked-roles - Under OAuth2 → Redirects, add
https://YOUR_VAL_URL/linked-roles/callback - Register the metadata schema by making an admin request
with
?register-metadata=true - In your Discord server, go to Server Settings → Roles, create a role, and under Links add your app as a requirement
The default verifier (always-verified.ts) approves everyone. Switch to a
different verifier (Steam, GitHub, Patreon, account age) by changing the import
in services/interactions.http.ts. See the Linked Roles
section for details.
To send bot logs to a Discord channel:
- Create a webhook in a private channel (Channel Settings → Integrations → Webhooks → New Webhook)
- Copy the webhook URL
- Set
DISCORD_CONSOLEin your Val Town environment variables to the webhook URL
The bot will batch and send log entries (info, warn, error) to that channel.
Useful for monitoring in production. You can mute noisy paths (e.g. frequent
commands or cron jobs) with /server logging mute — only warnings and errors
will still appear.
Discord requires apps to have a Terms of Service and Privacy Policy URL:
- In the Discord Developer Portal under General Information, set:
- Terms of Service URL →
https://YOUR_VAL_URL/terms - Privacy Policy URL →
https://YOUR_VAL_URL/privacy
- Terms of Service URL →
The pages are served directly from the bot. To customize the content, edit
discord/pages.ts.
Three approaches to control who can use your bot, from broadest to most specific:
-
Public Bot toggle — In the Discord Developer Portal under Bot, toggle Public Bot off. Only you (the application owner) can add the bot to servers.
-
ALLOWED_GUILD_IDS— Set this environment variable to a comma-separated list of guild IDs. The bot will reject interactions from any server not in the list. Leave unset to allow all guilds (backwards compatible). DMs and Discord's PING verification are always allowed.ALLOWED_GUILD_IDS=123456789012345678,987654321098765432 -
Auth-gated
/inviteroute — VisitGET /invitewith a Bearer token (sameADMIN_PASSWORDas other admin endpoints) to get a page with the bot's invite URL. This lets you share the invite only with trusted users without exposing the URL publicly.
| Variable | Required | Description |
|---|---|---|
DISCORD_APP_ID | Yes | Discord application ID |
DISCORD_PUBLIC_KEY | Yes | Ed25519 public key for interaction signature verification |
DISCORD_BOT_TOKEN | Yes | Bot token for Discord API calls |
DISCORD_APP_OWNER_ID | No | Your Discord user ID (global admin bypass) |
DISCORD_CONSOLE | No | Webhook URL for logger output |
DISCORD_CLIENT_SECRET | No | Required for Linked Roles OAuth2 flow |
ADMIN_PASSWORD | No | Password for admin HTTP endpoints (?discover, ?register, ?register-metadata) |
STEAM_API_KEY | No | Steam Web API key (for Steam linked role verifier) |
PATREON_WEBHOOK_SECRET | No | Patreon webhook HMAC-MD5 secret |
FEEDBACK_WEBHOOK | No | Webhook URL for /feedback submissions (command disabled when unset) |
TWITCH_CLIENT_ID | No | Twitch application client ID (for /livestream Twitch tracking) |
TWITCH_CLIENT_SECRET | No | Twitch application client secret |
YOUTUBE_API_KEY | No | YouTube Data API v3 key (for /livestream YouTube tracking) |
ALLOWED_GUILD_IDS | No | Comma-separated guild IDs to restrict the bot to (empty = all guilds allowed) |
Required variables throw immediately at module load if missing.
The bot runs entirely on Val Town's serverless platform:
- HTTP val (
services/interactions.http.ts) — Single endpoint handles all Discord interactions, OAuth callbacks, admin endpoints, and legal pages. Each request is a new Deno isolate. - Cron vals (
services/*.cron.ts) — Scheduled jobs for delivering reminders, ending giveaways/polls, sending scheduled messages, and posting livestream notifications. Each invocation is a new isolate. - KV persistence (
discord/persistence/kv.ts) — All state is stored in Val Town SQLite via a key-value abstraction with atomic operations. - Cold start — Every isolate re-runs module-level code including registry loading from KV, Ed25519 key import, and logger setup.
services/interactions.http.ts routes all incoming requests:
| Path / Query | Auth | Description |
|---|---|---|
/terms | None | Terms of Service page |
/privacy | None | Privacy Policy page |
/invite | Bearer | Invite page with bot invite URL |
/linked-roles | None | Initiates Discord OAuth2 for linked role verification |
/linked-roles/callback | None | Handles OAuth2 callback |
/?discover=true | Bearer | Scans project files and saves command/component manifest to KV |
/?register=true | Bearer | Bulk-registers all commands with Discord |
/?register-metadata=true | Bearer | Pushes linked roles metadata schema to Discord |
GET / | None | Health check — { "status": "ok", "timestamp": "..." } |
| Path | Description |
|---|---|
/patreon/webhook | Patreon membership webhook (HMAC-MD5 verified) |
POST / | Discord interactions endpoint (Ed25519 verified) |
Admin endpoints require a Bearer token header with your admin password.
All loggers are flushed in a finally block before the isolate terminates.
Commands have two registration types:
- Global (
registration: { type: "global" }) — Available everywhere, registered once via Discord's global commands API - Guild (
registration: { type: "guild" }) — Must be enabled per-server with/server commands enable
Health check. Returns "Pong!" (ephemeral).
Lists all non-admin commands alphabetically as an embed.
Manage command registration with Discord.
register <command>— Register a command for this guild (orallfor all enabled). Guild commands must be enabled first. Autocomplete shows commands annotated with enabled/not-enabled status from guild config.unregister <command>— Unregister from the current guild (orall). Autocomplete shows enabled commands from guild config.sync— Full cross-guild sync: registers global commands globally and bulk overwrites each configured guild's commands to match their config.list— Shows registered vs expected commands for this guild, highlighting mismatches (stale, missing, unknown).
Per-guild bot configuration.
admin add <role>— Add a role as a bot admin role (max 25)admin remove <role>— Remove an admin roleadmin list— Show configured admin rolescommands enable <command>— Enable a guild command, bulk overwrites all enabled commands for this guild with Discordcommands disable <command>— Disable a guild command, bulk overwrites remaining enabled commands (removes the disabled one from Discord)commands list— Show status of all guild commandslogging mute <path>— Suppress routine (info/debug) webhook logs for a command or cron path (e.g.cmd:games,cron:reminders). Warnings and errors always get through. Supports prefix matching — mutingcmd:gamesalso mutescmd:games:coinflip.logging unmute <path>— Re-enable webhook logs for a previously muted pathlogging list— Show all currently muted log pathsinfo— Full guild config summary (now includes muted log paths)
These must be enabled per-server via /server commands enable.
Bot info embed with command count and runtime details.
Guild data export and import.
export— Snapshot tags, templates, and counter to blob storageimport <id>— Restore from a backup (overwrites current data)list— Show available backups with timestamps, creator, and contentsdelete <id>— Remove a backup
Max 5 backups per guild. Autocomplete on backup IDs.
Flip a coin using cryptographically secure randomness.
Per-guild persistent counter. Increment by default, pass reset to reset. Uses
atomic KV update().
Echoes input back. Sanitizes @everyone, @here, and mention syntax.
Browse 8 fun facts with Previous/Next pagination buttons.
Opens a modal dialog with Topic and Details fields. Submission sends a formatted
embed to the FEEDBACK_WEBHOOK URL and shows a "Feedback Received" confirmation
to the user. Disabled with an ephemeral error when FEEDBACK_WEBHOOK is not set.
One active giveaway per guild.
create <prize> <duration> <channel> [winners]— Post a giveaway panel with "Enter Giveaway" button. Duration up to 30 days, 1–10 winners.end— End early, pick winners, post announcementreroll— Re-pick winners from the ended giveaway's entrants
Auto-ended by the giveaways.cron.ts job. Max 10,000 entrants. Panel updates
throttled to 5-second intervals.
Server pastebin using blob storage.
create <content>— Store text (max 6000 chars), get an 8-char hex codeget <code> [public]— Retrieve a paste (role/user-gated; ephemeral by default,public: trueto show the channel)list— Show all pastes with code, content preview, and permission infodelete <code>— Remove a paste (creator or admin)allow-role <code> <role>— Grant a role view permission (admin)deny-role <code> <role>— Revoke a role's view permission (admin)allow-user <code> <user>— Grant a user view permission (admin)deny-user <code> <user>— Revoke a user's view permission (admin)
Max 50 pastes per guild. Autocomplete on paste codes. Pastes are unrestricted by default; once any role or user is added, only those roles/users (and admins) can view.
Pick a random item from a comma-separated list (min 2 choices).
One active poll per guild.
create <question> <options> <channel> [duration]— Post a poll with one button per option (2–10 options, up to 5 per row). Default duration 7 days, max 30 days. Omit duration for no time limit.end— End the poll, show final results with vote bars and percentages
Vote behavior: click to vote, click same to remove, click different to switch.
Max 10,000 voters. Panel updates throttled to 5-second intervals. Auto-ended by
polls.cron.ts.
Roll dice using TTRPG notation. Supports XdN, XdN+M, XdN-M. 1–20 dice,
d2–d100. Shows individual rolls and total.
- secret — Roll is ephemeral (only you see it). Includes a Reveal Roll button to post the result publicly.
- announce — When rolling secretly, posts a public notice ("🎲 @user rolled some dice...") so the table knows something happened.
Examples: /roll dice:1d20, /roll dice:4d6, /roll dice:2d20+5,
/roll dice:1d20 secret:True announce:True
Self-assignable role panels.
add <role> <emoji> <label>— Add a role (max 25, supports custom and unicode emoji)remove <role>— Remove a role from the panellist— Show current configurationsend <channel>— Post or update the role panel (patches existing message if present)clear— Delete all config
Users click buttons to toggle roles on/off.
Personal reminders. Duration supports s, m, h, d and combinations like
1d12h. Max 10 active per user, max 30 days, max 500 chars. Delivered by
reminders.cron.ts.
Time-delayed message delivery.
send <channel> <time> <message>— Schedule a message (max 2000 chars, max 30 days)list— Show pending messages with channel, preview, and relative timecancel <id>— Cancel a pending message (autocomplete shows pending)
Max 25 per guild. Delivered by scheduled-messages.cron.ts.
Deferred command demo. Waits 1–10 seconds (default 3) then echoes. 10-second cooldown.
Personal clipboard — snippets persist across all servers.
save <name> <content>— Save or overwrite a named snippet (max 4000 chars)get <name>— Recall a snippetlist— Show all entries with previewsdelete <name>— Remove a snippet
Max 25 entries per user. Names sanitized to lowercase alphanumeric + hyphens (max 32 chars). Always ephemeral. Autocomplete on names.
Private support ticket channels. Requires Manage Channels permission.
new— Opens a modal to create a new ticket. Creates a private channel under the configured category with permission overwrites so only the creator and bot can see it. Posts a staff control panel (Join/Close buttons) in the staff channel. Max 3 open tickets per user.close [reason]— Close the current ticket channel. Locks the channel, renames it, posts a close notice, and schedules auto-deletion after 24 hours.setup <staff-channel> <category>— Admin-only. Configure which channel receives staff control panels and which category new tickets are created under.
Closed tickets are auto-deleted by tickets.cron.ts after 24 hours.
Track streamers across Twitch, YouTube, and Kick. Notifications are posted automatically when tracked streamers go live.
add <platform> <username> <channel> [display-name]— Track a streamer in the specified channel. Supports Twitch, YouTube (channel ID), and Kick.remove <platform> <username>— Stop tracking a streamerlist— Show all tracked streamers grouped by platform
Max 25 trackers per guild. Requires TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET
for Twitch, YOUTUBE_API_KEY for YouTube. Kick works without credentials.
Delivered by livestreams.cron.ts with a 5-minute renotification cooldown.
Per-guild text snippets with optional role/user-gated viewing.
view <name>— Display a tag (role/user-gated, autocomplete on name)add <name> <content>— Create a tag (admin-only, max 50 per guild)edit <name> <content>— Update a tag (admin-only)remove <name>— Delete a tag (admin-only)allow-role <name> <role>— Grant a role view permission (admin)deny-role <name> <role>— Revoke a role's view permission (admin)allow-user <name> <user>— Grant a user view permission (admin)deny-user <name> <user>— Revoke a user's view permission (admin)list— Show all tag names with permission info
Tags are unrestricted by default; once any role or user is added, only those roles/users (and admins) can view.
Reusable embed builder with role/user-based send permissions. Lets authorized users post rich embeds — something normally impossible without a bot or webhook.
create <name>— Open a modal to build an embed (admin)edit <name>— Open a pre-filled modal to modify an embed (admin)add-field <name> <field-name> <field-value> [inline]— Add a field (admin)remove-field <name> <field-name>— Remove a field (admin)allow-role <name> <role>— Grant a role permission to send (admin)deny-role <name> <role>— Revoke a role's send permission (admin)allow-user <name> <user>— Grant a user permission to send (admin)deny-user <name> <user>— Revoke a user's send permission (admin)preview <name>— Preview the embed privately (anyone)send <name> [channel]— Post the embed (role/user-gated, see below)list— Show all templates with permission info (anyone)delete <name>— Remove a template (admin)
Role/user-based send access: Each template has allowedRoles and
allowedUsers lists. If both are empty, only admins can send. If roles or users
are listed, users with at least one matching role or user ID (or admins) can
send. Non-admins always post to the current channel; admins can specify a
different channel.
Modal fields: Title (required), Description (required), Color (hex, e.g.
#5865f2), Footer, Image URL.
Max 25 templates per guild, up to 25 fields per embed. Autocomplete on names.
Right-click a user to see their display name, username, ID, account creation date, and avatar.
Select menu demo. Choose a color from a dropdown to see a colored embed.
Located in discord/interactions/components/. Auto-discovered and matched by
custom_id.
| File | custom_id | Match | Type | Description |
|---|---|---|---|---|
color-select.ts | color-select | exact | select | Color picker dropdown handler |
example-button.ts | example-button | exact | button | Demo button |
facts-page.ts | facts-page: | prefix | button | Fact pagination |
feedback-modal.ts | feedback-modal | exact | modal | Feedback form → webhook |
giveaway-enter.ts | giveaway-enter: | prefix | button | Giveaway entry (atomic dedup, 10k cap) |
poll-vote.ts | poll-vote: | prefix | button | Poll voting (toggle/switch, 10k cap) |
react-role.ts | react-role: | prefix | button | Role toggle via Discord API |
roll-reveal.ts | roll-reveal: | prefix | button | Reveal a secret dice roll publicly |
template-modal.ts | template-modal: | prefix | modal | Template create/edit modal submission |
ticket-modal.ts | ticket-modal: | prefix | modal | Ticket creation (channel + staff panel) |
ticket-join.ts | ticket-join: | prefix | button | Staff joins ticket channel |
ticket-close.ts | ticket-close: | prefix | button | Opens close-reason modal |
ticket-close-modal.ts | ticket-close-modal: | prefix | modal | Processes close reason, closes ticket |
All cron vals run every 1–5 minutes. Each uses the listDue() + claimDelete()
pattern for exactly-once delivery.
Finds expired giveaways, atomically ends them, picks winners, updates panel, posts announcement. Cleans up ended giveaways after a delay.
Finds expired polls, atomically ends them, patches panel with final vote bars and percentages.
Delivers due reminders in batches of 5. Sends ⏰ <@user>, reminder: {message}
to the original channel. Retries up to 5 times with exponential backoff (1m, 2m,
4m, 8m, 16m). Permanent failures (403/404) drop immediately.
Delivers due messages in batches of 5. Same retry and permanent-failure logic as reminders.
Deletes closed ticket channels after their 24-hour grace period. Uses
claimDelete() for atomic dedup. Treats 404 (already deleted) as success.
On failure, re-inserts with a 1-hour retry delay.
Polls streaming platforms and posts Discord embeds when tracked streamers go
live. Groups trackers by platform — Twitch is batched (up to 100 per API call),
YouTube and Kick are checked in parallel (concurrency limit of 5). Uses atomic
kv.update() to prevent duplicate notifications and a 5-minute cooldown to
avoid spam on rapid online/offline cycles. Skips platforms whose credentials are
not configured.
Template showing webhook usage from a cron job.
discord/persistence/kv.ts — SQLite-backed key-value store. Table:
kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL, due_at INTEGER) with an
index on due_at.
| Method | Description |
|---|---|
get<T>(key) | Read a value by key |
set(key, value, dueAt?) | Upsert. Optional dueAt (epoch ms) for time-based queries. |
delete(key) | Delete by key |
claimDelete(key) | Atomically delete and return true if existed. For exactly-once delivery. |
list(prefix?, limit?) | List entries by prefix. Limit enforced in TypeScript (Val Town SQLite has no LIMIT). |
listDue(now, prefix?, limit?) | List entries where due_at <= now. |
update<T>(key, fn, retries?) | Atomic read-modify-write with optimistic concurrency (CAS). Falls back to unconditional write. |
claimUpdate<T>(key, fn, retries?) | Like update() but strict claim semantics — returns null on missing key, null return, or exhausted retries. No fallback. |
import { kv } from "../../persistence/kv.ts";
await kv.set("user:123", { score: 42 });
const data = await kv.get<{ score: number }>("user:123");
await kv.delete("user:123");
const all = await kv.list("user:");
| Prefix | Description |
|---|---|
cooldown:{command}:{userId} | Per-user command cooldown expiry |
cooldown:games:daily:{userId} | Daily reward 24h cooldown |
counter:{guildId} | Guild counter value |
giveaway:{guildId} | Active/ended giveaway state |
guild_config:{guildId} | Admin roles, enabled commands |
logging:muted_paths | Muted webhook log paths (global) |
manifest | Command/component file manifest |
patreon:discord:{discordId} | Patreon patron record |
poll:{guildId} | Active/ended poll state |
ratelimit:patreon | Patreon webhook rate limit |
react-roles:{guildId} | Role panel config |
reminder:{userId}:{guildId}:{ts}-{rnd} | Individual reminder with due_at |
scheduled-msg:{guildId}:{ts}-{rnd} | Individual scheduled message with due_at |
tags:{guildId} | All tags for a guild |
ticket:{guildId}:{channelId} | Ticket state |
discord/persistence/blob.ts — Re-exports Val Town's blob storage (Cloudflare
R2-backed). Better suited for larger, opaque data that doesn't need indexed
queries.
import { blob } from "../../persistence/blob.ts";
await blob.setJSON("paste:g1:abc123", { content: "hello" });
const data = await blob.getJSON<PasteEntry>("paste:g1:abc123");
await blob.delete("paste:g1:abc123");
const items = await blob.list("paste:g1:");
| Prefix | Description |
|---|---|
backup:{guildId}:{id} | Guild data backup snapshots |
paste:{guildId}:{code} | Server pastebin entries |
stash:{userId}:{name} | Personal clipboard snippets |
template:{guildId}:{name} | Reusable embed templates |
discord/persistence/guild-config.ts — Stored at guild_config:{guildId}.
interface GuildConfig {
guildId: string;
adminRoleIds: string[]; // up to 25
enabledCommands: string[]; // up to 50
createdAt: string;
updatedAt: string;
}
Methods: get(), getAdminRoleIds(), getEnabledCommands(),
setAdminRoles(), addAdminRole(), removeAdminRole(), enableCommand(),
disableCommand(), listGuilds().
isGuildAdmin(guildId, userId, memberRoles, memberPermissions?) in
discord/constants.ts uses a three-tier check:
- Bot owner — User ID matches
CONFIG.appOwnerId(global bypass) - Server administrator — Member permissions bitfield has the
ADMINISTRATORbit - Configured admin role — Member has any role in the guild's
adminRoleIdsfrom KV
Commands with adminOnly: true are gated by this check before execution.
SUCCESS: 0x57f287; // green
ERROR: 0xed4245; // red
INFO: 0x5865f2; // blurple
WARNING: 0xfee75c; // yellow
Discord's Linked Roles feature lets server admins gate roles behind external account verification.
- Set
DISCORD_CLIENT_SECRETenvironment variable - In the Discord Developer Portal under General Information, set Linked
Roles Verification URL to
https://YOUR_ENDPOINT/linked-roles - Under OAuth2, add
https://YOUR_ENDPOINT/linked-roles/callbackas a redirect URI - Register the metadata schema:
GET ?register-metadata=true(password-protected)
- User clicks a linked role in the server → redirected to
/linked-roles - Bot generates CSRF state token, redirects to Discord OAuth2 (scopes:
role_connections.write identify+ verifier extras) - Discord redirects to
/linked-roles/callbackwith code and state - Bot validates CSRF state (HMAC-SHA256, 10-minute expiry), exchanges code for tokens, fetches user, runs verifier, pushes metadata
- User sees success page
| Verifier | File | Metadata | Description |
|---|---|---|---|
| Always Verified | always-verified.ts | verified (boolean) | Always passes. Default. |
| Account Age | account-age.ts | account_age_days (integer) | Extracts creation date from Discord snowflake |
| GitHub | github.ts | public_repos, account_age_days | Reads Discord-linked GitHub, fetches public profile |
| Patreon | patreon.ts | is_patron (boolean) | Reads KV record populated by Patreon webhook |
| Steam | steam.ts | games_owned, account_age_days | Reads Discord-linked Steam, fetches via Steam API |
import { defineVerifier, MetadataType } from "../define-verifier.ts";
import { setVerifier } from "../routes.ts";
const myVerifier = defineVerifier({
name: "My Verifier",
metadata: [
{
key: "level",
name: "Level",
description: "User level must be at least this value",
type: MetadataType.INTEGER_GREATER_THAN_OR_EQUAL,
},
],
async verify(user) {
const level = await fetchLevelFromMyAPI(user.id);
return {
platformName: "My Platform",
platformUsername: user.username,
metadata: { level },
};
},
});
setVerifier(myVerifier);
Update the import in services/interactions.http.ts to point to your verifier.
POST /patreon/webhook — HMAC-MD5 signature verification via
X-Patreon-Signature header. Rate-limited to 30 requests per 60 seconds via KV.
Handles members:create, members:update (writes patron record to KV), and
members:delete (deletes record). Extracts Discord user ID from Patreon's
social connections data.
discord/webhook/logger.ts — Batched Discord webhook logger.
import { createLogger } from "../webhook/logger.ts";
const log = createLogger("my-module");
log.info("Server started");
log.warn("Rate limited");
log.error("Connection failed", error);
log.debug("Verbose detail");
Configuration: webhookUrl, context (module name), minLevel (default
"info"), batchIntervalMs (default 2000), maxBatchSize (default 15),
fallbackToConsole (default true).
Behavior: Errors flush immediately. Other levels schedule a flush after
batchIntervalMs. On flush failure, entries are restored to the buffer (capped
at 100). finalizeAllLoggers() flushes all registered loggers before the
isolate terminates.
Muting: Each logger instance has a muted flag. When muted = true,
info/debug entries are still emitted to console but suppressed from the webhook.
Warnings and errors always get through. The interaction handler and cron helper
set this flag automatically based on paths configured via
/server logging mute. Path format: cmd:<name> or cron:<name> with prefix
matching (muting cmd:games also mutes cmd:games:coinflip). Configuration is
stored in KV at logging:muted_paths (max 100 paths).
Format: **[context]** - N log(s) followed by {emoji} HH:MM:SS message
discord/webhook/send.ts — Send messages and embeds to Discord webhooks.
import { send } from "../webhook/send.ts";
await send("Hello world", webhookUrl);
await send([embed1, embed2], webhookUrl);
Chunking: Strings split at 2000 chars (breaking at newlines/spaces). Embeds batched into groups of 10 staying under 6000 total characters.
Discord limits enforced: Content 2000, embed title 256, description 4096, fields 25, field name 256, field value 1024, footer 2048, author name 256, total embed chars 6000, embeds per message 10.
Rate limiting: On 429, waits Retry-After (capped 10s), retries once. On
401/403/404, retries with DISCORD_CONSOLE fallback webhook if different.
discord/interactions/registration.ts handles registering commands with
Discord's API.
- Global commands — Bulk overwrite via
PUT /applications/{appId}/commands - Guild commands — Per-guild overwrite via
PUT /applications/{appId}/guilds/{guildId}/commands
Functions: registerGlobalCommands(), syncAllGuilds(),
registerCommand(name, guildId), registerCommandsToGuild(guildId),
deregisterCommandFromGuild(name, guildId), deregisterAllFromGuild(guildId),
fetchRegisteredCommands(guildId?).
registerCommandsToGuild() always bulk PUTs from guild config — reads enabled
commands from KV and overwrites the guild's registered commands to match.
registerCommand() requires a guild ID and validates the command is enabled
before registering. syncAllGuilds() is the only function that touches all
guilds.
GET /?discover=true scans the project using Val Town's listFiles(), finds
.ts files in commands/ and components/, and saves the manifest to KV. The
registry loads this manifest on cold start to dynamically import all command and
component files. Falls back to a static manifest if KV is empty.
import { parseDuration } from "../../helpers/duration.ts";
parseDuration("1h30m"); // 5400000 (ms)
parseDuration("2d"); // 172800000
Supports s (seconds), m (minutes), h (hours), d (days), combinable.
Returns null if invalid or exceeds 30 days.
import { embed } from "../../helpers/embed-builder.ts";
const e = embed()
.title("Hello")
.description("World")
.color(0x5865f2)
.field("Name", "Value", true)
.footer("Footer text")
.timestamp()
.build();
Presets: .success(desc), .error(desc), .info(desc), .warning(desc).
timingSafeEqual(a, b)— Constant-time string comparison via HMAC to prevent timing attackssecureRandomIndex(max)— Cryptographically secure random integer in[0, max)using rejection sampling (no modulo bias)
import { ExpiringCache } from "../../helpers/cache.ts";
const cache = new ExpiringCache<string, Item[]>(30_000, 500);
const items = await cache.getOrFetch(key, () => fetchItems());
cache.delete(key); // invalidate
Generic cache with TTL expiry and max-entry eviction. Used by paste, template, tag, stash, backup, and schedule commands for autocomplete caching.
import { checkEntityAccess, blobAllow, blobDeny, kvAllow, kvDeny } from "../../helpers/permissions.ts";
// Check if user can access an entity (open by default)
await checkEntityAccess(entry, guildId, userId, roles, perms);
// Check with closed default (admin-only when no restrictions)
await checkEntityAccess(entry, guildId, userId, roles, perms, { defaultOpen: false });
Shared permission checking and role/user CRUD for blob-stored and KV-stored entities. Used by paste, template, and tag commands.
import { formatPermissionInfo, discordTimestamp } from "../../helpers/format.ts";
formatPermissionInfo(entry); // " (roles: <@&r1>; users: <@u1>)"
formatPermissionInfo(entry, "admin-only"); // " (admin-only)" when no restrictions
discordTimestamp(Date.now()); // "<t:1700000000:R>"
import { runCron, deliverWithRetry } from "../../helpers/cron.ts";
await runCron({
name: "MyCron",
prefix: "myprefix:",
process: async (entry, logger) => {
await deliverWithRetry({ entry, deliver: ..., logger, entityLabel: "item" });
},
});
Shared cron lifecycle (logger, listDue, allSettled, finalize) and claim-delete + delivery + backoff retry pattern.
UserFacingError — Custom error class with a userMessage shown to Discord
users and an optional internalMessage for logs. All other errors show a
generic message with an 8-char interaction ID reference.
Create a file in discord/interactions/commands/:
import { defineCommand } from "../define-command.ts";
export default defineCommand({
name: "hello",
description: "Say hello",
registration: { type: "guild", servers: ["MAIN"] },
deferred: false,
ephemeral: false,
async execute({ userId }) {
return { success: true, message: `Hello <@${userId}>!` };
},
});
Then enable it with /server commands enable command:hello and it will be
registered automatically. Or use /commands register hello to register it
manually.
| Property | Default | Description |
|---|---|---|
name | — | Command name |
description | — | Command description |
type | 1 (CHAT_INPUT) | 2 for USER context menu, 3 for MESSAGE context menu |
registration | — | { type: "global" } or { type: "guild" } |
deferred | true | false for instant response, true for background execution |
ephemeral | true | false to make responses visible to the whole channel |
adminOnly | false | Restrict to admins (via isGuildAdmin) |
cooldown | 3 | Seconds between uses per user |
options | [] | Discord command options array |
The execute function receives:
{
userId, guildId, channelId, interactionId, interactionToken,
options, // parsed options (flat or subcommand-prefixed)
targetId, // for context menu commands
resolved, // resolved users/members/channels/roles
memberRoles, // array of role IDs
subcommand, // parsed subcommand name (e.g. "admin:add")
}
{
success: boolean;
message?: string; // text content
embeds?: Embed[]; // Discord embeds
components?: Component[];// action rows
modal?: ModalData; // open a modal (non-deferred only)
updateMessage?: boolean; // update the original message (components only)
}
Create a file in discord/interactions/components/:
import { defineComponent } from "../define-component.ts";
export default defineComponent({
customId: "my-button",
match: "exact",
type: "button",
async execute({ userId }) {
return { success: true, message: `Clicked by <@${userId}>!` };
},
});
Match modes: "exact" for full custom_id match, "prefix" for prefix match
(useful for dynamic IDs like delete:123).
Return a modal from a non-deferred command, then handle the submission with a
component handler:
// Command returns a modal
async execute() {
return {
success: true,
modal: {
title: "Feedback",
custom_id: "feedback-modal",
components: [
{ type: 1, components: [{ type: 4, custom_id: "topic", label: "Topic", style: 1, required: true }] },
],
},
};
}
// components/feedback-modal.ts — sends submission to FEEDBACK_WEBHOOK
export default defineComponent({
customId: "feedback-modal",
match: "exact",
type: "modal",
async execute({ fields, userId }) {
if (CONFIG.feedbackWebhook) {
await send({ title: "New Feedback", fields: [...] }, CONFIG.feedbackWebhook);
}
return { success: true, message: "", embed: { title: "Feedback Received", ... } };
},
});
Return components with a select menu, then handle the selection:
// Command returns a select menu
async execute() {
return {
success: true,
message: "Choose:",
components: [{
type: 1,
components: [{ type: 3, custom_id: "my-select", options: [{ label: "A", value: "a" }] }],
}],
};
}
// components/my-select.ts
export default defineComponent({
customId: "my-select",
match: "exact",
type: "select",
async execute({ values }) {
return {
success: true,
updateMessage: true,
message: `You picked: ${values?.[0]}`,
};
},
});
Return buttons with encoded state in the custom_id, then use a prefix-match
handler:
// components/my-page.ts
export default defineComponent({
customId: "my-page:",
match: "prefix",
type: "button",
async execute({ customId }) {
const page = parseInt(customId.split(":")[1], 10);
return { success: true, updateMessage: true, message: `Page ${page}`, components: [...] };
},
});
Catnip has a comprehensive test suite — 126 test files with 1621 tests and a 100% pass rate. Tests run on Deno's built-in test runner with no external test dependencies.
deno test --allow-env --allow-net --no-check
| Layer | Files | Tests | What's Covered |
|---|---|---|---|
| Core infrastructure | 6 | 61 | Config loading, API retry logic, crypto helpers, duration parsing, embed builder, timeouts |
| Interaction framework | 7 | 55 | Handler dispatch, auto-discovery, command factory, component factory, error handling, patterns, registration |
| Commands | 14 | 193 | backup, facts, giveaway, paste, poll, roll, remind, schedule, server, stash, tag, template, ticket |
| Components | 9 | 66 | giveaway-enter, poll-vote, react-role, roll-reveal, template-modal, ticket-close, ticket-close-modal, ticket-join, ticket-modal |
| Persistence | 2 | 43 | KV store CRUD, atomic operations, optimistic concurrency, time-based queries, guild config |
| Linked roles | 5 | 34 | OAuth2 flow, verifier factory, Patreon webhook, routes, CSRF state tokens |
| Webhooks | 2 | 36 | Batched logger (flush, levels, truncation), message sending (chunking, embeds, rate limits) |
| Cron jobs | 6 | 35 | Giveaway auto-end, poll auto-end, reminder delivery, scheduled messages, ticket expiry, livestream notifications |
| HTTP endpoint | 1 | 12 | Signature verification, routing, health check, admin auth |
| HTML pages | 1 | 6 | Legal pages rendering, security headers |
All external dependencies are mocked so tests run offline and in isolation:
test/_mocks/sqlite.ts— In-memory SQLite mock with full query supporttest/_mocks/blob.ts— In-memory blob storage mock (getJSON, setJSON, delete, list, copy, move)test/_mocks/fetch.ts— Configurable HTTP fetch mock for Discord API callstest/_mocks/env.ts— Environment variable mock for config testingtest/_mocks/sign.ts— Ed25519 request signing for interaction teststest/_mocks/val-utils.ts— Val Town runtime utilities mock
├── discord/
│ ├── constants.ts # CONFIG, isGuildAdmin, EmbedColors
│ ├── discord-api.ts # Discord API client with retry logic
│ ├── pages.ts # HTML pages (legal, linked roles)
│ ├── helpers/
│ │ ├── cache.ts # ExpiringCache with TTL + max entries
│ │ ├── cron.ts # runCron() + deliverWithRetry() helpers
│ │ ├── crypto.ts # timingSafeEqual, secureRandomIndex
│ │ ├── duration.ts # Human-readable duration parser
│ │ ├── embed-builder.ts # Fluent embed builder
│ │ ├── format.ts # formatPermissionInfo, discordTimestamp
│ │ ├── permissions.ts # checkEntityAccess, blob/KV perm CRUD
│ │ └── timeout.ts # withTimeout() utility
│ ├── linked-roles/
│ │ ├── define-verifier.ts # defineVerifier() helper and types
│ │ ├── oauth.ts # Discord OAuth2 token exchange
│ │ ├── patreon-webhook.ts # Patreon webhook handler
│ │ ├── register-metadata.ts # Push metadata schema to Discord
│ │ ├── routes.ts # HTTP route handlers + verifier registry
│ │ ├── state.ts # HMAC-SHA256 CSRF state tokens
│ │ └── verifiers/
│ │ ├── account-age.ts # Discord account age verifier
│ │ ├── always-verified.ts# Always-true verifier (default)
│ │ ├── github.ts # GitHub profile verifier
│ │ ├── patreon.ts # Patreon patron verifier
│ │ └── steam.ts # Steam profile verifier
│ ├── persistence/
│ │ ├── blob.ts # Blob storage (Val Town / Cloudflare R2)
│ │ ├── guild-config.ts # Per-guild config (admin roles, commands)
│ │ ├── kv.ts # Key-value store (Val Town SQLite)
│ │ └── log-config.ts # Webhook log muting config (KV-backed)
│ ├── interactions/
│ │ ├── auto-discover.ts # File discovery, saves manifest to KV
│ │ ├── define-command.ts # defineCommand() factory
│ │ ├── define-component.ts # defineComponent() factory
│ │ ├── errors.ts # UserFacingError class
│ │ ├── handler.ts # Main interaction dispatcher
│ │ ├── manifest.ts # Static fallback manifest
│ │ ├── patterns.ts # Discord API constants & autocomplete
│ │ ├── registration.ts # Command registration logic
│ │ ├── registry.ts # Command & component registry (KV-backed)
│ │ ├── commands/
│ │ │ ├── about.ts # Bot info
│ │ │ ├── backup.ts # Guild data export/import
│ │ │ ├── coin-flip.ts # Coin flip
│ │ │ ├── color-picker.ts # Select menu demo
│ │ │ ├── commands.ts # Admin: manage registration
│ │ │ ├── counter.ts # Persistent counter
│ │ │ ├── echo.ts # Echo input
│ │ │ ├── facts.ts # Paginated facts
│ │ │ ├── feedback.ts # Modal demo
│ │ │ ├── giveaway.ts # Giveaway system
│ │ │ ├── help.ts # List commands
│ │ │ ├── paste.ts # Server pastebin
│ │ │ ├── pick.ts # Random picker
│ │ │ ├── ping.ts # Health check
│ │ │ ├── poll.ts # Poll system
│ │ │ ├── roll.ts # Dice roller
│ │ │ ├── react-roles.ts # Self-assign role panels
│ │ │ ├── remind.ts # Personal reminders
│ │ │ ├── schedule.ts # Scheduled messages
│ │ │ ├── server.ts # Guild configuration
│ │ │ ├── slow-echo.ts # Deferred command demo
│ │ │ ├── stash.ts # Personal clipboard
│ │ │ ├── tag.ts # Custom text tags
│ │ │ ├── template.ts # Embed builder & poster
│ │ │ ├── ticket.ts # Ticket system
│ │ │ └── user-info.ts # User context menu
│ │ └── components/
│ │ ├── color-select.ts # Color picker handler
│ │ ├── example-button.ts # Button demo handler
│ │ ├── facts-page.ts # Fact pagination handler
│ │ ├── feedback-modal.ts # Feedback modal handler
│ │ ├── giveaway-enter.ts # Giveaway entry handler
│ │ ├── poll-vote.ts # Poll vote handler
│ │ ├── react-role.ts # Role toggle handler
│ │ ├── roll-reveal.ts # Dice roll reveal handler
│ │ ├── template-modal.ts # Template create/edit modal handler
│ │ ├── ticket-close.ts # Ticket close handler
│ │ ├── ticket-close-modal.ts # Close reason modal handler
│ │ ├── ticket-join.ts # Ticket join handler
│ │ └── ticket-modal.ts # Ticket creation modal handler
│ └── webhook/
│ ├── logger.ts # Batched Discord webhook logger
│ └── send.ts # Webhook message sending
├── services/
│ ├── interactions.http.ts # HTTP endpoint (all routes)
│ ├── example.cron.ts # Cron job template
│ ├── giveaways.cron.ts # Auto-end expired giveaways
│ ├── polls.cron.ts # Auto-end expired polls
│ ├── reminders.cron.ts # Deliver due reminders
│ ├── scheduled-messages.cron.ts# Deliver due scheduled messages
│ ├── tickets.cron.ts # Expire inactive tickets
│ └── livestreams.cron.ts # Post go-live notifications
└── test/
└── _mocks/ # Test infrastructure mocks
This project is licensed under the MIT License.