Skip to content

Building a Bot On This

Josh Dionne edited this page Sep 4, 2026 · 3 revisions

Generated from the thetowersdk repository. Edits made here are overwritten on the next push — change the source file instead.

Building a Bot On This

A Tower bot answers the same few hundred questions from data that does not change between releases, so most of it is already written:

import { calculatorCommands, createTowerBot } from 'thetowersdk/bot'

const bot = createTowerBot({ commands: calculatorCommands() })
const reply = await bot.run('lab-research', { args: { labName: 'Damage', targetLevel: 10 } })

calculatorCommands() turns every builder into a command — options, coercion, formatting and notes — because each builder already describes its own inputs. A builder added to the SDK later appears in your bot with no edit. The registry handles dispatch, argument coercion, memoising and errors; bot.run never throws, so a gateway handler cannot leave someone watching a spinner until the interaction expires.

Nothing in it knows what a gateway is, so the same commands run under discord.js, on a CLI, and at full speed in a test.

npm run bot:demo                                            # list the commands
npm run bot:demo -- lab-research labName=Damage targetLevel=10
npm run bot:test

templates/bot-starter.ts is a complete bot with a hand-written command and a CLI. docs/BUILDING_A_BOT.md covers performance and the Discord adapter.

Rules that came out of running three of them

Three Discord bots are built on this package and run in production. The rules below came out of getting that wrong first, and almost none of them are Discord-specific — they apply to Slack, Telegram, Matrix, or anything else built around callbacks with opaque ids.

Treat the bot as a first-class client, not a bolt-on. Domain logic — parsing, normalization, cost math, run shapes — belongs in a shared package that both the bot and your UI import. Only platform glue (embeds, components, modals, collectors) lives in the bot repo. The failure this prevents is subtle and expensive: a bot that reimplements a calculation drifts from the website, and now the same question has two answers depending on where a player asks it. That is the whole reason this SDK is framework-agnostic.

One router. Every interaction dispatches through a single entry point, with persistent handlers registered against it. Scattered per-feature listeners are how you get two handlers responding to one click, and the second one erroring because the first already replied.

Component ids get exactly one owner. Build and parse them in one module, and let handlers consume parsed values only. A custom id is a wire format — it is serialized, handed to a remote client, and handed back later, possibly after your process restarted. Once a handler does split(':') inline, the format is defined in as many places as it is read, and adding a field breaks callers nobody remembered. Registry lookup should use exact or longest-prefix matching for the same reason, never slice(prefix.length) at each call site.

Interactions are owned. Wait on a modal by filtering on the component id and the initiating user. Without both, one user's click resolves another user's pending wait — a bug that never appears in single-user testing and appears constantly in a busy channel.

Guard tokens before touching session state. Anything carrying a session token must check it is present and unexpired first. Callbacks arrive late, arrive twice, and arrive after a redeploy; treating an expired token as a valid one corrupts state rather than erroring.

Support every component kind from the start. Buttons, all select-menu variants, and modals — even when the current feature only uses one. Retrofitting a router that assumed buttons is worse than writing it general, and it is a small amount of extra work up front.

Stay quiet when the interaction is not yours. An unregistered submission that belongs to a command-local ownership flow should return silently, not log an error or reply. Otherwise normal operation fills your logs with noise and hides the real failures.

Keep the probe scripts. Diagnostic tooling — "what does this record actually look like in the database" — belongs in a checked-in scripts/ directory, not in a throwaway file. You will need it again, and next time it will be during an incident.


Clone this wiki locally