Docs: wiki-style documentation in docs/, README as the front page
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
1ee6280d91
commit
fefa74e045
10 files changed
+1137
-130
No files matched your search
@@ -0,0 +1,298 @@
|
||||
# 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`, and to an API migration if the dashboard will show it (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 the dashboard 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 and on the dashboard instead. 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 the dashboard**: 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: in `schema.sql` **and** in an API migration, with a note about deploy order
|
||||
- [ ] 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
|
||||
Reference in new issue
Block a user