- README, license section and architecture no longer point to the dashboard repositories - schema.sql is the schema for contributors; database changes only need schema.sql and a note in the PR - self-hosting: settings without a dashboard (SQL for security thresholds, beta settings, opted-in servers) - beta features, deployment and troubleshooting reworded for people who don't have the dashboard Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
299 lines
13 KiB
Markdown
299 lines
13 KiB
Markdown
# Development guide
|
|
|
|
How to change the bot and how to add to it. Read [Architecture](architecture.md) first if you have not: it explains the layers this page keeps referring to.
|
|
|
|
- [Daily workflow](#daily-workflow)
|
|
- [Add a simple command](#add-a-simple-command)
|
|
- [Add a subcommand](#add-a-subcommand)
|
|
- [Command options and permissions](#command-options-and-permissions)
|
|
- [Buttons, select menus and forms](#buttons-select-menus-and-forms)
|
|
- [Add a feature that stores settings](#add-a-feature-that-stores-settings)
|
|
- [React to a gateway event](#react-to-a-gateway-event)
|
|
- [Add a background job](#add-a-background-job)
|
|
- [Add an environment variable](#add-an-environment-variable)
|
|
- [Make a command beta](#make-a-command-beta)
|
|
- [Testing](#testing)
|
|
- [Style and safety rules](#style-and-safety-rules)
|
|
- [Checklist before a pull request](#checklist-before-a-pull-request)
|
|
|
|
## Daily workflow
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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](commands.md).
|
|
|
|
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:
|
|
|
|
```go
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
|
Name: "lobbies",
|
|
Description: "List this server's meeting lobbies",
|
|
},
|
|
```
|
|
|
|
and a case to the handler's switch:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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](database.md#changing-the-schema)).
|
|
|
|
**2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values:
|
|
|
|
```go
|
|
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):
|
|
|
|
```go
|
|
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`:
|
|
|
|
```go
|
|
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. 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:
|
|
|
|
```go
|
|
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) { ... }
|
|
```
|
|
|
|
2. Register it in `bot.Start()` (`internal/bot/bot.go`):
|
|
|
|
```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.
|
|
|
|
```go
|
|
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](getting-started.md#2-configure-the-environment).
|
|
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](beta-features.md).
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
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` |
|
|
|
|
```bash
|
|
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](commands.md) and [Features](features.md) updated
|
|
- [ ] No secrets, tokens or personal IDs in code, logs or tests
|