Files
velox-bot/docs/development.md
FernandoJVideiraandClaude Sonnet 5.5 30cbd9a798 Help and docs for the new commands
- /help is sent as a few messages (Discord caps an embed at 6000 characters), with a test for the limits
- command reference, features, self-hosting, beta and getting-started pages describe the commands; the SQL section is gone
- development and contributing: every setting needs a command

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:11:44 +01:00

14 KiB

Development guide

How to change the bot and how to add to it. Read Architecture first if you have not: it explains the layers this page keeps referring to.

Daily workflow

docker compose up -d db        # PostgreSQL
air                            # rebuild and restart on every change

Set GUILD_ID to a test server in .env, so commands update within seconds. Before pushing:

gofmt -l .
go build ./... && go vet ./... && go test ./...

Some older files are not gofmt-clean. Format the files you touch, and leave the others alone so reviews stay readable.

Add a simple command

A command is a definition (what Discord shows) plus a handler (what runs). Each feature has a package under internal/commands/<feature>, with this shape:

internal/commands/fun/
  registry.go         re-exports the commands and handlers of the feature
  public/             the definitions and handlers
    ping.go
  shared/             helpers used by several commands of the feature

Example: a /hello command in the fun feature.

1. The definition and handler, in internal/commands/fun/public/hello.go:

package public

import "github.com/bwmarrin/discordgo"

var Hello = &discordgo.ApplicationCommand{
	Name:        "hello",
	Description: "Say hello",
}

func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
	_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
		Type: discordgo.InteractionResponseChannelMessageWithSource,
		Data: &discordgo.InteractionResponseData{Content: "Hello!"},
	})
}

2. Re-export it, in internal/commands/fun/registry.go:

var Hello *discordgo.ApplicationCommand = public.Hello

func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HelloHandler(s, i) }

3. Register it, in internal/commands/commands.go: add fun.Hello to AllCommands, and "hello": fun.HelloHandler to the handlers map. The key is the command's name. A command that is in AllCommands but not in handlers is shown in Discord and then does nothing.

4. Restart the bot. It re-registers all commands on start.

5. Document it: add it to /help (internal/commands/help/help.go) and to the Command reference.

Names must be lowercase, up to 32 characters, and unique. Descriptions are required and limited to 100 characters.

For an entirely new feature, create a new package with the same shape, and import it in commands.go.

Add a subcommand

A command with subcommands (like /meeting) lists them in Options, and its handler switches on the name of the first option. Add the subcommand to the definition:

{
	Type:        discordgo.ApplicationCommandOptionSubCommand,
	Name:        "lobbies",
	Description: "List this server's meeting lobbies",
},

and a case to the handler's switch:

switch data.Options[0].Name {
case "lobbies":
	handleListLobbies(s, i)
// ...
default:
	shared.RespondEphemeral(s, i, "Unknown subcommand.")
}

Keep one handleX function per subcommand. For a group (/tickets category add), the first option is the group and its first option is the subcommand.

Command options and permissions

Options go in the definition's Options (or in a subcommand's). Types are String, Integer, Boolean, User, Channel, Role, Number, and others. A channel option can be limited with ChannelTypes. A string can offer fixed Choices.

Inside a subcommand, shared.SubcommandOptionMap(i) (in the meeting, level and other shared packages) turns the options into a map by name:

opts := shared.SubcommandOptionMap(i)
channel := opts["channel"].ChannelValue(s) // *discordgo.Channel

Where a command may be used. Contexts lists where Discord offers it (InteractionContextGuild, InteractionContextBotDM, InteractionContextPrivateChannel). A server-only command should also refuse politely if i.GuildID == "": the shared packages have RequireGuild for this.

Who may use it. There are two layers, and you need both:

  1. DefaultMemberPermissions on the definition only decides who sees the command in Discord's menu. Server admins can change it in their settings, so it is not security.
  2. The handler must check again, using i.Member.Permissions. The shared packages have helpers such as RequireManageGuild. Moderation uses hasPerm(i, discordgo.PermissionKickMembers) and so on.

For anything where a mistake hurts (security settings, anything destructive), check the owner or trusted list as well, as internal/commands/security does with allowedOrTrusted. Do not rely on Discord's permissions alone.

Responses.

  • Answer within three seconds. If a command takes longer, respond with InteractionResponseDeferredChannelMessageWithSource first, then edit the response with InteractionResponseEdit.
  • Use Flags: discordgo.MessageFlagsEphemeral for replies only the user should see (errors, settings). shared.RespondEphemeral does this.
  • When the message contains user-controlled text, set AllowedMentions: &discordgo.MessageAllowedMentions{} so it cannot ping anyone.

Buttons, select menus and forms

Components and modals carry a custom ID, and the dispatcher routes them by prefix (internal/commands/commands.go, HandleInteraction). To add one to a feature:

  1. Use the feature's prefix when you build the component: CustomID: "projects:close:" + id. Put whatever you need in the ID after the prefix, split with :.
  2. The dispatcher already sends music:, schedule:, projects:, tickets: and security: to that feature's HandleComponent, and schedule:, projects:, tickets: modals to its HandleModalSubmit.
  3. In HandleComponent, parse the rest of the ID, check permissions again (the button may be clicked by anyone who can see the message), and respond.

For a new prefix, add a case to the switch in HandleInteraction, and write HandleComponent and/or HandleModalSubmit in your feature.

Custom IDs are limited to 100 characters, and a modal can have up to five inputs.

Never trust the contents of a custom ID. A user cannot edit it, but old messages stay around with old IDs, and IDs may refer to things that no longer exist.

Add a feature that stores settings

The usual path is table, repo, service, command. The meeting-room lobbies are a complete small example (/meeting add-lobby).

1. The table. Add it to schema.sql (see Database).

2. The repo, in internal/db/repos/<area>repo/repo.go. SQL only, returning plain Go values:

func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at`, guildID)
	// ...
}

Always pass values as parameters ($1), never build SQL with string formatting.

3. The service, in internal/db/services/<area>/service.go. It wraps the repo and holds the rules (limits, defaults, validation):

type Service struct{ settings *settingsrepo.Repo }

func New(settings *settingsrepo.Repo) *Service { return &Service{settings: settings} }

4. Wire it up. Add a field to services.Services and the constructor in internal/db/services/services.go, and build the repo and service in main.go next to the others.

5. Use it from the command, through services.Global:

if services.Global == nil || services.Global.Meeting == nil {
	shared.RespondEphemeral(s, i, "Meeting service is not available.")
	return
}
added, err := services.Global.Meeting.AddLobby(ctx, guildID, channelID)

6. Enforce limits in the database when you can. The 10-lobby limit is a trigger, so the bot and anything else writing to the table cannot disagree and two simultaneous requests cannot both slip through.

7. Give it commands. Every setting must be visible and changeable from Discord (a slash command to set it, and a way to see it). The bot has to work completely without a dashboard, so never add a setting that only a dashboard or SQL can change. Put the options in the command definition, and internal/commands/limits_test.go will tell you if it goes over Discord's limits.

8. Default to off. A module that does something to a server without being asked (posts messages, moderates, grants roles) must start disabled and be turned on by an admin.

React to a gateway event

  1. Write the function in internal/events/, taking the session, the event and the services:

    func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) { ... }
    
  2. Register it in bot.Start() (internal/bot/bot.go):

    b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
        events.HandleChannelDelete(s, c, b.Services)
    })
    
  3. Check that the intent for that event is enabled in NewBot. Events from an intent you did not ask for never arrive. Privileged intents must also be enabled in the developer portal.

Handlers run concurrently on goroutines. Do not share unsynchronised state, and do not block: slow work belongs in its own goroutine.

If the event is something security cares about (joins, messages, audit log), look at how bot.go calls the security engine before your handler.

Add a background job

Follow StartScheduleReminderLoop in internal/events/schedule_reminders.go: a function that returns immediately and starts a goroutine with a ticker.

func StartMyLoop(s *discordgo.Session, svc *services.Services) {
	if svc == nil || svc.Thing == nil || s == nil {
		return
	}
	go func() {
		time.Sleep(10 * time.Second) // let the bot finish starting
		ticker := time.NewTicker(time.Minute)
		defer ticker.Stop()
		for range ticker.C {
			// check, act, log errors, never panic
		}
	}()
}

Call it at the end of bot.Start(). Log errors instead of returning them, and make one failing server not stop the loop for the others.

Add an environment variable

  1. Add a field to Config and read it in LoadConfig() (internal/config/config.go). Return an error if the bot cannot work without it.
  2. Document it in Getting started.
  3. Pass it where it is needed from main.go.

Never log a secret, and never put one in a message to a user.

Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database instead (set with a command, or by a dashboard). That is how the beta settings work.

Make a command beta

Register it with commands.RegisterBeta instead of adding it to AllCommands and handlers. See Beta features.

Testing

go test ./...                       # unit tests, no database needed
go test -race ./...                 # recommended before a pull request

Where the code allows it, the Discord and database calls go through small interfaces, so flows can be tested with fakes. internal/tickets and internal/security are the models: look at their fakes_test.go.

Tests that need a real PostgreSQL are behind the integration build tag and skip themselves when their environment variable is missing:

Package Variable
internal/db/repos/settingsrepo TEST_DB
internal/db/repos/ticketrepo, internal/tickets TICKETS_TEST_DB
internal/db/repos/securityrepo SECURITY_TEST_DB
TEST_DB="postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" \
  go test -tags integration ./internal/db/repos/settingsrepo

Use a throwaway database for these, not one with real data. The tables must already exist (load schema.sql).

Good things to test: permission checks (does the wrong person get refused?), limits, anything that parses user input, and "what if the service or the database fails".

Style and safety rules

  • One feature per package, SQL only in repos, rules in services. Commands never contain SQL.
  • Match the code around you: naming, comment density, error handling. Comments explain why, not what.
  • Fail closed. If you cannot tell whether someone is allowed, they are not.
  • Validate everything that comes from a user or from another program writing to the database: option values, custom IDs, payload JSON in command rows.
  • Escape user text in messages, and set AllowedMentions so it cannot ping.
  • Bound everything per server: caches, lists, loops. One server must not be able to use unlimited memory.
  • Do not leak internals to users: reply with a short message, log the detail.
  • Idempotent writes: use ON CONFLICT for "add" operations, so double clicks and retries are harmless.
  • Handle the bot being restarted at any time. State that must survive (raid mode, quarantines) lives in the database.

Checklist before a pull request

  • gofmt on the files you touched, go build ./..., go vet ./..., go test -race ./...
  • Commands: in AllCommands and handlers, permission check in the handler, ephemeral errors
  • New table or column: in schema.sql, and mentioned in the pull request
  • Module that acts on a server: off by default
  • /help, Command reference and Features updated
  • No secrets, tokens or personal IDs in code, logs or tests