A struct oriented command framework for disgo, in the spirit of poise. Requires Go 1.27.
A command is a struct. Its exported fields are the options.
type Ban struct {
User discord.ResolvedMember `strut:"user" desc:"Member to ban"`
Reason strut.Option[string] `strut:"reason,max=512" desc:"Why"`
Days strut.Option[int] `strut:"days,min=0,max=7" desc:"Days of messages"`
}
func (*Ban) Meta() strut.Meta {
return strut.Meta{
Name: "ban", Description: "Ban a member",
GuildOnly: true, UserPerms: discord.PermissionBanMembers,
}
}
func (c *Ban) Cooldown() strut.CooldownConfig {
return strut.CooldownConfig{User: 10 * time.Second}
}
func (c *Ban) Run(e *strut.Event[Data]) error {
return e.Sayf("Banned %s: %s", c.User.User.Username, c.Reason.LoadOr("no reason"))
}f := strut.New(client, strut.Options[Data]{Data: state})
f.Add(&Ban{}).MustValidate()
client.AddEventListeners(f)
f.SyncGuild(ctx, guildID)A command implements Meta and Run. Subcommands implement only Run.
Everything else is optional and found once, when the command is added:
Allower to gate it, Cooldowner to rate limit it, ErrorHandler,
Localizer, Helper and Parent. Modals take a Modaler for their title,
and a custom option type an Argument.
One struct, every surface. The same type can serve a slash command, a text command and both right click menus. Targets are filled from the interaction and never registered as options.
func (*Report) Meta() strut.Meta {
return strut.Meta{
Name: "report", Description: "Report something",
Kinds: strut.Slash | strut.Prefix | strut.UserMenu | strut.MessageMenu,
ContextMenuName: "Report",
}
}Component state that survives a restart. State is encoded into the
custom_id from struct tags and decoded back on click. The route comes from
the type name, so a button posted before a deploy still works after it.
type PageBtn struct {
Page int `strut:"p"`
Owner snowflake.ID `strut:"u"`
}
e.Reply(strut.Content("page 1").Row(
strut.Button(PageBtn{Page: 0, Owner: uid}, "<", strut.Secondary).WithDisabled(true),
strut.Button(PageBtn{Page: 2, Owner: uid}, ">", strut.Primary),
))
f.OnComponent(func(e *strut.Event[Data], s PageBtn) error {
if e.Author().ID != s.Owner {
return strut.ErrNotYours
}
return e.Edit(render(s.Page))
})Modals are structs too. Fields in, fields out. The type declares its own presentation, so call sites do not repeat it.
type Appeal struct {
Reason string `strut:"reason,paragraph,min=10,max=1000" label:"Why should we unban you?"`
Contact strut.Option[string] `strut:"contact" label:"Contact" placeholder:"email"`
}
func (Appeal) Modal() strut.ModalSpec {
return strut.ModalSpec{Title: "Ban appeal"}
}
appeal, submit, err := strut.ShowModal[Appeal](e)
if err != nil {
return err
}
return submit.Sayf("Logged: %s", appeal.Reason)ShowModal blocks the command until the user submits or the modal times out.
It does not block the bot: strut runs handlers off disgo's dispatch loop, so
other interactions keep arriving meanwhile.
submit is bound to the submission, and is the event that must be answered.
A field with a Choices method becomes a select menu instead of a text
input. SendModal with f.OnModal is the non-blocking form, which also
survives a restart where a parked goroutine does not.
Commands you can test. No gateway, no token, the same pipeline a live interaction takes.
h := struttest.New(f).As(struttest.Invoker{User: mod, GuildID: &guild})
rec := h.Command(t, "/ban", struttest.Args{"user": uid, "reason": "spam"})
rec.Content() // what the command replied
rec.Err // *strut.Error[D], or nilMistakes caught at start up. Structs are read once, when a command is
added. Anything Discord would reject is reported by Validate before the bot
connects, not on first use.
Scalar settings share one strut key. Free text gets its own keys, because
values may contain commas.
Days int `strut:"days,min=0,max=7" desc:"Days to delete" desc.de:"Zu löschende Tage"`| Setting | Applies to |
|---|---|
min, max |
numbers (value), strings and modal fields (length) |
channels=text|forum |
channel options |
autocomplete |
string, integer, number |
paragraph |
modal fields |
rest, lazy, flag |
prefix commands |
- |
skip the field entirely |
Separate keys: desc, label, placeholder, name.<locale>, desc.<locale>.
Option types are string, bool, every int and float width, discord.User,
ResolvedMember, ResolvedChannel, Role, Attachment, MentionableValue,
snowflake.ID, time.Duration, and anything implementing Argument.
The strut tag carries what Discord enforces. Those constraints are sent with
the command, and re-checked on receipt, because the interaction payload is
user controlled.
Anything Discord cannot express, such as an email or a URL, is yours.
Options.Validate runs after decoding and before the command, and takes any
function, so it pairs with
go-playground/validator without
strut depending on it.
v := validator.New()
strut.Options[Data]{
Validate: func(cmd any) error { return v.Struct(cmd) },
}type Signup struct {
Email string `strut:"email" desc:"Your email" validate:"required,email"`
}Six complete bots in _examples: choice lists and autocomplete, right click menus, component state, prefix commands, and testing. The testing one needs no token.
strut ships no commands of its own, not even help. Framework.Commands and
Framework.Command describe everything registered, down to each option's
name, type, requiredness and choices, which is what a help command reads.
Everything else is in the package documentation:
subcommands, layout components, middleware, hooks, autocomplete,
localization, and the Store behind cooldowns and edit tracking.
Things you would otherwise meet as an error.
Optional options come last. Option[T] marks an option optional, and
Discord requires those after every required one. strut enforces it when the
command is added.
A subcommand's Meta can only tighten. It may add a permission or make
itself ephemeral, never drop what the root requires. Its name and surfaces
still come from the root and the field tag.
Prefix commands are off unless Options.Prefix is set, so the default
needs no message content intent, and interactions work the same whether the
bot runs on a gateway or over HTTP. Entity options have no text form and are
rejected at registration; use snowflake.ID or strut.Mention, which take an
id or any mention form.
Slices are prefix only. They take every remaining argument. Discord has no repeatable option, so declaring one on a slash command is rejected.
Layout components replace content and embeds. Using Text, Section or
Container sets the components v2 flag, and Discord does not allow both.
A dismissed modal looks like an abandoned one. Discord never reports a
dismissal, so a blocking ShowModal ends in ErrModalTimeout either way.
Add is not safe once the client is receiving events. Register everything
before OpenGateway.
Handlers run concurrently. Each interaction gets its own goroutine, so a slow command does not hold up the rest. Anything a command shares must be safe for that.