A clean, general-purpose Discord bot template built for Val Town. Provides slash command handling, webhook messaging, and structured logging out of the box.
defineCommand(), auto-registered to DiscorddefineComponent()/commands register and /commands unregister for managing bot commands via Discord{ status: "ok" } for monitoringinteractions.http.ts val)/commands register in 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 |
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}>!` };
},
});
/commands register in 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: [...] };
},
});
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 namesExample:
/tag add name:rules content:Please read #rules before posting./tag view name:rulesAdmin-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 entrantsExample:
/giveaway create prize:Nitro duration:1d channel:#giveaways winners:2/giveaway rerollCron: 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
s (seconds), m (minutes), h (hours), d (days), and combinations like 1d12hCron: 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 resultsExample:
/poll create question:Fav color? options:Red,Blue,Green channel:#general duration:1h/poll end or let the cron auto-end itduration for a poll with no time limitCron: 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 send channel:#announcements time:30m message:Server maintenance starting now!/schedule list/schedule cancel (autocomplete shows pending messages)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
XdN, XdN+M, XdN-MExamples:
/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:
/react-roles add role:@Gamer emoji:🎮 label:Gamer/react-roles send channel:#rolesSubcommands:
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 configurationThe 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