Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

strut

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.

What it gives you

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 nil

Mistakes 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.

Tags

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.

Validation

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"`
}

Examples

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.

Rules worth knowing

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.

About

a poise like command framework for disgo

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages