• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
catalloc

catalloc

catnip

Public
Open source Discord bot template built for Val Town
Like
catnip
Home
Code
13
discord
13
services
15
test
2
.env.example
.gitignore
.vtignore
AGENTS.md
CHANGELOG.md
CONTRIBUTING.md
LICENSE
README.md
SECURITY.md
deno.json
Connections
Environment variables
8
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
README.md
Code
/
README.md
Search
3/11/2026
Viewing readonly version of main branch: v331
View latest version
README.md

Catnip

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.

Table of Contents

  • 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
    • Global Commands
    • Guild 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

Features

  • 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
  • Linked roles — Discord OAuth2 verification with pluggable verifiers (Steam, GitHub, Patreon, account age)
  • Webhook logging — Batched Discord webhook logger with log levels and auto-flush
  • 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" }

Deployment

This guide walks you through remixing Catnip on Val Town and getting it running in your Discord server.

1. Fork the Project

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.

2. Create a Discord Application

  1. Go to the Discord Developer Portal
  2. Click New Application and give it a name
  3. Note the following values from the portal — you'll need them for environment variables:
    • General Information → APPLICATION ID and PUBLIC KEY
    • Bot → Click Reset Token to generate a BOT TOKEN
  4. Under Bot, make sure Public Bot is toggled to your preference (on = anyone can invite, off = only you)
  5. Under Bot → Privileged Gateway Intents, no intents are required — the bot is interactions-only and does not use the gateway

3. Set Environment Variables

In your Val Town project, go to Settings → Environment Variables and add:

VariableRequiredValue
DISCORD_APP_IDYesApplication ID from the portal
DISCORD_PUBLIC_KEYYesPublic Key from the portal
DISCORD_BOT_TOKENYesBot token from the portal
DISCORD_APP_OWNER_IDRecommendedYour personal Discord user ID (grants global admin bypass)
ADMIN_PASSWORDRecommendedA 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.

4. Configure the Interactions Endpoint

  1. Find the URL of your interactions.http.ts val — it will look like https://<your-username>-catnip-interactionshttp.web.val.run
  2. In the Discord Developer Portal, go to General Information
  3. Set Interactions Endpoint URL to your val's URL
  4. Discord will send a verification ping — if your environment variables are set correctly, it will succeed and save

Admin Requests

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.

5. Discover Commands

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.

6. Register Commands

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.

7. Invite the Bot

Build an invite URL using the Discord Developer Portal:

  1. Go to OAuth2 → URL Generator
  2. Select scopes: bot and applications.commands
  3. 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
  4. 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.

8. Set Up Cron Jobs

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 ValPurposeRequired For
services/giveaways.cron.tsAuto-end expired giveaways/giveaway
services/polls.cron.tsAuto-end expired polls/poll
services/reminders.cron.tsDeliver due reminders/remind
services/scheduled-messages.cron.tsDeliver due messages/schedule
services/livestreams.cron.tsPost 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.

9. Configure Your Server

Once the bot is in your server and global commands have propagated:

  1. Set admin roles (optional): /server admin add role:@Moderator — lets users with that role manage the bot without needing Discord Administrator permission
  2. 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, r, etc.)
  3. 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.

10. Optional: Linked Roles

If you want to use Discord's Linked Roles feature:

  1. Set DISCORD_CLIENT_SECRET in your Val Town environment variables (found in the Discord Developer Portal under OAuth2 → Client Secret)
  2. In the portal under General Information, set Linked Roles Verification URL to https://YOUR_VAL_URL/linked-roles
  3. Under OAuth2 → Redirects, add https://YOUR_VAL_URL/linked-roles/callback
  4. Register the metadata schema by making an admin request with ?register-metadata=true
  5. 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.

11. Optional: Webhook Logging

To send bot logs to a Discord channel:

  1. Create a webhook in a private channel (Channel Settings → Integrations → Webhooks → New Webhook)
  2. Copy the webhook URL
  3. Set DISCORD_CONSOLE in 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.

12. Optional: Legal Pages

Discord requires apps to have a Terms of Service and Privacy Policy URL:

  1. 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

The pages are served directly from the bot. To customize the content, edit discord/pages.ts.

Restricting Bot Access

Three approaches to control who can use your bot, from broadest to most specific:

  1. 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.

  2. 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
    
  3. Auth-gated /invite route — Visit GET /invite with a Bearer token (same ADMIN_PASSWORD as 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.

Environment Variables

VariableRequiredDescription
DISCORD_APP_IDYesDiscord application ID
DISCORD_PUBLIC_KEYYesEd25519 public key for interaction signature verification
DISCORD_BOT_TOKENYesBot token for Discord API calls
DISCORD_APP_OWNER_IDNoYour Discord user ID (global admin bypass)
DISCORD_CONSOLENoWebhook URL for logger output
DISCORD_CLIENT_SECRETNoRequired for Linked Roles OAuth2 flow
ADMIN_PASSWORDNoPassword for admin HTTP endpoints (?discover, ?register, ?register-metadata)
STEAM_API_KEYNoSteam Web API key (for Steam linked role verifier)
PATREON_WEBHOOK_SECRETNoPatreon webhook HMAC-MD5 secret
FEEDBACK_WEBHOOKNoWebhook URL for /feedback submissions (command disabled when unset)
TWITCH_CLIENT_IDNoTwitch application client ID (for /livestream Twitch tracking)
TWITCH_CLIENT_SECRETNoTwitch application client secret
YOUTUBE_API_KEYNoYouTube Data API v3 key (for /livestream YouTube tracking)
ALLOWED_GUILD_IDSNoComma-separated guild IDs to restrict the bot to (empty = all guilds allowed)

Required variables throw immediately at module load if missing.

Architecture

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.

HTTP Endpoint

services/interactions.http.ts routes all incoming requests:

GET Routes

Path / QueryAuthDescription
/termsNoneTerms of Service page
/privacyNonePrivacy Policy page
/inviteBearerInvite page with bot invite URL
/linked-rolesNoneInitiates Discord OAuth2 for linked role verification
/linked-roles/callbackNoneHandles OAuth2 callback
/?discover=trueBearerScans project files and saves command/component manifest to KV
/?register=trueBearerBulk-registers all commands with Discord
/?register-metadata=trueBearerPushes linked roles metadata schema to Discord
GET /NoneHealth check — { "status": "ok", "timestamp": "..." }

POST Routes

PathDescription
/patreon/webhookPatreon 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

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

Global Commands

/ping

Health check. Returns "Pong!" (ephemeral).

/help

Lists all non-admin commands alphabetically as an embed.

/commands (admin-only)

Manage command registration with Discord.

  • register <command> — Register a command for this guild (or all for 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 (or all). 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).

/server (admin-only)

Per-guild bot configuration.

  • admin add <role> — Add a role as a bot admin role (max 25)
  • admin remove <role> — Remove an admin role
  • admin list — Show configured admin roles
  • commands enable <command> — Enable a guild command, bulk overwrites all enabled commands for this guild with Discord
  • commands disable <command> — Disable a guild command, bulk overwrites remaining enabled commands (removes the disabled one from Discord)
  • commands list — Show status of all guild commands
  • info — Full guild config summary

Guild Commands

These must be enabled per-server via /server commands enable.

/about

Bot info embed with command count and runtime details.

/backup (admin-only)

Guild data export and import.

  • export — Snapshot tags, templates, and counter to blob storage
  • import <id> — Restore from a backup (overwrites current data)
  • list — Show available backups with timestamps, creator, and contents
  • delete <id> — Remove a backup

Max 5 backups per guild. Autocomplete on backup IDs.

/coin-flip

Flip a coin using cryptographically secure randomness.

/counter [action]

Per-guild persistent counter. Increment by default, pass reset to reset. Uses atomic KV update().

/echo <message>

Echoes input back. Sanitizes @everyone, @here, and mention syntax.

/facts

Browse 8 fun facts with Previous/Next pagination buttons.

/feedback

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.

/giveaway (admin-only)

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 announcement
  • reroll — 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.

/paste

Server pastebin using blob storage.

  • create <content> — Store text (max 6000 chars), get an 8-char hex code
  • get <code> [public] — Retrieve a paste (role/user-gated; ephemeral by default, public: true to show the channel)
  • list — Show all pastes with code, content preview, and permission info
  • delete <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 <choices>

Pick a random item from a comma-separated list (min 2 choices).

/poll (admin-only)

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.

/r <dice> [secret] [announce]

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: /r dice:1d20, /r dice:4d6, /r dice:2d20+5, /r dice:1d20 secret:True announce:True

/react-roles (admin-only)

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 panel
  • list — Show current configuration
  • send <channel> — Post or update the role panel (patches existing message if present)
  • clear — Delete all config

Users click buttons to toggle roles on/off.

/remind <duration> <message>

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.

/schedule (admin-only)

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 time
  • cancel <id> — Cancel a pending message (autocomplete shows pending)

Max 25 per guild. Delivered by scheduled-messages.cron.ts.

/slow-echo <message> [delay]

Deferred command demo. Waits 1–10 seconds (default 3) then echoes. 10-second cooldown.

/stash

Personal clipboard — snippets persist across all servers.

  • save <name> <content> — Save or overwrite a named snippet (max 4000 chars)
  • get <name> — Recall a snippet
  • list — Show all entries with previews
  • delete <name> — Remove a snippet

Max 25 entries per user. Names sanitized to lowercase alphanumeric + hyphens (max 32 chars). Always ephemeral. Autocomplete on names.

/ticket

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.

/livestream (admin-only)

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 streamer
  • list — 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.

/tag

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.

/template

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.

/user-info (context menu)

Right-click a user to see their display name, username, ID, account creation date, and avatar.

/color-picker

Select menu demo. Choose a color from a dropdown to see a colored embed.

Component Handlers

Located in discord/interactions/components/. Auto-discovered and matched by custom_id.

Filecustom_idMatchTypeDescription
color-select.tscolor-selectexactselectColor picker dropdown handler
example-button.tsexample-buttonexactbuttonDemo button
facts-page.tsfacts-page:prefixbuttonFact pagination
feedback-modal.tsfeedback-modalexactmodalFeedback form → webhook
giveaway-enter.tsgiveaway-enter:prefixbuttonGiveaway entry (atomic dedup, 10k cap)
poll-vote.tspoll-vote:prefixbuttonPoll voting (toggle/switch, 10k cap)
react-role.tsreact-role:prefixbuttonRole toggle via Discord API
roll-reveal.tsroll-reveal:prefixbuttonReveal a secret dice roll publicly
template-modal.tstemplate-modal:prefixmodalTemplate create/edit modal submission
ticket-modal.tsticket-modal:prefixmodalTicket creation (channel + staff panel)
ticket-join.tsticket-join:prefixbuttonStaff joins ticket channel
ticket-close.tsticket-close:prefixbuttonOpens close-reason modal
ticket-close-modal.tsticket-close-modal:prefixmodalProcesses close reason, closes ticket

Cron Jobs

All cron vals run every 1–5 minutes. Each uses the listDue() + claimDelete() pattern for exactly-once delivery.

giveaways.cron.ts

Finds expired giveaways, atomically ends them, picks winners, updates panel, posts announcement. Cleans up ended giveaways after a delay.

polls.cron.ts

Finds expired polls, atomically ends them, patches panel with final vote bars and percentages.

reminders.cron.ts

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.

scheduled-messages.cron.ts

Delivers due messages in batches of 5. Same retry and permanent-failure logic as reminders.

tickets.cron.ts

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.

livestreams.cron.ts

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.

example.cron.ts

Template showing webhook usage from a cron job.

KV Store

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.

Methods

MethodDescription
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.

Usage

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:");

Key Namespaces

PrefixDescription
cooldown:{command}:{userId}Per-user command cooldown expiry
counter:{guildId}Guild counter value
giveaway:{guildId}Active/ended giveaway state
guild_config:{guildId}Admin roles, enabled commands
manifestCommand/component file manifest
patreon:discord:{discordId}Patreon patron record
poll:{guildId}Active/ended poll state
ratelimit:patreonPatreon 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

Blob Storage

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.

Usage

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:");

Key Namespaces

PrefixDescription
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

Guild Configuration

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().

Admin System

isGuildAdmin(guildId, userId, memberRoles, memberPermissions?) in discord/constants.ts uses a three-tier check:

  1. Bot owner — User ID matches CONFIG.appOwnerId (global bypass)
  2. Server administrator — Member permissions bitfield has the ADMINISTRATOR bit
  3. Configured admin role — Member has any role in the guild's adminRoleIds from KV

Commands with adminOnly: true are gated by this check before execution.

Embed Colors

SUCCESS: 0x57f287; // green ERROR: 0xed4245; // red INFO: 0x5865f2; // blurple WARNING: 0xfee75c; // yellow

Linked Roles

Discord's Linked Roles feature lets server admins gate roles behind external account verification.

Setup

  1. Set DISCORD_CLIENT_SECRET environment variable
  2. In the Discord Developer Portal under General Information, set Linked Roles Verification URL to https://YOUR_ENDPOINT/linked-roles
  3. Under OAuth2, add https://YOUR_ENDPOINT/linked-roles/callback as a redirect URI
  4. Register the metadata schema: GET ?register-metadata=true (password-protected)

Flow

  1. User clicks a linked role in the server → redirected to /linked-roles
  2. Bot generates CSRF state token, redirects to Discord OAuth2 (scopes: role_connections.write identify + verifier extras)
  3. Discord redirects to /linked-roles/callback with code and state
  4. Bot validates CSRF state (HMAC-SHA256, 10-minute expiry), exchanges code for tokens, fetches user, runs verifier, pushes metadata
  5. User sees success page

Built-in Verifiers

VerifierFileMetadataDescription
Always Verifiedalways-verified.tsverified (boolean)Always passes. Default.
Account Ageaccount-age.tsaccount_age_days (integer)Extracts creation date from Discord snowflake
GitHubgithub.tspublic_repos, account_age_daysReads Discord-linked GitHub, fetches public profile
Patreonpatreon.tsis_patron (boolean)Reads KV record populated by Patreon webhook
Steamsteam.tsgames_owned, account_age_daysReads Discord-linked Steam, fetches via Steam API

Creating a Custom Verifier

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.

Patreon Webhook

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.

Webhook Logging

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.

Format: **[context]** - N log(s) followed by {emoji} HH:MM:SS message

Webhook Sending

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.

Command Registration

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.

Discovery

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.

Helpers

Duration Parser (discord/helpers/duration.ts)

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.

Embed Builder (discord/helpers/embed-builder.ts)

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).

Crypto (discord/helpers/crypto.ts)

  • timingSafeEqual(a, b) — Constant-time string comparison via HMAC to prevent timing attacks
  • secureRandomIndex(max) — Cryptographically secure random integer in [0, max) using rejection sampling (no modulo bias)

TTL Cache (discord/helpers/cache.ts)

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.

Permissions (discord/helpers/permissions.ts)

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.

Format (discord/helpers/format.ts)

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>"

Cron (discord/helpers/cron.ts)

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.

Errors (discord/interactions/errors.ts)

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.

Adding a New Command

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.

Command Options

PropertyDefaultDescription
name—Command name
description—Command description
type1 (CHAT_INPUT)2 for USER context menu, 3 for MESSAGE context menu
registration—{ type: "global" } or { type: "guild" }
deferredtruefalse for instant response, true for background execution
ephemeraltruefalse to make responses visible to the whole channel
adminOnlyfalseRestrict to admins (via isGuildAdmin)
cooldown3Seconds between uses per user
options[]Discord command options array

Execution Context

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") }

Response Shape

{ 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) }

Adding a Component Handler

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).

Modal Dialogs

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", ... } }; }, });

Select Menus

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]}`, }; }, });

Pagination with Buttons

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: [...] }; }, });

Testing

Catnip has a comprehensive test suite — 57 test files with 684 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

Coverage by Layer

LayerFilesTestsWhat's Covered
Core infrastructure661Config loading, API retry logic, crypto helpers, duration parsing, embed builder, timeouts
Interaction framework755Handler dispatch, auto-discovery, command factory, component factory, error handling, patterns, registration
Commands14193backup, facts, giveaway, paste, poll, r, remind, schedule, server, stash, tag, template, ticket
Components966giveaway-enter, poll-vote, react-role, roll-reveal, template-modal, ticket-close, ticket-close-modal, ticket-join, ticket-modal
Persistence243KV store CRUD, atomic operations, optimistic concurrency, time-based queries, guild config
Linked roles534OAuth2 flow, verifier factory, Patreon webhook, routes, CSRF state tokens
Webhooks236Batched logger (flush, levels, truncation), message sending (chunking, embeds, rate limits)
Cron jobs635Giveaway auto-end, poll auto-end, reminder delivery, scheduled messages, ticket expiry, livestream notifications
HTTP endpoint112Signature verification, routing, health check, admin auth
HTML pages16Legal pages rendering, security headers

Test Infrastructure

All external dependencies are mocked so tests run offline and in isolation:

  • test/_mocks/sqlite.ts — In-memory SQLite mock with full query support
  • test/_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 calls
  • test/_mocks/env.ts — Environment variable mock for config testing
  • test/_mocks/sign.ts — Ed25519 request signing for interaction tests
  • test/_mocks/val-utils.ts — Val Town runtime utilities mock

Project Structure

├── 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)
│   ├── 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
│   │   │   ├── r.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

License

This project is licensed under the MIT License.

FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
AboutAlternativesPricingBlogNewsletterCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.