Collectors
Overview
Collectors are bounded waits for one matching event. Bot.AwaitInteraction supports buttons, selects, and modals; Bot.AwaitMessage supports a message workflow. Both accept a context.Context, return the first match, and remove their registration when the result arrives or the context ends.
Tutorial: Wait For One Button
- Send the prompt and capture the initiating user's ID.
- Create a timeout context.
- Filter by component kind, custom ID, and actor.
- Acknowledge the returned interaction with
Update,Reply, or a defer. - Handle cancellation without logging expected timeouts as failures.
A collector is a workflow convenience, not a security boundary. Recheck authorization after a match and before changing application state.
Complete Runnable Example
Copy to examples/collectors/main.go, set DISCORD_TOKEN, and run it. Invoke /choose, click Next as the initiating user, or wait for the 30-second timeout.
package main
import (
"context"
"errors"
"log"
"os"
"time"
"github.com/discord-go/discord.go/bot"
"github.com/discord-go/discord.go/components"
"github.com/discord-go/discord.go/intents"
"github.com/discord-go/discord.go/interactions"
"github.com/discord-go/discord.go/snowflake"
)
func actorID(ctx *bot.InteractionContext) snowflake.ID {
if ctx.User != nil {
return ctx.User.ID
}
if ctx.Member != nil && ctx.Member.User != nil {
return ctx.Member.User.ID
}
return 0
}
func main() {
token := os.Getenv("DISCORD_TOKEN")
if token == "" {
log.Fatal("DISCORD_TOKEN is required")
}
router := bot.NewRouter()
router.Command("choose", "Wait for a private button choice", func(ctx *bot.InteractionContext) {
owner := actorID(ctx)
if owner == 0 {
_ = ctx.ReplyEphemeral("Could not identify the command user.")
return
}
button := components.NewButtonBuilder().
SetCustomID("collector:next").
SetLabel("Next").
SetStyle(components.ButtonStylePrimary).
Build()
row := components.NewActionRowBuilder().AddComponents(button).Build()
if err := ctx.ReplyComplex(&interactions.InteractionCallbackData{
Content: "Only the initiating user can click this within 30 seconds.",
Components: []components.Component{row},
}); err != nil {
log.Printf("collector prompt: %v", err)
return
}
waitCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
go func() {
defer cancel()
selected, err := ctx.Bot.AwaitInteraction(waitCtx, func(candidate *bot.InteractionContext) bool {
return candidate.IsButton() && candidate.CustomID() == "collector:next" && actorID(candidate) == owner
})
if err != nil {
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
log.Printf("collector: %v", err)
}
return
}
if err := selected.UpdateContent("Choice received; this collector is closed."); err != nil {
log.Printf("collector update: %v", err)
}
}()
})
b := bot.New(token, bot.WithIntents(intents.Guilds), bot.WithRouter(router))
if err := b.Run(); err != nil {
log.Fatal(err)
}
}Collector Design
Use a filter that is cheap and specific: check event kind, custom ID, actor, guild, channel, and message where available. Use context.WithCancel when a workflow completes through another path, and cancel all workflow contexts on shutdown. A process-wide unbounded collector is a memory and authorization risk.
AwaitMessage follows the same shape:
message, err := b.AwaitMessage(waitCtx, func(candidate *bot.MessageContext) bool {
return candidate.GuildID == guildID && candidate.Author != nil && candidate.Author.ID == owner
})Common Mistakes
- Passing
context.Background()to a user-facing collector. - Matching only a custom ID shared by several messages.
- Assuming the first matching event is authorized.
- Forgetting to acknowledge the collected component.
- Starting a goroutine without a timeout or shutdown context.
Expected Result
/choose starts one scoped collector. Only the initiating user's matching button completes it; cancellation or timeout removes the collector.