A clean, general-purpose Discord bot template built for Val Town. Provides slash command handling, webhook messaging, and structured logging out of the box.
- Slash command framework - Define commands with
defineCommand(), auto-registered to Discord - Interaction handler - Native Ed25519 signature verification, subcommand parsing, fast/deferred command routing
- Component & modal handling - Auto-discovered handlers for buttons, selects, and modals via
defineComponent() - Context menu commands - User and message context menu support with resolved data
- Webhook messaging - Send text and embeds with chunking, rate-limit handling, and fallback support
- Structured logging - Batched Discord webhook logger with log levels
- Admin commands - Built-in
/commands registerand/commands unregisterfor managing bot commands via Discord - Command cooldowns - Per-user cooldowns with configurable duration
- Health check - GET endpoint returns
{ status: "ok" }for monitoring - Persistence - Minimal KV store wrapping Val Town SQLite
- Fork this project on Val Town
- Create a Discord application at discord.com/developers
- Set environment variables (see below)
- Set the Interactions Endpoint URL in your Discord app settings to your Val Town HTTP endpoint URL (the
interactions.http.tsval) - Run
/commands registerin your Discord server to register the bot's commands
| Variable | Required | Description |
|---|---|---|
DISCORD_APP_ID | Yes | Discord application ID |
DISCORD_PUBLIC_KEY | Yes | Discord public key (for signature verification) |
DISCORD_BOT_TOKEN | Yes | Discord bot token |
DISCORD_GUILD_ID | Yes | Your Discord server (guild) ID |
DISCORD_APP_OWNER_ID | No | Your Discord user ID (for admin command access) |
DISCORD_ADMIN_ROLE_ID | No | Role ID authorized for admin commands |
DISCORD_CONSOLE | No | Webhook URL for logger output |
- Create a new file in
discord/interactions/commands/:
// discord/interactions/commands/hello.ts
import { defineCommand } from "../define-command.ts";
export default defineCommand({
name: "hello",
description: "Say hello",
registration: { type: "guild", servers: ["MAIN"] },
deferred: false,
async execute({ userId }) {
return { success: true, message: `Hello <@${userId}>!` };
},
});
- Use
/commands registerin Discord to register the new command.
Create a user or message context menu command by setting type: 2 (USER) or type: 3 (MESSAGE):
// discord/interactions/commands/user-info.ts
import { defineCommand } from "../define-command.ts";
export default defineCommand({
name: "User Info",
description: "",
type: 2, // USER context menu
registration: { type: "guild", servers: ["MAIN"] },
deferred: false,
async execute({ targetId, resolved }) {
const user = resolved?.users?.[targetId!];
return { success: true, message: `User: ${user?.username}` };
},
});
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
export default defineComponent({
customId: "feedback-modal",
match: "exact",
type: "modal",
async execute({ fields }) {
return { success: true, message: `Topic: ${fields?.topic}` };
},
});
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: [...] };
},
});
- Create a new file in
discord/interactions/components/:
// discord/interactions/components/my-button.ts
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}>!` };
},
});
Component handlers are auto-discovered. Match modes: "exact" for full custom_id match, "prefix" for prefix match (useful for dynamic IDs like delete:123).
Add a cooldown property (in seconds) to any command definition:
export default defineCommand({
name: "my-command",
cooldown: 10, // 10 seconds between uses per user
// ...
});
Custom text snippets stored per-guild. Anyone can view tags; adding, editing, and removing requires admin permissions.
Subcommands:
view <name>— Display a tag's content (autocomplete on name)add <name> <content>— Create a new tag (admin-only, max 50 per guild)edit <name> <content>— Update an existing tag (admin-only)remove <name>— Delete a tag (admin-only)list— Show all available tag names
Example:
- Create a tag:
/tag add name:rules content:Please read #rules before posting. - View it:
/tag view name:rules - Anyone can view, only admins can manage.
Admin-only giveaway system with button entry. One active giveaway per guild.
Subcommands:
create <prize> <duration> <channel> [winners]— Start a giveaway (posts panel with Enter button)end— End the current giveaway early and pick winnersreroll— Pick new winner(s) from the ended giveaway's entrants
Example:
- Create:
/giveaway create prize:Nitro duration:1d channel:#giveaways winners:2 - Users click "Enter Giveaway" button on the panel
- End early or let the cron job end it automatically
- Reroll if needed:
/giveaway reroll
Cron: Schedule services/giveaways.cron.ts to run every 1-5 minutes to auto-end expired giveaways.
Personal reminders delivered as channel messages. Open to all users.
Usage: /remind duration:1h message:Check the oven
- Duration supports:
s(seconds),m(minutes),h(hours),d(days), and combinations like1d12h - Maximum 10 active reminders per user
- Maximum duration: 30 days
- Reminder is posted to the channel where the command was used
Cron: Schedule services/reminders.cron.ts to run every 1-5 minutes to deliver due reminders.
Admin-only button-based polls. One active poll per guild.
Subcommands:
create <question> <options> <channel> [duration]— Start a poll (options are comma-separated, 2–10 choices)end— End the active poll and show final results
Example:
- Create:
/poll create question:Fav color? options:Red,Blue,Green channel:#general duration:1h - Users click buttons to vote (click again to remove, click different to switch)
- End early with
/poll endor let the cron auto-end it - Omit
durationfor a poll with no time limit
Cron: Schedule services/polls.cron.ts to run every 1-5 minutes to auto-end expired polls.
Admin-only time-delayed message delivery.
Subcommands:
send <channel> <time> <message>— Schedule a message (e.g.time:2h)list— Show pending scheduled messages for this guildcancel <id>— Cancel a pending message (autocomplete on id)
Example:
- Schedule:
/schedule send channel:#announcements time:30m message:Server maintenance starting now! - Check pending:
/schedule list - Cancel if needed:
/schedule cancel(autocomplete shows pending messages) - Maximum 25 scheduled messages per guild
Cron: Schedule services/scheduled-messages.cron.ts to run every 1-5 minutes to deliver due messages.
Roll dice using standard TTRPG notation. Open to all users.
Usage: /r dice:2d20+5
- Supports standard notation:
XdN,XdN+M,XdN-M - 1–20 dice per roll, d2–d100
- Shows individual rolls and total with modifier breakdown
Examples:
/r dice:1d20→ single d20 result/r dice:4d6→ roll 4d6, shows each roll + total/r dice:2d20+5→ roll 2d20, add 5 to total/r dice:1d2→ coin flip (1 or 2)
Self-assignable role panels with button-based toggling.
Setup:
- Add roles:
/react-roles add role:@Gamer emoji:🎮 label:Gamer - Repeat for up to 25 roles
- Send the panel:
/react-roles send channel:#roles - Users click buttons to self-assign/remove roles
Subcommands:
add <role> <emoji> <label>— Add a role to the panelremove <role>— Remove a role from the panellist— Show current configurationsend <channel>— Post or update the role panelclear— Delete all configuration
The panel is a non-ephemeral message with an embed and buttons. Clicking a button toggles the role (add if missing, remove if present). Re-running send updates the existing panel message instead of creating a duplicate.
A minimal KV store wrapping Val Town SQLite is available:
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:");
Send a GET request to the interactions endpoint to check if the bot is running:
GET /interactions -> { "status": "ok", "timestamp": "..." }
├── discord/
│ ├── constants.ts # Centralized CONFIG, env validation, embed colors
│ ├── discord-api.ts # Discord Bot API fetch helper
│ ├── persistence/
│ │ └── kv.ts # Key-value store (Val Town SQLite)
│ ├── helpers/
│ │ ├── duration.ts # Human-readable duration parser
│ │ └── embed-builder.ts # Fluent embed builder
│ ├── interactions/
│ │ ├── auto-discover.ts # File discovery helper
│ │ ├── define-command.ts # defineCommand() helper and types
│ │ ├── define-component.ts # defineComponent() helper and types
│ │ ├── errors.ts # UserFacingError class
│ │ ├── handler.ts # Main interaction handler
│ │ ├── patterns.ts # Discord API constants & autocomplete
│ │ ├── registration.ts # Command registration logic
│ │ ├── registry.ts # Unified command & component registry
│ │ ├── commands/
│ │ │ ├── about.ts # Bot info command
│ │ │ ├── coin-flip.ts # Coin flip command
│ │ │ ├── color-picker.ts # Select menu demo
│ │ │ ├── commands.ts # Admin: manage registration
│ │ │ ├── counter.ts # KV persistence demo
│ │ │ ├── echo.ts # Echo command
│ │ │ ├── facts.ts # Pagination demo
│ │ │ ├── feedback.ts # Modal demo
│ │ │ ├── giveaway.ts # Giveaway system (admin)
│ │ │ ├── help.ts # List available commands
│ │ │ ├── poll.ts # Poll system (admin)
│ │ │ ├── pick.ts # Random picker
│ │ │ ├── ping.ts # Health check command
│ │ │ ├── r.ts # Dice roller
│ │ │ ├── react-roles.ts # React-roles panel admin command
│ │ │ ├── remind.ts # Personal reminders
│ │ │ ├── schedule.ts # Scheduled messages (admin)
│ │ │ ├── slow-echo.ts # Deferred command example
│ │ │ ├── tag.ts # Custom text tags
│ │ │ └── user-info.ts # Context menu demo
│ │ └── components/
│ │ ├── color-select.ts # Select menu handler
│ │ ├── example-button.ts # Button handler
│ │ ├── facts-page.ts # Pagination handler
│ │ ├── feedback-modal.ts # Modal handler
│ │ ├── giveaway-enter.ts # Giveaway entry button handler
│ │ ├── poll-vote.ts # Poll vote button handler
│ │ └── react-role.ts # React-role toggle handler
│ └── webhook/
│ ├── send.ts # Webhook message sending
│ └── logger.ts # Discord webhook logger
└── services/
├── interactions.http.ts # HTTP endpoint for Discord interactions
├── example.cron.ts # Cron job with webhook example
├── 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