strut

package module
v0.0.0-...-c28910b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 22 Imported by: 0

README

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.

Documentation

Overview

Package strut builds Discord commands from Go structs, in the spirit of poise.

A command is a struct whose exported fields are its options, plus a Meta method describing it and a Run method executing it:

type Ban struct {
	User   discord.ResolvedMember `strut:"user" desc:"Member to ban"`
	Reason strut.Option[string]   `strut:"reason" desc:"Why"`
}

func (*Ban) Meta() strut.Meta {
	return strut.Meta{Name: "ban", Description: "Ban a member"}
}

func (c *Ban) Run(e *strut.Event[Data]) error {
	return e.Sayf("banned %s", 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)

Structs are analysed once, when a command is added. Everything Discord would reject is reported by Validate at start up rather than on first use.

Option[T] marks an option optional, keeping absent distinct from zero. Meta carries a command's data; behaviour goes on the optional interfaces Allower, Cooldowner, ErrorHandler, Localizer, Helper and Parent, each detected once at registration.

One struct can serve several surfaces at once through Meta.Kinds: slash commands, both context menus, and prefix commands when Options.Prefix is set. Components and modals are structs too, with their state encoded into the custom_id.

Commands can be exercised without a gateway; see Invoke, InvokeText, InvokeComponent, InvokeModal and InvokeAutocomplete.

See the README and _examples for a fuller tour.

Index

Constants

Variables

View Source
var ErrNotYours = errors.New("strut: this component is not yours")

ErrNotYours is a convenience check failure for components clicked by someone other than the user they were created for.

Functions

func DefaultErrorHandler

func DefaultErrorHandler[D any](err *Error[D])

DefaultErrorHandler replies to the user for failures they caused and logs the rest.

func SendModal

func SendModal[M, D any](e *Event[D], prefill M, override ...ModalSpec) error

SendModal displays a modal and returns immediately. The submission goes to the handler registered with OnModal, which survives a restart in a way a parked goroutine does not.

Types

type Allower

type Allower[D any] interface {
	Allow(*Event[D]) (bool, error)
}

Allower gates a command in addition to Meta.Checks.

type Argument

type Argument interface {
	OptionType() discord.ApplicationCommandOptionType
	FromInteraction(data discord.SlashCommandInteractionData, name string) error
}

Argument makes a type usable as a command option. Declare both methods on the pointer receiver; the field itself stays the value type.

type ArgumentError

type ArgumentError struct {
	Field string
	Input string
	Err   error
}

ArgumentError is a single option that could not be supplied. It is wrapped in an Error with kind ErrArgumentParse before reaching a handler.

func (*ArgumentError) Error

func (e *ArgumentError) Error() string

func (*ArgumentError) Unwrap

func (e *ArgumentError) Unwrap() error

type Check

type Check[D any] func(*Event[D]) (bool, error)

Check reports whether a command may run. Returning false without an error rejects silently; returning an error reports it through OnError.

type Choice

type Choice[T any] struct {
	Name          string
	Value         T
	NameLocalized map[discord.Locale]string
}

Choice is one entry of a static choice list.

type Command

type Command[D any] interface {
	Runner[D]
	Meta() Meta
}

Command is a top-level command. Subcommands only implement Runner: their name and description come from the parent's field tag, so nesting is described where it is declared.

type CommandInfo

type CommandInfo struct {
	Meta Meta
	// Options are the command's own options, empty when it holds
	// subcommands instead.
	Options []OptionInfo
	// Subcommands is the tree below this command, one level per nesting.
	Subcommands []SubcommandInfo
}

CommandInfo describes one registered command, for a help command or any other listing. It is derived from the payload sent to Discord, so it says what a user will actually see.

type Component

type Component struct {
	// contains filtered or unexported fields
}

Component is one built component. A build error travels with it and surfaces when the reply is sent, so a builder chain stays readable.

Everything is placed with Row. Discord decides what may share one: up to five buttons, or a single select menu.

func Button

func Button[S any](state S, label string, style Style) Component

Button carries state back to the handler registered with OnComponent.

func ChannelSelect

func ChannelSelect[S any](state S, placeholder string) Component

ChannelSelect picks channels.

func LinkButton

func LinkButton(label, url string) Component

LinkButton opens a URL. It carries no state, since Discord never sends an interaction for it.

func MentionableSelect

func MentionableSelect[S any](state S, placeholder string) Component

MentionableSelect picks users and roles together.

func RoleSelect

func RoleSelect[S any](state S, placeholder string) Component

RoleSelect picks roles.

func Select

func Select[S, V any](state S, placeholder string, choices ...Choice[V]) Component

Select offers a fixed list of values, handed to the handler registered with OnSelect.

func UserSelect

func UserSelect[S any](state S, placeholder string) Component

UserSelect picks users. The chosen ids reach the handler registered with OnEntitySelect.

func (Component) WithDisabled

func (b Component) WithDisabled(disabled bool) Component

WithDisabled greys the component out, which is how a menu is closed off once a choice has been made.

func (Component) WithEmoji

func (b Component) WithEmoji(emoji discord.ComponentEmoji) Component

WithEmoji puts an emoji on a button.

func (Component) WithRange

func (b Component) WithRange(min, max int) Component

WithRange lets a select menu take between min and max values. Without it a menu takes exactly one.

type Container

type Container struct {
	// contains filtered or unexported fields
}

Container groups components behind an accent colour.

func NewContainer

func NewContainer() Container

NewContainer starts a container.

func (Container) Row

func (c Container) Row(components ...Component) Container

Row adds a row of components inside the container.

func (Container) Separator

func (c Container) Separator() Container

Separator adds a divider inside the container.

func (Container) Text

func (c Container) Text(content string) Container

Text adds markdown inside the container.

func (Container) WithColor

func (c Container) WithColor(color int) Container

WithColor sets the accent colour.

type CooldownConfig

type CooldownConfig struct {
	Global  time.Duration
	User    time.Duration
	Guild   time.Duration
	Channel time.Duration
	Member  time.Duration
}

CooldownConfig is a set of cooldowns. Every non-zero duration is enforced, and the longest one still running wins.

type CooldownKey

type CooldownKey struct {
	Command string
	User    snowflake.ID
	Guild   snowflake.ID
	Channel snowflake.ID
}

CooldownKey identifies who ran a command and where.

type CooldownOpt

type CooldownOpt func(*CooldownTracker)

CooldownOpt configures a CooldownTracker.

func WithCooldownStore

func WithCooldownStore(s Store[bucket, time.Time]) CooldownOpt

WithCooldownStore keeps cooldowns somewhere other than this process, so they survive a restart and hold across shards.

type CooldownTracker

type CooldownTracker struct {
	// contains filtered or unexported fields
}

CooldownTracker records when each bucket was last used.

Entries expire with the cooldown that created them, so the tracker stays bounded without any sweeping of its own.

func NewCooldownTracker

func NewCooldownTracker(opts ...CooldownOpt) *CooldownTracker

NewCooldownTracker returns a tracker backed by an in-process store unless given another one.

func (*CooldownTracker) Remaining

func (t *CooldownTracker) Remaining(cfg CooldownConfig, key CooldownKey) (time.Duration, bool)

Remaining reports how long until the command may run again, and whether any bucket is still cooling down.

func (*CooldownTracker) Reset

func (t *CooldownTracker) Reset(cfg CooldownConfig, key CooldownKey)

Reset clears every bucket for one invocation's scopes.

func (*CooldownTracker) Use

func (t *CooldownTracker) Use(cfg CooldownConfig, key CooldownKey)

Use marks the command as just run, starting every configured bucket.

type Cooldowner

type Cooldowner interface {
	Cooldown() CooldownConfig
}

Cooldowner rate limits a command.

type EditTracker

type EditTracker struct {
	// contains filtered or unexported fields
}

EditTracker remembers which bot message answered which invocation, so an edited command can update its original reply instead of sending a new one.

func NewEditTracker

func NewEditTracker(cfg EditTrackerConfig) *EditTracker

NewEditTracker returns a tracker with the given configuration.

type EditTrackerConfig

type EditTrackerConfig struct {
	// TTL is how long an invocation stays editable. Zero means ten minutes.
	TTL time.Duration
	// ExecuteUntrackedEdits runs a command when an edit turns a message into
	// a valid invocation, even though the original was not one.
	ExecuteUntrackedEdits bool
	// IgnoreEditsIfNotResponded skips edits to invocations the bot never
	// answered.
	IgnoreEditsIfNotResponded bool
	// Store keeps the invocation to reply mapping. Nil uses an in-process
	// store, which is usually right: a guild always lands on one shard.
	Store Store[snowflake.ID, TrackedReply]
}

EditTrackerConfig tunes how message edits are followed.

type Error

type Error[D any] struct {
	Kind  ErrorKind
	Event *Event[D]
	Err   error

	Input string // ErrArgumentParse: the text that failed to parse
	Field string // ErrArgumentParse: the option it belongs to

	Remaining time.Duration       // ErrCooldown
	Missing   discord.Permissions // ErrMissingUserPerms, ErrMissingBotPerms
	Stack     []byte              // ErrCommandPanic
}

Error is every failure strut reports, from argument parsing through to a command returning an error.

func (*Error[D]) Error

func (e *Error[D]) Error() string

func (*Error[D]) Is

func (e *Error[D]) Is(target error) bool

Is matches against an ErrorKind, so callers can write errors.Is(err, strut.ErrCooldown).

func (*Error[D]) Unwrap

func (e *Error[D]) Unwrap() error

Unwrap exposes the underlying cause, if any.

type ErrorHandler

type ErrorHandler[D any] interface {
	OnError(*Error[D])
}

ErrorHandler overrides Options.OnError for one command.

type ErrorKind

type ErrorKind int

ErrorKind classifies why an invocation failed. Sentinel values are comparable with errors.Is against an *Error.

const (
	ErrArgumentParse ErrorKind = iota
	ErrValidation
	ErrCheckFailed
	ErrCooldown
	ErrMissingUserPerms
	ErrMissingBotPerms
	ErrNotOwner
	ErrGuildOnly
	ErrDMOnly
	ErrNSFWOnly
	ErrCommandPanic
	ErrCommandFailed
	ErrSetup
	ErrEventHandler
	ErrModalTimeout
	ErrNoInteraction
	ErrShuttingDown
	ErrSubcommandRequired
)

func (ErrorKind) Error

func (k ErrorKind) Error() string

func (ErrorKind) String

func (k ErrorKind) String() string

type Event

type Event[D any] struct {
	// contains filtered or unexported fields
}

Event carries one command invocation, abstracting over slash, context menu and prefix sources. Named Event, not Context, to leave context.Context the name callers expect.

func NewEvent

func NewEvent[D any](data D, meta Meta, kind Kind, src Source, res Responder, opts ...EventOpt[D]) *Event[D]

NewEvent builds an Event around the given seams. It exists for test harnesses and alternative front ends; a bot never calls it.

func ShowModal

func ShowModal[M, D any](e *Event[D], override ...ModalSpec) (M, *Event[D], error)

ShowModal displays a modal and waits for it to be submitted.

The returned Event is bound to the submission, not the original interaction, and is the one that must be responded to. A dismissed modal is indistinguishable from an abandoned one, so waiting ends in ErrModalTimeout either way.

func ShowModalWith

func ShowModalWith[M, D any](e *Event[D], prefill M, override ...ModalSpec) (M, *Event[D], error)

ShowModalWith displays a modal prefilled from an existing value.

func (*Event[D]) Author

func (e *Event[D]) Author() discord.User

Author returns the invoking user.

func (*Event[D]) ChannelID

func (e *Event[D]) ChannelID() snowflake.ID

ChannelID returns the channel the command was invoked in.

func (*Event[D]) Client

func (e *Event[D]) Client() *bot.Client

Client returns the underlying disgo client.

func (*Event[D]) Command

func (e *Event[D]) Command() Meta

Command returns the metadata of the command being run.

func (*Event[D]) Context

func (e *Event[D]) Context() context.Context

Context returns the context for this invocation.

func (*Event[D]) Data

func (e *Event[D]) Data() D

Data returns the application state passed to New.

func (*Event[D]) Defer

func (e *Event[D]) Defer() error

Defer buys more than Discord's three second response window. Prefix commands broadcast typing instead.

func (*Event[D]) DeferEphemeral

func (e *Event[D]) DeferEphemeral() error

DeferEphemeral defers with an ephemeral response.

func (*Event[D]) Edit

func (e *Event[D]) Edit(r Reply) error

Edit replaces the command's original response.

func (*Event[D]) Followup

func (e *Event[D]) Followup(r Reply) (*discord.Message, error)

Followup sends an additional message after the first response.

func (*Event[D]) GuildID

func (e *Event[D]) GuildID() *snowflake.ID

GuildID returns the guild, or nil in DMs.

func (*Event[D]) Interaction

func (e *Event[D]) Interaction() (*handler.CommandEvent, bool)

Interaction returns the underlying disgo command event. It is absent for prefix invocations, which have no interaction to respond to.

func (*Event[D]) Kind

func (e *Event[D]) Kind() Kind

Kind reports which surface this invocation arrived on.

func (*Event[D]) Locale

func (e *Event[D]) Locale() discord.Locale

Locale returns the invoker's locale. Empty for prefix commands.

func (*Event[D]) Logger

func (e *Event[D]) Logger() *slog.Logger

Logger returns the framework logger, tagged with the invocation, so a bot does not repeat those fields on every line.

func (*Event[D]) Member

func (e *Event[D]) Member() *discord.ResolvedMember

Member returns the invoking member, or nil outside a guild.

func (*Event[D]) Message

func (e *Event[D]) Message() (*events.MessageCreate, bool)

Message returns the underlying message event, set only for prefix invocations.

func (*Event[D]) Reply

func (e *Event[D]) Reply(r Reply) error

Reply responds to the invocation, following up automatically once the command has already replied or deferred.

func (*Event[D]) Say

func (e *Event[D]) Say(s string) error

Say sends a plain text reply.

func (*Event[D]) Sayf

func (e *Event[D]) Sayf(format string, a ...any) error

Sayf sends a formatted text reply.

type EventOpt

type EventOpt[D any] func(*Event[D])

EventOpt configures an Event built by NewEvent.

func WithContext

func WithContext[D any](ctx context.Context) EventOpt[D]

WithContext sets the invocation's context.

func WithFramework

func WithFramework[D any](f *Framework[D]) EventOpt[D]

WithFramework binds the event to a framework, so replies pick up its defaults and commands can reach it.

func WithModalOpener

func WithModalOpener[D any](o ModalOpener) EventOpt[D]

WithModalOpener lets the event open modals.

type FieldSpec

type FieldSpec struct {
	Label       string
	Placeholder string
	Value       string
}

FieldSpec overrides one modal input's presentation.

type Framework

type Framework[D any] struct {
	// contains filtered or unexported fields
}

Framework routes Discord interactions to commands. It implements bot.EventListener, so it is added to a client like any other listener.

func New

func New[D any](client *bot.Client, opts ...Options[D]) *Framework[D]

New returns a Framework wrapping client.

func (*Framework[D]) Add

func (f *Framework[D]) Add(cmds ...Command[D]) *Framework[D]

Add registers commands. Problems are collected rather than returned, so a chain of calls stays readable; Validate reports them.

Add is not safe to call once the client is receiving events. Register everything before OpenGateway.

func (*Framework[D]) Command

func (f *Framework[D]) Command(name string) (CommandInfo, bool)

Command returns one registered command by name.

func (*Framework[D]) Commands

func (f *Framework[D]) Commands() iter.Seq[CommandInfo]

Commands iterates every registered command.

func (*Framework[D]) Cooldowns

func (f *Framework[D]) Cooldowns() *CooldownTracker

Cooldowns exposes the tracker, for commands using Meta.ManualCooldown.

func (*Framework[D]) Data

func (f *Framework[D]) Data() D

Data returns the application state every command is given.

func (*Framework[D]) InitOwners

func (f *Framework[D]) InitOwners(ctx context.Context, opts ...rest.RequestOpt) error

func (*Framework[D]) MustValidate

func (f *Framework[D]) MustValidate() *Framework[D]

MustValidate panics if any command is invalid. Call it during start up: an invalid command is a programming error, not a runtime condition.

func (*Framework[D]) Mux

func (f *Framework[D]) Mux() *handler.Mux

Mux exposes the underlying router, so raw disgo handlers can be mixed in.

func (*Framework[D]) OnComponent

func (f *Framework[D]) OnComponent[S any](h func(*Event[D], S) error) *Framework[D]

OnComponent registers a handler for components carrying S. The route comes from S's type name, so it survives a restart.

func (*Framework[D]) OnEntitySelect

func (f *Framework[D]) OnEntitySelect[S any](h func(*Event[D], S, []snowflake.ID) error) *Framework[D]

OnEntitySelect registers a handler for a user, role, channel or mentionable select carrying S. Those menus always return ids.

func (*Framework[D]) OnEvent

func (f *Framework[D]) OnEvent(event bot.Event)

OnEvent implements bot.EventListener. Handlers run on their own goroutine, so a slow command does not hold up the bot.

func (*Framework[D]) OnModal

func (f *Framework[D]) OnModal[M any](h func(*Event[D], M) error) *Framework[D]

OnModal registers a handler for modals of type M sent with SendModal.

func (*Framework[D]) OnSelect

func (f *Framework[D]) OnSelect[S, V any](h func(*Event[D], S, []V) error) *Framework[D]

OnSelect registers a handler for a select menu carrying S. The chosen values are decoded into V.

func (*Framework[D]) OptionTypes

func (f *Framework[D]) OptionTypes(route string) (map[string]discord.ApplicationCommandOptionType, bool)

OptionTypes reports the wire type of each option of a command, so a harness can build interaction data without restating them.

func (*Framework[D]) Schema

func (f *Framework[D]) Schema() []discord.ApplicationCommandCreate

Schema returns the payloads for every registered command.

func (*Framework[D]) SchemaWhere

func (f *Framework[D]) SchemaWhere(keep func(Meta) bool) []discord.ApplicationCommandCreate

SchemaWhere returns the payloads for the commands keep accepts. A nil keep returns everything.

func (*Framework[D]) Shutdown

func (f *Framework[D]) Shutdown(ctx context.Context) error

Shutdown stops accepting interactions and waits for the ones already running, giving up when ctx does. Call it before closing the disgo client.

An invocation waiting on a user, such as a blocking ShowModal, is released rather than holding the drain open.

func (*Framework[D]) ShuttingDown

func (f *Framework[D]) ShuttingDown() bool

ShuttingDown reports whether Shutdown has been called.

func (*Framework[D]) SimulateAutocomplete

func (f *Framework[D]) SimulateAutocomplete(route, option string, p Partial, e *Event[D]) ([]discord.AutocompleteChoice, error)

SimulateAutocomplete asks a command's autocomplete method for suggestions.

func (*Framework[D]) SimulateCommand

func (f *Framework[D]) SimulateCommand(route string, data discord.SlashCommandInteractionData, e *Event[D]) (*Error[D], error)

SimulateCommand runs the command at route against the given interaction data.

func (*Framework[D]) SimulateComponent

func (f *Framework[D]) SimulateComponent(state any, e *Event[D]) (*Error[D], error)

SimulateComponent runs the handler registered for the given state value.

func (*Framework[D]) SimulateModal

func (f *Framework[D]) SimulateModal(modal reflect.Type, data discord.ModalSubmitInteractionData, e *Event[D]) (*Error[D], error)

SimulateModal runs the handler registered for modals of the given type.

func (*Framework[D]) SimulateText

func (f *Framework[D]) SimulateText(content string, e *Event[D]) (*Error[D], error)

SimulateText runs a prefix invocation. content is the whole message, including the prefix.

func (*Framework[D]) Sync

func (f *Framework[D]) Sync(ctx context.Context, o SyncOptions, opts ...rest.RequestOpt) (bool, error)

Sync registers commands with Discord, reporting whether it wrote anything.

func (*Framework[D]) SyncGlobal

func (f *Framework[D]) SyncGlobal(ctx context.Context, opts ...rest.RequestOpt) error

SyncGlobal registers every command globally.

func (*Framework[D]) SyncGuild

func (f *Framework[D]) SyncGuild(ctx context.Context, guildID snowflake.ID, opts ...rest.RequestOpt) error

SyncGuild registers every command in one server, which takes effect immediately rather than after Discord's global propagation delay.

func (*Framework[D]) Use

func (f *Framework[D]) Use(m ...Middleware[D]) *Framework[D]

Use adds middleware around every command.

func (*Framework[D]) UseRaw

func (f *Framework[D]) UseRaw(m ...handler.Middleware) *Framework[D]

UseRaw adds middleware to the underlying router, which runs before strut decodes anything.

func (*Framework[D]) Validate

func (f *Framework[D]) Validate() error

Validate reports every problem found while adding commands.

type Handler

type Handler[D any] func(*Event[D]) error

Handler runs a command. Middleware wraps one.

type Helper

type Helper[D any] interface {
	HelpText(*Event[D]) string
}

Helper supplies help text computed at display time. strut never calls it; it is the shape a help command can assert against, so commands agree on one.

type Kind

type Kind uint8

Kind is the set of invocation surfaces a command is exposed on.

const (
	Slash Kind = 1 << iota
	Prefix
	UserMenu
	MessageMenu
	// EntryPoint is the command Discord shows for an Activity.
	EntryPoint
)

func (Kind) Has

func (k Kind) Has(want Kind) bool

Has reports whether k includes every surface in want.

func (Kind) String

func (k Kind) String() string

type L10n

type L10n map[discord.Locale]Translation

L10n maps a locale to a translated name and description.

type Lexer

type Lexer struct {
	// contains filtered or unexported fields
}

Lexer tokenises the argument text of a prefix command. Tokens are whitespace separated, with double quotes grouping a token that contains spaces. A backslash escapes the next character inside quotes.

func NewLexer

func NewLexer(s string) *Lexer

NewLexer returns a Lexer over the argument text following a command name.

func (*Lexer) Empty

func (l *Lexer) Empty() bool

Empty reports whether any tokens remain.

func (*Lexer) Next

func (l *Lexer) Next() (string, bool)

Next consumes and returns the next token.

func (*Lexer) Peek

func (l *Lexer) Peek() (string, bool)

Peek returns the next token without consuming it.

func (*Lexer) Rest

func (l *Lexer) Rest() string

Rest consumes and returns everything left, unquoted and untrimmed. Used by options tagged rest.

type Localizer

type Localizer interface {
	Localize() L10n
}

Localizer supplies translations for the command itself. Option translations come from name.<locale> and desc.<locale> tags.

type MemoryStore

type MemoryStore[K comparable, V any] struct {
	// contains filtered or unexported fields
}

MemoryStore is the default Store: a mutex guarded map that drops expired entries as it goes. It is safe for concurrent use.

func NewMemoryStore

func NewMemoryStore[K comparable, V any]() *MemoryStore[K, V]

NewMemoryStore returns an empty in-process store.

func (*MemoryStore[K, V]) Get

func (s *MemoryStore[K, V]) Get(k K) (V, bool)

func (*MemoryStore[K, V]) Len

func (s *MemoryStore[K, V]) Len() int

func (*MemoryStore[K, V]) Put

func (s *MemoryStore[K, V]) Put(k K, v V, ttl time.Duration)

func (*MemoryStore[K, V]) Remove

func (s *MemoryStore[K, V]) Remove(k K) (V, bool)

type Mention

type Mention snowflake.ID

Mention is a snowflake parsed from any of Discord's mention forms, so entity-like options work in prefix commands.

It is an ordinary snowflake for slash commands, where Discord resolves the entity itself.

func (*Mention) FromInteraction

func (m *Mention) FromInteraction(d discord.SlashCommandInteractionData, name string) error

FromInteraction reads the resolved user id.

func (*Mention) FromText

func (m *Mention) FromText(l *Lexer) error

FromText accepts a raw id or any mention form.

func (Mention) ID

func (m Mention) ID() snowflake.ID

ID returns the underlying snowflake.

func (*Mention) OptionType

OptionType makes Mention a user option, which is what a mention usually refers to. Use a plain snowflake.ID field for other entity types.

func (Mention) String

func (m Mention) String() string

type MessageTarget

type MessageTarget struct {
	Message discord.Message
}

MessageTarget is the message a message context menu command was invoked on.

type Meta

type Meta struct {
	Name        string
	Description string
	Aliases     []string // prefix only
	Category    string
	Kinds       Kind // zero means Slash

	// Gating, all checked before a command runs.
	GuildOnly  bool
	DMOnly     bool
	NSFWOnly   bool
	OwnersOnly bool
	UserPerms  discord.Permissions
	BotPerms   discord.Permissions
	// DefaultMemberPermissions is sent to Discord. Nil leaves it unset, which
	// differs from a zero value: zero hides the command from everyone.
	DefaultMemberPermissions *discord.Permissions

	// How replies to this command behave.
	Ephemeral bool
	// ReuseResponse edits the first response instead of sending a followup.
	ReuseResponse bool
	// BroadcastTyping shows typing for prefix commands, and defers for
	// interactions, both of which buy time before the reply.
	BroadcastTyping bool

	// Prefix behaviour, all requiring PrefixOptions.EditTracker.
	InvokeOnEdit  bool
	TrackDeletion bool

	// Where Discord offers the command.
	IntegrationTypes []discord.ApplicationIntegrationType
	Contexts         []discord.InteractionContextType
	ContextMenuName  string
	// EntryPointHandler decides whether Discord launches the activity itself
	// or hands the interaction to the bot. Only read for Kinds EntryPoint.
	EntryPointHandler discord.EntryPointCommandHandlerType

	// How the command appears in the built-in help.
	HideInHelp bool
	HelpText   string

	// SubcommandRequired rejects a bare prefix invocation of a command that
	// has subcommands, instead of running the parent's own Run. Slash
	// commands cannot be invoked bare, so it has no effect there.
	SubcommandRequired bool

	ManualCooldown bool
	CustomData     any
}

Meta is a command's static description. Behaviour lives on the optional interfaces above rather than here.

type Middleware

type Middleware[D any] func(next Handler[D]) Handler[D]

Middleware wraps command execution. It sees the typed Event and the decoded command, unlike a router middleware which sees the raw interaction.

type ModalOpener

type ModalOpener interface {
	OpenModal(discord.ModalCreate) error
}

modalOpener shows a modal. Commands and components can open one; a prefix invocation has no interaction, and Discord does not allow a modal to be opened from another modal's submission.

type ModalSpec

type ModalSpec struct {
	Title   string
	Timeout time.Duration
	// Fields overrides per-field tag settings at show time, for text that is
	// only known at runtime.
	Fields map[string]FieldSpec
}

ModalSpec is a modal's presentation. A modal type supplies its own by implementing Modaler, so call sites do not restate it.

type Modaler

type Modaler interface {
	Modal() ModalSpec
}

Modaler supplies a modal's presentation. Without it, the struct tags are used and the title falls back to the type name.

type Option

type Option[T any] struct {
	// contains filtered or unexported fields
}

Option holds a value that may be absent. Wrapping a field makes it optional, and Discord requires those to be declared after required ones.

func None

func None[T any]() Option[T]

None returns an absent Option.

func Some

func Some[T any](v T) Option[T]

Some returns an Option holding v.

func (Option[T]) IsZero

func (o Option[T]) IsZero() bool

IsZero reports absence, so encoding/json omits it under `json:",omitzero"`.

func (Option[T]) Load

func (o Option[T]) Load() (T, bool)

Load returns the value and whether it was present.

func (Option[T]) LoadOr

func (o Option[T]) LoadOr(def T) T

LoadOr returns the value, or def if absent.

func (Option[T]) MarshalJSON

func (o Option[T]) MarshalJSON() ([]byte, error)

MarshalJSON encodes an absent Option as null.

func (Option[T]) Must

func (o Option[T]) Must() T

Must returns the value and panics if absent.

func (Option[T]) Present

func (o Option[T]) Present() bool

Present reports whether a value was supplied.

func (Option[T]) String

func (o Option[T]) String() string

func (*Option[T]) UnmarshalJSON

func (o *Option[T]) UnmarshalJSON(b []byte) error

UnmarshalJSON decodes null as absent.

type OptionInfo

type OptionInfo struct {
	Name        string
	Description string
	Type        discord.ApplicationCommandOptionType
	Required    bool
	// Choices are the fixed values offered, if any.
	Choices []string
	// Autocomplete reports whether values are suggested as the user types.
	Autocomplete bool
}

OptionInfo is one option a user fills in.

type Options

type Options[D any] struct {
	Data D

	// Validate checks a decoded command or modal for anything Discord cannot
	// express, such as an email. It runs before the command, on a pointer to
	// the populated struct, and takes any function:
	//
	//	Validate: func(v any) error { return validator.New().Struct(v) },
	Validate func(any) error

	OnError       func(*Error[D])
	PreCommand    func(*Event[D])
	PostCommand   func(*Event[D])
	GlobalCheck   Check[D]
	ReplyCallback func(*Event[D], *Reply)

	AllowedMentions *discord.AllowedMentions
	Logger          *slog.Logger

	// EventHandler receives every Discord event, so a bot can react to
	// things that are not commands without a second listener.
	EventHandler func(bot.Event)

	Owners              []snowflake.ID
	SkipChecksForOwners bool
	// InitOwners fills Owners from the application's owner and team when the
	// framework first syncs.
	InitOwners bool
	// OwnerTeamRoles limits which team roles count as owners. Empty accepts
	// every team member.
	OwnerTeamRoles []discord.TeamRole

	// RequireCacheForGuildCheck fails a guild-only command when the guild is
	// not cached, rather than trusting the interaction's guild id.
	RequireCacheForGuildCheck bool

	// DisableRecover lets a panicking command crash the process instead of
	// being reported as ErrCommandPanic.
	DisableRecover bool

	// OnUnknown runs for an interaction no registered handler matched, which
	// usually means a command was removed while Discord still offers it.
	OnUnknown func(*handler.InteractionEvent)

	// AutoDefer acknowledges an interaction that has not answered within this
	// long, so a slow command does not hit Discord's three second deadline
	// and fail outright. Zero disables it.
	//
	// A command that answers quickly is unaffected: the acknowledgement only
	// happens if it is still working.
	AutoDefer time.Duration

	// Cooldowns rate limits commands. Nil uses an in-process tracker.
	Cooldowns *CooldownTracker

	// Prefix enables text commands. Nil keeps the framework slash only,
	// which needs no message content intent.
	Prefix *PrefixOptions[D]
}

Options configures a Framework. The zero value is usable: every field opts into behaviour, none opts out.

type Parent

type Parent[D any] interface {
	Children() []Command[D]
}

Parent supplies subcommands that are not known at compile time.

type Partial

type Partial struct {
	// Value is the text typed into the focused option so far.
	Value string
	// Name is the focused option's name.
	Name string
	// contains filtered or unexported fields
}

Partial is the state of an option list while the user is still typing. Options they have already filled in are readable; the focused one is not yet a value.

func (Partial) Bool

func (p Partial) Bool(name string) (bool, bool)

Bool reads another option as a boolean.

func (Partial) Int

func (p Partial) Int(name string) (int, bool)

Int reads another option as a number.

func (Partial) Option

func (p Partial) Option(name string) (string, bool)

Option reads another option the user has already filled in.

func (Partial) String

func (p Partial) String() string

String returns the text typed so far, so a Partial can be used directly where the raw input is wanted.

type PrefixOptions

type PrefixOptions[D any] struct {
	Prefix     string
	Additional []string

	// Dynamic returns the prefix for one message, for per-guild prefixes.
	Dynamic func(*events.MessageCreate) string
	// StrippedDynamic handles prefixes Dynamic cannot express, returning the
	// content after the prefix. It wins over Prefix and Dynamic.
	StrippedDynamic func(*events.MessageCreate) (rest string, ok bool)

	MentionAsPrefix bool
	CaseInsensitive bool

	AllowBots           bool
	AllowSelf           bool
	AllowThreadCreation bool

	// EditTracker follows message edits, so an edited invocation updates its
	// original reply. Nil disables edit handling.
	EditTracker *EditTracker

	// NonCommand runs for messages that carry no command.
	NonCommand func(*events.MessageCreate)
}

PrefixOptions enables text commands. Leaving it nil in Options keeps the framework slash only, which needs no message content intent.

Every field opts into behaviour: bots and thread creation messages are ignored unless allowed.

type Reply

type Reply struct {
	Content    string
	Embeds     []discord.Embed
	Components []discord.LayoutComponent
	Files      []*discord.File
	Ephemeral  bool
	// Reference replies to the invoking message. Prefix commands only.
	Reference       bool
	AllowedMentions *discord.AllowedMentions
	// contains filtered or unexported fields
}

Reply is one response, built once and sent through whichever surface the command was invoked on.

func Content

func Content(s string) Reply

Content returns a Reply carrying text.

func Embeds

func Embeds(e ...discord.Embed) Reply

Embeds returns a Reply carrying embeds.

func Text

func Text(content string) Reply

Text starts a reply built from layout components.

func (Reply) AsEphemeral

func (r Reply) AsEphemeral() Reply

AsEphemeral makes the reply visible only to the invoker. Ignored by prefix commands, which have no ephemeral messages.

func (Reply) AsReply

func (r Reply) AsReply() Reply

AsReply replies to the invoking message. Prefix commands only.

func (Reply) Container

func (r Reply) Container(c Container) Reply

Container adds a container to the reply.

func (Reply) Row

func (r Reply) Row(components ...Component) Reply

Row adds one action row. Discord allows up to five buttons in a row, or a single select menu on its own.

func (Reply) Section

func (r Reply) Section(s Section) Reply

Section adds a section to the reply.

func (Reply) Separator

func (r Reply) Separator() Reply

Separator adds a divider.

func (Reply) Text

func (r Reply) Text(content string) Reply

Text adds a block of markdown as its own component.

func (Reply) Textf

func (r Reply) Textf(format string, a ...any) Reply

Textf adds formatted markdown.

func (Reply) WithAllowedMentions

func (r Reply) WithAllowedMentions(a *discord.AllowedMentions) Reply

WithAllowedMentions overrides the framework default.

func (Reply) WithComponents

func (r Reply) WithComponents(c ...discord.LayoutComponent) Reply

WithComponents appends component rows.

func (Reply) WithContent

func (r Reply) WithContent(s string) Reply

WithContent sets the text.

func (Reply) WithEmbeds

func (r Reply) WithEmbeds(e ...discord.Embed) Reply

WithEmbeds appends embeds.

func (Reply) WithFiles

func (r Reply) WithFiles(f ...*discord.File) Reply

WithFiles appends attachments.

type Responder

type Responder interface {
	Create(Reply) error
	DeferResponse(ephemeral bool) error
	Update(Reply) error
	Followup(Reply) (*discord.Message, error)
	Typing() error
}

responder performs the raw output operations.

type Runner

type Runner[D any] interface {
	Run(*Event[D]) error
}

Runner is the behaviour every command and subcommand supplies.

type Section

type Section struct {
	// contains filtered or unexported fields
}

Section pairs text with one accessory, such as a button or a thumbnail.

func NewSection

func NewSection(text ...string) Section

NewSection starts a section holding the given markdown blocks.

func (Section) WithButton

func (s Section) WithButton(b Component) Section

WithButton puts a button beside the text.

func (Section) WithThumbnail

func (s Section) WithThumbnail(url string) Section

WithThumbnail puts an image beside the text.

type Source

type Source interface {
	Author() discord.User
	Member() *discord.ResolvedMember
	GuildID() *snowflake.ID
	ChannelID() snowflake.ID
	Locale() discord.Locale
	NSFW() bool
	AppPerms() *discord.Permissions
}

source supplies invocation identity.

type Store

type Store[K comparable, V any] interface {
	// Get returns the value for k, if it is present and unexpired.
	Get(k K) (V, bool)
	// Put stores v under k for ttl. A ttl of zero never expires.
	Put(k K, v V, ttl time.Duration)
	// Remove drops k, returning whatever it held.
	Remove(k K) (V, bool)
	// Len reports how many unexpired entries are held.
	Len() int
}

Store keeps state that outlives a single interaction. Everything strut stores expires, so Put takes a time to live rather than trusting the caller to clean up.

The default is an in-process map. A shared one keeps cooldowns honest across several processes, where a local map lets a user hit another shard.

type Style

type Style = discord.ButtonStyle

Style is a button's appearance.

type SubcommandInfo

type SubcommandInfo struct {
	Name        string
	Description string
	Options     []OptionInfo
	// Subcommands is set for a group, which holds subcommands rather than
	// options.
	Subcommands []SubcommandInfo
}

SubcommandInfo is one subcommand or subcommand group.

type SyncOptions

type SyncOptions struct {
	// Guild registers to one server, which takes effect immediately. Zero
	// registers globally, which Discord propagates over about an hour.
	Guild snowflake.ID

	// Only keeps the commands it accepts, so a server can be given a
	// different set from the rest. Nil keeps everything.
	Only func(Meta) bool

	// IfChanged reads back what Discord already holds and writes only when it
	// differs. Registration is heavily rate limited, so this makes syncing on
	// every start up cheap.
	IfChanged bool
}

SyncOptions selects what to register and where.

type TextArgument

type TextArgument interface {
	Argument
	FromText(l *Lexer) error
}

TextArgument is an Argument that can also be parsed from a prefix command's raw text. Required only for options used by prefix commands.

type TrackedReply

type TrackedReply struct {
	// Response is nil when the command produced no reply.
	Response *discord.Message
	// DeleteWithInvocation records Meta.TrackDeletion, so deleting the
	// invocation removes the answer with it.
	DeleteWithInvocation bool
}

TrackedReply is the answer strut gave to one invocation.

type Translation

type Translation struct {
	Name        string
	Description string
}

Translation is a localized command name and description. Either may be empty.

type UserTarget

type UserTarget struct {
	User discord.User
	// Member is nil when the command was invoked outside a guild.
	Member *discord.ResolvedMember
}

UserTarget is the user a user context menu command was invoked on. It is filled from the interaction, never registered as an option.

type Validator

type Validator interface {
	Validate() error
}

Validator checks one command's input beyond what Discord can express. It runs after Options.Validate, if both are set.

Directories

Path Synopsis
_examples
autocomplete command
Command autocomplete is a bot showing the two ways to offer a user a set of values: a fixed list Discord renders itself, and suggestions computed as they type.
Command autocomplete is a bot showing the two ways to offer a user a set of values: a fixed list Discord renders itself, and suggestions computed as they type.
basic command
Command basic is a slash-only bot: one command, a gate, a cooldown and an error handler.
Command basic is a slash-only bot: one command, a gate, a cooldown and an error handler.
components command
Command components is a bot showing typed component state and modals.
Command components is a bot showing typed component state and modals.
contextmenu command
Command contextmenu is a bot showing right-click commands, where one struct serves a slash command and both context menus.
Command contextmenu is a bot showing right-click commands, where one struct serves a slash command and both context menus.
prefix command
Command prefix is a bot where one struct serves both slash and text commands, with edit tracking.
Command prefix is a bot where one struct serves both slash and text commands, with edit tracking.
testing
Package moderation is a set of commands in their own package, the way a real bot would organise them, so they can be tested on their own.
Package moderation is a set of commands in their own package, the way a real bot would organise them, so they can be tested on their own.
Package struttest runs strut commands without a gateway or a REST client.
Package struttest runs strut commands without a gateway or a REST client.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL