Skip to content

Capability interfaces

Ten interfaces beyond Actor, each optional, each found by type-asserting Provider.Actor. The exceptions are VoiceParticipants and ReactionObserver, which are found on Provider.Reader, because asking who is in a voice channel and hearing a reaction land both observe and change nothing. A provider implementing none of them is legitimate.

Interface Helper Methods
VoiceParticipants (on Reader) AsVoiceParticipants Participants, ParticipantCapabilities
ReactionObserver (on Reader) AsReactionObserver Reactions
Moderator AsModerator DeleteMessage, TimeoutMember
MemberInspector AsMemberInspector Member, MemberJoined
Interactive AsInteractive Prompt, OpenForm, Respond, UpdateSource, Interactions
Commands AsCommands RegisterCommands, SupportsOption
VoiceReceiver AsVoiceReceiver Capabilities, Join
VoiceSender AsVoiceSender Send, Stream
Author AsAuthor Replace, Delete
Indicator AsIndicator Show, Clear, IndicatorLimits

Voice is two interfaces rather than one, and why is explained separately.

Why type assertion rather than methods on Actor is explained separately.

How to ask whether a provider has a capability

func AsModerator(p *Provider) (Moderator, bool)
func AsMemberInspector(p *Provider) (MemberInspector, bool)
func AsInteractive(p *Provider) (Interactive, bool)
func AsCommands(p *Provider) (Commands, bool)
func AsVoiceReceiver(p *Provider) (VoiceReceiver, bool)
func AsVoiceSender(p *Provider) (VoiceSender, bool)
func AsVoiceParticipants(p *Provider) (VoiceParticipants, bool) // asserts on Reader
func AsReactionObserver(p *Provider) (ReactionObserver, bool)   // asserts on Reader
func AsAuthor(p *Provider) (Author, bool)
func AsIndicator(p *Provider) (Indicator, bool)

Each returns the capability and true, or nil and false. All ten are nil-safe in two directions: a nil *Provider and a provider with a nil Actor both answer false rather than panicking.

Why one might answer no

A type assertion carries no error, so a false answer says nothing about why. There are three reasons, and the first is the one that surprises people:

  1. The capability was never declared. Provider.Needs gates discovery, so a consumer that did not ask for something cannot reach it however capable the provider is. If a capability you expected is missing, check what the Client was constructed with before suspecting the provider.
  2. The provider does not implement it, because its platform has no such concept.
  3. The scope is read-only, so there is no Actor at all. That does not rule out VoiceParticipants or ReactionObserver, the two capabilities on the Reader.

The gating is the contract's job rather than each provider's. A provider implements what its platform can do and populates Provider.Needs from its client; it does not withhold anything. The alternative was every provider varying its Actor's method set to match what was declared, one concrete type per combination and doubling with each capability, which no author would do, so the promise would quietly not hold.

The third case is the one that matters. A read-only provider has no Actor, so it has none of the Actor-side capabilities. Asking is always safe and always answers no:

if mod, ok := chatplatform.AsModerator(p); ok {
    err := mod.DeleteMessage(ctx, ref, "off-topic")
}

Type-asserting p.Actor yourself works identically and is what the helpers do. They exist so call sites read as a question about the provider rather than about Go's type system.

Moderator: the destructive surface

type Moderator interface {
    DeleteMessage(ctx context.Context, ref Ref, reason string) error
    TimeoutMember(ctx context.Context, userID ID, d time.Duration, reason string) error
}

reason is recorded in the platform's audit log where one exists, and ignored where none does.

TimeoutMember with a non-positive duration lifts an existing timeout. It is not an error, and providers are required to treat it that way, so a caller has exactly one way to say "undo this" rather than a separate method that only some platforms would have.

Nothing a person typed or a model produced may reach these methods directly. The path to them belongs behind an authorisation check on a verified interaction, and should share no code with the answering path.

MemberInspector: the signals that change how a message reads

type MemberInspector interface {
    Member(ctx context.Context, userID ID) (Member, error)
    MemberJoined(ctx context.Context, userID ID) (time.Time, error)
}

Member returns the member's current identity and roles. MemberJoined returns when they joined. The contract requires ErrNotFound for somebody who is not in the space. Check what your provider actually returns before relying on it, and see the Discord provider's error mapping.

An account created yesterday posting its fourth message is a different situation from a member of two years, and neither fact is on the inbound Message.

Interactive: prompts, forms and their replies

type Interactive interface {
    Prompt(ctx context.Context, to Ref, p PromptSpec) (Ref, error)
    OpenForm(ctx context.Context, tok ResponseToken, f FormSpec) error
    Respond(ctx context.Context, tok ResponseToken, content string, ephemeral bool) error
    UpdateSource(ctx context.Context, tok ResponseToken, content string, choices []Choice) error
    Interactions() <-chan Interaction
}

Prompt posts a message offering choices and returns a Ref to it, so the card can be replaced or retracted later; a bare id carried no channel, so nothing could be done with it. Interactions() yields what people do with it, until the session ends.

OpenForm, Respond and UpdateSource all take a ResponseToken rather than a Ref, because most platforms only permit them as a response to an interaction that has just happened.

UpdateSource with nil or empty choices removes the buttons. Without that, a moderation card stays live after it has been actioned and a second person actions the same thing.

The surface stops deliberately short of any platform's component model: no rows, no styling beyond a hint, no custom-ID encoding, no message flags. See Where the interactive surface stops.

ResponseToken

type ResponseToken string

An opaque handle for replying to an interaction. Do not parse it: its contents are the provider's business and differ per platform.

Platforms differ in how long one stays valid and what may be done with it. Providers must acknowledge an interaction on receipt so the token survives long enough for a caller to do real work. Retrieving documents and calling a model cannot be done inside a three-second deadline, and no consumer should have to know one exists.

Interaction: what a person did

type Interaction struct {
    Type      InteractionType
    Token     ResponseToken
    Ref       Ref
    By        Member
    ChoiceKey string
    Values    map[string]string
    Command   string
    Path      []string
    Args      Args
}

func (i Interaction) Value(key string) string // Values[key]

Which fields are populated depends on Type:

Type String form Populated
ChoiceSelected choice_selected ChoiceKey
FormSubmitted form_submitted Values
CommandInvoked command_invoked Command, Path, Args

Type, Token, Ref and By are set for all three. Any other InteractionType value renders as "unknown".

Value is safe on a zero Interaction: reading from a nil map returns the empty string, and an interaction carrying no values is ordinary rather than exceptional. It does not distinguish "absent" from "submitted empty"; if you need that, read the map directly.

Args is not a map. It is read through typed accessors (String, Int, Number, Bool, Channel, User, Role), each answering (value, ok), and ok is false both when the argument is absent and when it arrived as a different kind. See Commands below.

Authorisation is decided from By.Roles. Never from a claim in message content, and never from By.Name.

PromptSpec and Choice

type PromptSpec struct {
    Content   string
    Choices   []Choice
    Ephemeral bool
}

type Choice struct {
    Key   string
    Label string
    Style ChoiceStyle
}

Choice.Key is what comes back as Interaction.ChoiceKey. It is matched exactly, so it must be stable across restarts. An interaction can arrive long after the prompt was posted, from a process that has since been redeployed.

Ephemeral asks that only the person who triggered it sees the result. Providers that cannot honour it must post normally rather than fail: losing privacy is recoverable, losing the answer is not. Treat it as a request, and never as a confidentiality guarantee.

ChoiceStyle is a hint, and no behaviour may depend on it:

Constant String form Intent
StyleDefault default an ordinary choice
StylePrimary primary the choice a person most likely wants
StyleDanger danger destructive (deleting, banning)

An unrecognised ChoiceStyle renders as "default" rather than "unknown", because a prompt that cannot be posted is worse than one that looks plain.

FormSpec and FieldSpec

type FormSpec struct {
    Title  string
    Fields []FieldSpec
}

type FieldSpec struct {
    Key       string
    Label     string
    Value     string
    Multiline bool
    Required  bool
    MaxLen    int
}

Key is what the submitted value comes back under, in Interaction.Values.

Value prefills the field. That is what lets a person see and edit exactly what is about to be published on their behalf, rather than consenting to something they have not read.

MaxLen of zero means the platform's default. Multiline and Required are requests to the platform's own input rendering; neither is enforced by this module, so validate a submitted Values map yourself before acting on it.

Commands: declaring what users can run

type Commands interface {
    RegisterCommands(ctx context.Context, cmds []CommandSpec) error
    SupportsOption(OptionType) bool
}

type CommandSpec struct {
    Name          string
    Description   string
    Options       []CommandOption
    Subcommands   []Subcommand
    Groups        []CommandGroup
    RequiredRoles []ID
}

type Subcommand struct {
    Name        string
    Description string
    Options     []CommandOption
}

type CommandGroup struct {
    Name        string
    Description string
    Subcommands []Subcommand
}

type CommandOption struct {
    Name        string
    Description string
    Type        OptionType
    Required    bool
}

RegisterCommands declares the complete set and replaces whatever was registered before. It is declarative rather than incremental: idempotent, safe to run on every start, and there is no way for the registered set to drift from the declared one. Partial updates are impossible by design, and passing an empty slice unregisters everything.

RequiredRoles restricts who may invoke a command where the platform can enforce it. A provider that cannot must still deliver the interaction, and the caller re-checks Interaction.By.Roles regardless. Platform-side gating is a convenience and never the authority.

Write the role check yourself. RequiredRoles is a hint to the platform and the shipping Discord provider does not pass it on at all, so a command declared with it is invokable by anyone who can see it. The authorisation that counts is the one you run on Interaction.By.Roles when the interaction arrives:

if !in.By.HasAnyRole(moderatorRoles...) {
    _ = act.Respond(ctx, in.Token, "Moderators only.", true)

    return
}

An option declares what it means, so the platform can draw its own picker and validate before dispatch:

CommandOption{Name: "channel", Description: "where", Type: OptionChannel, Required: true}

Seven types: OptionString (the zero value), OptionInteger, OptionNumber, OptionBoolean, OptionChannel, OptionUser, OptionRole.

Ask before you register. Commands.SupportsOption answers without connecting, and a provider that cannot carry a type refuses the registration rather than quietly substituting a text box:

if !cmds.SupportsOption(OptionChannel) {
    // decide deliberately: fall back to OptionString, or refuse and say why
}

Read arguments through their own accessor, each answering (value, bool):

id, ok := in.Args.Channel("channel")

ok is false when the argument was absent, and when it arrived as a different kind. There is no accessor that answers for everything, on purpose: a resolved channel and text somebody typed must not read alike. Args.String therefore answers false for a resolved channel; ask Channel for that.

Commands nest one level. A command carries either options or a tree, never both, and subcommands and groups mix freely:

CommandSpec{Name: "scout", Description: "...",
    Subcommands: []Subcommand{{Name: "join", Description: "..."}},
    Groups: []CommandGroup{{Name: "table", Description: "...",
        Subcommands: []Subcommand{{Name: "new", Description: "..."}}}},
}

Interaction.Path carries the verbs beneath the command (empty for a flat one), so you read which subcommand ran rather than parsing it back out.

No choice lists and no autocomplete.

Declaring a surface step by step is Declare a command surface; handling what arrives is Read command arguments.

VoiceReceiver and VoiceSender: frames in, frames out

type VoiceReceiver interface {
    Capabilities() VoiceCapabilities
    Join(ctx context.Context, channelID ID, sink VoiceSink) (VoiceSession, error)
}

type VoiceSender interface {
    Send(ctx context.Context, payload []byte) error
    Stream(ctx context.Context, frames <-chan []byte) error
}

type VoiceSession interface {
    Leave(ctx context.Context) error
    Stats() VoiceStats
    Interruptions() []VoiceInterruption
    CanSend() bool
    Format() VoiceFormat
    Done() <-chan struct{}
    Err() error
}

type VoiceSink func(VoiceFrame)

The boundary is frames in and frames out, with attribution. No decoding, no mixing, no voice activity detection, no transcoding. Those belong to the consumer.

Join takes the sink rather than the session exposing a way to set one afterwards, because audio arrives immediately. A two-step join would leave a window in which frames must be buffered or dropped, and buffering them is the one thing the sink exists to avoid.

Providers must return ErrChannelDenied for a voice channel outside the allowlist (a voice channel is a channel), and ErrInvalidArgument rather than accepting a join that will panic on the first frame. Send returns ErrNoVoiceSession when the provider is not in a channel, rather than discarding the frame.

One session at a time. A second Join is refused with ErrVoiceBusy rather than silently moving: a move would tear down the encryption epoch and the speaker mapping mid-recording, and the caller would get a truncated recording with no error to explain it.

Done and Err are how a session's end reaches you. Done closes when the session has ended for any reason, and it is a receive barrier: before it closes, every sink invocation has returned, none can begin, and Stats and Interruptions have reached their final values. It exists because the ordinary way a capture ends is not an error a caller sees. A moderator disconnecting the bot fires no callback, so without it frames stopping is indistinguishable from nobody talking. Err says why, is nil after a clean Leave, and is meaningful only once Done is closed; the first cause wins, so a Leave racing a platform-initiated end does not overwrite the reason with nil.

channelID is opaque, and what it names varies. Platforms scope a call to a channel, to a room, or to a whole session; one whose audio is not channel-scoped uses whatever does identify its call, and one where connecting is joining treats the call as already open. Do not assume the identifier means the same thing as the text channel of the same name.

The sink is called synchronously, and that is the point

rx, ok := chatplatform.AsVoiceReceiver(p)
if !ok {
    return errNoVoice
}

session, err := rx.Join(ctx, "voice-channel-id", func(f chatplatform.VoiceFrame) {
    speaker, known := f.Speaker()
    if !known {
        return // nothing to check consent against
    }

    archive.Append(speaker, f.Payload())
})

The sink runs once per frame on the provider's receive path, and the provider retains nothing after it returns. That is what lets a caller refuse a frame before anything keeps it, which matters if you are enforcing consent or excluding a speaker: the decision has to happen before the write, not as a filter over something already written.

The cost is yours. Blocking in a sink blocks the provider reading from the platform, and audio arriving during the block is lost at the socket. Slow work belongs on your own queue:

session, err := rx.Join(ctx, channelID, func(f chatplatform.VoiceFrame) {
    speaker, known := f.Speaker()
    if !known || !consented(speaker) {
        return
    }

    select {
    case work <- f: // f owns its payload, so this is safe to hold
    default:        // shed rather than stall the receive path
    }
})

This is why voice does not deliver on a channel the way Reader.Messages does. Reader requires implementations not to block the read loop when a consumer is slow; voice cannot honour that without breaking the retention rule, and where the two conflict retention wins.

Declaring what you need, and what that does and does not promise

A Client declares Needs, and a provider must not request platform privileges beyond them. On Discord that makes least privilege a property of the wiring rather than a convention: not declaring NeedMessages means the app never requests the privileged intent, and the platform refuses to serve one that was not granted. A gateway asked for an ungranted privileged intent closes with 4014.

What it does not give you is anything a server owner can check. Intents are not part of the OAuth2 authorisation request; they are sent in the gateway IDENTIFY payload and toggled in the Developer Portal, so the consent screen, which renders scopes and permissions, cannot show them. The guarantee is real and enforced by the platform, but it is not auditable from outside. See what declaring a Need costs.

State the property with its bound, though, because the unbounded version is false. The honest form is:

a bot that does not declare NeedMessages cannot read messages other than those addressed to it or sent by it

Discord exempts four cases from the privilege regardless: content in messages the app sent, content in DMs with the app, content in which the app is mentioned, and the content of a message a message-context-menu command was used on. The exemption is per message, not per call, so a consumer holding no privilege still sees content in all four.

That distinction is the one that gets quoted in a privacy conversation, and the unbounded version does not survive anyone who knows the platform asking a follow-up.

It governs reading history too. Discord's own pages conflict (the gateway page says the intent applies across the APIs, the message-resource page attaches the empty-content warning to gateway events alone), so it was measured rather than read.

The same four human-authored messages, in the same channel, fetched twice with nothing changed but the privilege:

Application flags What content came back as
0, no privileged intent "" "" "" ""
524288, message content "Howdy", and three of 44, 28 and 40 characters

Neither run touched a message that mentioned or was sent by the app, so no exemption applied to either. A single variable, identical inputs, opposite results.

So declare NeedMessages if you need ThreadHistory to return populated content. A provider where the privilege is required must return ErrForbidden rather than messages with empty content, and the reason is sharper than tidiness: the per-message exemptions apply here too, so you would receive a mixture of populated and stripped messages in one slice and could not tell a message nobody mentioned the bot in from one the privilege was missing for. Silent degradation with a plausible explanation is worse than silent degradation.

One limit remains, stated because it bounds what was shown: the per-message exemptions were not exercised over REST, so the mixture is reasoned from the documented rules rather than observed.

Ask what the platform can do before you join

type VoiceCapabilities struct {
    Format               VoiceFormat
    Attributes           bool
    Sequenced            bool
    ReportsInterruptions bool
}

Answerable without joining anything, which is the point. A consumer that cannot work without per-speaker attribution should find that out at startup and refuse, not join a channel and infer it from a stream of audio it can never use.

caps := rx.Capabilities()
if !caps.Attributes {
    return errNeedsAttribution // this platform will never say who spoke
}

Attributes: false is not a lesser version of true. It means every frame arrives unattributed forever. That is a different situation from the ordinary lag before a speaker becomes known, and distinguishing the two is the whole reason this is declared rather than discovered.

Sequenced means the frames carry a usable sequence number, and nothing more. It is false on every platform that hands a bot decoded audio rather than packets, because the sequence numbers exist on the wire and are consumed before the bot boundary. When it is false, VoiceFrame.Sequence is meaningless.

It is deliberately not a claim that loss can be measured. Carrying sequence numbers and being able to count what went missing are different capabilities, and treating them as one is how this contract came to publish a loss figure no receiver can produce.

ReportsInterruptions says whether the provider can see inbound audio stop and restart. False is not a claim that interruptions do not happen; it means VoiceSession.Interruptions will always be empty and that emptiness carries no information. A consumer whose recording cannot tolerate an unmarked gap should check it at startup and refuse.

Platforms differ more than you would guess. Discord hands a bot the raw packets; most platforms decode first, and the sequence numbers are consumed on the way. See the platform audio landscape report for what four of them actually hand a bot.

Sending: the provider paces, and Send blocks

A voice channel expects frames at the rate the codec was framed for, and a generator does not produce them at that rate. Speech synthesis runs far faster than real time. So Send blocks until the frame's turn on the wire.

Pacing needs a monotonic clock and drift correction to survive a long utterance, and that is the kind of work this module exists not to hand out once per consumer. The consequence is the one worth having: the naive loop is correct.

for frame := range synth.Frames() {
    if err := tx.Send(ctx, frame); err != nil {
        return err
    }
}

Stream: when the generator should not be on the wire's goroutine

func (tx VoiceSender) Stream(ctx context.Context, frames <-chan []byte) error

Send alone makes your generator and the wire the same goroutine, so a generator that stalls stalls the wire. Stream separates them: you write ahead as fast as you can produce, the provider takes frames at the codec's rate, and a hiccup in generation is absorbed rather than heard.

The channel's capacity is the jitter buffer, and it is your choice rather than a number this contract picks. Unbuffered runs the generator in lockstep with the wire; capacity n lets it run n frames ahead. Writing blocks once the provider is that far behind, which is exactly the backpressure you want. It is the signal that generation has outpaced real time.

frames := make(chan []byte, 50) // about a second of absorption

go func() {
    defer close(frames) // closing ends the stream; it does not leave the channel
    for f := range synth.Frames() {
        select {
        case frames <- f:
        case <-ctx.Done(): // Stream has stopped reading; do not block for ever
            return
        }
    }
}()

if err := tx.Stream(ctx, frames); err != nil { /* ... */ }

That select is not decoration. When Stream returns early (connection lost, context cancelled, channel left) the provider stops reading. A producer still writing into a full channel then blocks for ever, and never learns why, because the error was returned on a different goroutine from the one that is now stuck.

Select on the same context you passed to Stream. The version without the select is the one you write first, and it deadlocks silently.

Errors come back from Stream, not on a channel. That is the whole reason it takes a channel rather than returning one: a failed write to a channel is indistinguishable from a successful one, so a connection lost mid-utterance would be silent.

A momentarily empty channel is not the end of the utterance. Only closing it is. This is the case Stream exists for: a provider that read an empty channel as "finished" would stop the transmission and restart it when frames resumed, which on a platform that shows who is talking means the indicator flickering through every hesitation in generation. Providers keep the transmission open across a gap.

The flip side is that a generator dying without closing the channel holds the transmission open until the context is cancelled or you leave. Close the channel in a defer, as the example above does.

Send and Stream are one capability rather than two, because sending a frame and sending a stream are the same authority: this deployment may speak. Splitting them would make you check twice for one permission. A provider offering either offers both.

Only one at a time. Send returns ErrVoiceBusy while a stream is running, and Stream returns it if one already is. Two writers pacing into one timeline do not produce a degraded utterance, they produce two utterances chopped together and neither speaker said it.

Sending and receiving at the same time

A provider holds one connection carrying both directions and nothing serialises them. A sink may be called while Send is blocked, and Send may be called while a sink is running. Both are expected.

Send is safe to call concurrently with a sink. It is not safe from more than one goroutine at a time. The frames interleave, for the same reason Stream and Send cannot both be live.

One shape is a trap, and it is the obvious thing to write for a bot that answers what it hears:

// WRONG. Send blocks for the length of the utterance, and it blocks the
// receive path while it does, so everything said meanwhile is lost.
rx.Join(ctx, channelID, func(f chatplatform.VoiceFrame) {
    _ = tx.Send(ctx, synthesise(f))
})

Never send from inside a sink. Hand the frame to your own goroutine and send from there. This follows from the sink's blocking rule, but a blocking Send makes it much easier to do by accident.

Do you need inbound as a stream too?

Build the channel yourself. That is the point rather than an inconvenience, because it puts the filter above the buffer:

rx.Join(ctx, channelID, func(f chatplatform.VoiceFrame) {
    speaker, known := f.Speaker()
    if !known || !permitted(speaker) {
        return // refused before anything holds it
    }

    select {
    case audio <- f:
    default:
    }
})

A channel offered by the contract would put the buffer above the filter, and then either the provider keeps frames you were never allowed to keep, or it takes a filter function as an argument... which is this callback with extra steps.

The asymmetry with Stream is deliberate, not an inconsistency: buffering audio the process produced itself raises no question about whether it may be kept, and absorbing a generator's hiccups is the reason to want it. Same reasoning, opposite answers, because the direction differs.

VoiceFrame: one Opus frame, and who said it

type VoiceFrame struct{ /* unexported */ }

func AttributedFrame(speaker ID, sequence uint16, timestamp uint32, opus []byte) VoiceFrame
func UnattributedFrame(sequence uint16, timestamp uint32, opus []byte) VoiceFrame

func (f VoiceFrame) Speaker() (ID, bool)
func (f VoiceFrame) Payload() []byte
func (f VoiceFrame) Sequence() uint16
func (f VoiceFrame) Timestamp() uint32

Payload is not named for a codec because it is not always the same one. Read Capabilities().Format.Codec to know what you have.

Sequence and Timestamp are meaningful only when Capabilities().Sequenced is true, and zero otherwise.

The payload is not decoded, reordered, reassembled or resampled. Three things depend on that: a recording must concatenate back to the original stream byte-exactly to be worth re-transcribing later; a cut may fall only on a frame boundary; and Sequence and Timestamp are the only evidence you have that the stream you hold is the stream that was sent.

Speaker returns two values, and the second is the design. Audio arrives before the platform says whose it is, on every session join and with gaps measured in seconds, so a frame may genuinely have no speaker yet. Because you must destructure to reach the identifier, the obvious code is also the correct code:

speaker, known := f.Speaker()
if !known {
    return
}

An unattributed frame is ordinary, not an error. What it carries is not that speaker's audio in any useful sense (undecryptable, or plaintext nobody has vouched for), so drop it if you cannot act on unidentified audio, and count it if you are tracking capture quality.

Two properties worth relying on:

  • The zero VoiceFrame is unattributed. A provider building one by struct literal gets an unattributed frame, which fails safe.
  • AttributedFrame with an empty ID yields an unattributed frame. A provider whose speaker lookup missed has an empty identifier to hand, and it must not be able to produce a frame that reads as identified.

The frame owns its payload. Payload() is the frame's own storage, so it stays valid after the sink returns and you may hold it. Providers reuse their receive buffers, and a frame that aliased one would hand you whatever arrived next. Do not mutate what Payload() returns.

Ask whether you may speak, rather than finding out

session, err := rx.Join(ctx, channelID, sink)
if err != nil { return err }

if !session.CanSend() {
    notifyOperator("I have joined but cannot speak. Check my permissions.")
}

CanSend exists so the ordinary case is discoverable without provoking a failure. A bot added to a space without the right to speak can say so the moment it joins, to somebody who can fix it, rather than at the moment it first has something to say.

It is advisory and never the authority. A permission can be revoked between the check and the next Send, so handle ErrForbidden from Send and Stream regardless. This contract already applies exactly that rule to CommandSpec.RequiredRoles: platform-side gating is a convenience, and the refusal is what decides.

It returns false where the provider cannot determine the answer, which fails closed in the harmless direction. Somebody is told to check a permission that may already be granted, and the worst outcome is a glance at a settings page. You may still attempt the send.

VoiceFormat: what shape to produce

type VoiceFormat struct {
    Codec           Codec         // "opus", "pcm_s16le", …
    SampleRate      int           // 48000 on Discord, 16000 on some others
    Channels        int           // 2
    FrameDuration   time.Duration // 20ms — the interval Send paces to
    MaxPayloadBytes int           // 1400 on Discord; 0 = no stated limit
}

func (f VoiceFormat) SamplesPerFrame() int // 960 for the above; 0 if unset
func (f VoiceFormat) Valid() bool          // false for a zero format

Ask the session, don't hardcode it:

f := session.Format()
if !f.Valid() {
    return errNoFormat // a provider that never filled it in
}

// SamplesPerFrame is per channel, so an interleaved buffer holds
// SamplesPerFrame * Channels. Most encoders take the frame size from the length
// of the PCM you hand them rather than from their constructor.
pcm := make([]int16, f.SamplesPerFrame()*f.Channels)

This exists because Send paces to FrameDuration. Hand over frames twice as long as the session expects and the audio plays at half speed, with nothing failing anywhere to say so. The alternative is every consumer reading the platform's documentation and hardcoding its constants, which is a consumer holding a fact about a platform this contract exists to keep it away from.

It matters inbound too, less obviously: cutting a recording only on frame boundaries needs the frame duration, and assuming one is assuming a platform constant.

The codec is declared, not assumed. A payload is bytes, and bytes in an unrecognised codec are indistinguishable from bytes in a recognised one, so a caller that cannot handle Format.Codec should decline rather than guess.

Codec is a string for the same reason Register takes one: a platform this module has never heard of must be able to ship a provider without anything being contributed here. Use CodecOpus or CodecPCM16 where they fit, and the platform's own name where they do not. CodecPCM16 is signed 16-bit little-endian, interleaved across channels. The endianness is in the name because a byte slice that is silently the other way round is a bug nobody hears until it is played.

Respect MaxPayloadBytes if it is non-zero. An encoder configured for a high bitrate can produce a frame the transport refuses or truncates, and neither failure is audible as itself; both present as a dropout. Discord caps an Opus frame at 1400 bytes, which nothing in the other fields would tell you. Zero means the provider states no limit, not a limit of zero, which is why it is not part of Valid().

Check Valid() before trusting the numbers. A provider that never filled the format in yields a zero value, and an encoder configured from a zero format produces nothing. Silently.

Format hangs off the session because framing is a property of the joined channel, which has an ordering consequence worth planning for: you cannot know what to encode until you have joined. Audio prepared beforehand may need re-encoding, so a fixed clip is better held as samples than as frames.

What the provider handles, so you don't

Platforms want more than audio around a transmission. One wants telling that transmission is starting and stopping so it can show who is talking; another wants a short run of silence after the audio stops, or receivers interpolate across the gap and the last word is smeared.

All of that is the provider's. You supply encoded audio in the shape Format() describes, and nothing else: no start signal, no stop signal, no magic silence frame. A consumer that has to know a platform's silence encoding is a consumer holding a platform detail, which is the thing this contract exists to prevent. Same argument that gives the provider pacing.

Leaving the channel while a stream is running ends the stream, and Stream returns ErrNoVoiceSession rather than blocking on a channel nobody will read again. So a shutdown can either close the channel and wait, or leave and let Stream return. You don't have to sequence them.

Overlaying two sources: mix before you encode

Speech over a music bed is one stream, not two. A platform carries one stream from one sender, so overlaid audio is a single stream that already contains both, which is why Send and Stream refuse to run together with ErrVoiceBusy rather than interleaving.

Mix in the sample domain and encode once. You have both sources before encoding, so mixing there is both possible and better than anything downstream, where it would mean decoding and re-encoding audio that was already encoded: audibly worse for no gain. Ducking a bed under speech is a gain envelope on samples, which is yours and cannot be anyone else's.

VoiceStats: what arrived, and nothing about what did not

type VoiceStats struct {
    Received     uint64
    Unattributed uint64
}

There is no loss counter, and that is a finding rather than a gap.

Earlier releases published Lost, LossMeasurable and LossRate. They were withdrawn because the quantity is not identifiable from what a receiver observes. A sequence number consumed with nothing delivered may have been a lost packet, a padding-only packet the transport discarded, or a packet that failed to decrypt. Two byte-indistinguishable streams can carry different true loss, so no arithmetic over the arrivals separates them. On Discord the published figure was about one percent, from padding-only packets alone, on a call that had lost nothing.

Do not reconstruct one from VoiceFrame.Sequence. The obvious attempt, treating any delta above 1 as a gap, is wrong by three orders of magnitude, because UDP reorders routinely. Measured on a healthy 55-second call: 398 frames received, 262,148 reported lost.

Unattributed counts frames that arrived while the speaker was not yet known. It is a capture-quality signal, not a drop count: nothing was lost, and only the attribution lagged.

VoiceInterruption: the gap, in a form that survives an archive

type VoiceInterruption struct {
    At time.Time
}

func PlatformInterruption(at time.Time, d time.Duration) VoiceInterruption
func ConsumerDelayedInterruption(at time.Time, d time.Duration) VoiceInterruption

func (i VoiceInterruption) Span() (time.Duration, bool)

Read them from the session, oldest first:

for _, in := range session.Interruptions() {
    d, platformOnly := in.Span()
    if !platformOnly {
        d -= myOwnRejoinDelay // only you can measure that part
    }
    markGap(in.At, d)
}

One instant and one duration, never two instants. At is absolute and survives being written down. The duration is measured by the provider while it still holds a monotonic reading, and is stored as a measured value.

The reason is worth knowing, because the alternative fails in the worst possible way. A time.Time carries a monotonic reading and Sub uses it, so subtracting two of them in-process is immune to a wall-clock step. But serialising a time.Time strips that reading, and so does storing it or handing it across a process boundary. Subtract two stored instants across a clock that stepped backwards and you get a negative duration. Code that subtracts immediately is correct in every test you will write and wrong in production.

Both edges are frame boundaries. At is when the last frame before the gap was delivered; At.Add(d) is when the first frame after it was. Neither is the moment the provider noticed. Detection lags the silence, so anchoring there would show audio continuing past the last frame you actually hold.

Span returns two values for the same reason Speaker does. A duration is meaningless without knowing whose time is in it:

  • true: the gap was seen inside a session that survived it. Nothing of yours is in the number.
  • false: the session died and a successor was joined, so the duration runs from the old session ending to the new one starting. That includes however long you took to notice, back off and retry. The provider knows when you rejoined, not when you became able to, so only you can subtract it.

That second case matters more than it sounds. If a voice connection dies during a twenty-minute break somebody deliberately took, and you rejoin when things resume, the interruption reads as twenty minutes, and an archive that trusts it records a chosen break as audio you failed to capture.

The zero value reads as false, so a provider that forgot to say which kind it is cannot be taken as having promised the duration is pure.

Check VoiceCapabilities.ReportsInterruptions first. When it is false, Interruptions() is always empty and that emptiness carries no information, the same trap as a bare Speaker identifier would be. If your recording is a source rather than a by-product, and an unmarked gap in the timeline is unacceptable, check at startup and refuse rather than find out later from a recording that looks complete.

Author: changing what you posted

type Author interface {
    Replace(ctx context.Context, ref Ref, content string, choices []Choice) error
    Delete(ctx context.Context, ref Ref) error
}

func AsAuthor(p *Provider) (Author, bool)

Declare NeedAuthoring.

Not Message.Author. That field is a Member, the person who wrote a message. This is the capability through which you act on messages you posted, and the two live a few lines apart in the same package.

The counterpart to Moderator. Whose message it is decides which applies and nothing else does: Moderator acts on other people's, Author acts on your own.

That split exists because the alternative charged you for the wrong thing. Deleting your own message used to mean declaring NeedModeration, a moderation capability, to tidy up after yourself, and that cost was imposed by this contract rather than by any platform. A consumer that only manages what it said can now say exactly that, and somebody reading its Needs learns something true about it.

Replace is wholesale, not a patch. Content and choices are both replaced by what you pass, and passing no choices leaves none. That is why it is not called Edit.

A provider must not re-notify. Replacing rather than reposting is almost always done because the reader has been asked to look once already. A provider that achieves the effect by deleting and reposting has not implemented this and should not claim it. The Ref you hold stays valid, because it is still the same message.

Delete takes no reason, where Moderator.DeleteMessage does. A reason exists for the platform's audit log; tidying up after yourself is not an audited action.

It is optional because platforms differ on whether a bot may change what it said. A provider that cannot implements nothing and AsAuthor returns false, the same answer you get for any capability a platform lacks.

Indicator: your words on the bot's name

type Indicator interface {
    Show(ctx context.Context, text string) error
    Clear(ctx context.Context) error
    IndicatorLimits() IndicatorLimits
}

type IndicatorLimits struct {
    MaxLength int // runes
}

func AsIndicator(p *Provider) (Indicator, bool)

Declare NeedIndicator. On Discord it buys no intent; the request is REST.

The words are yours because nobody else can supply them. The contract captures audio and delivers frames to your sink. Whether you transcribe them, store them, keep some speakers and drop others, or discard them is your decision and invisible to the provider, so a marker the contract labelled "recording" would assert something it cannot know. Capture itself is already visible (the bot is in the channel from Join until Done), and what you say about the interval, if anything, is your policy. An ear emoji is a valid text.

Show replaces the name; Clear removes it. Show sets the bot's displayed name in the space to text, overwriting one a space administrator set, and Clear removes the space-specific name so the platform falls back to the account name. Neither remembers anything, which is the point: a process that has just started can do either correctly. The administrator's name is not restored. Both are effect-idempotent, so repeating one changes nothing, and neither is free: each is a platform request that consumes rate-limit quota and can block on ctx.

A marker can outlive you. Clear is a call and a killed process makes none, so the name persists on the platform until something removes it. That fails in the safe direction, since a stale marker over-warns, but the mechanics that keep it honest are yours to run: Clear on every exit path, including a failed start and a recovered panic; and on startup, call whichever of Show or Clear is true now rather than reading the name you can see. A marker you did not set is evidence of a previous instance, not of a working one.

Ask the limit; do not guess it. IndicatorLimits().MaxLength is in runes, answers before Connect, and does not change. Show wraps ErrInvalidArgument for empty text and for text over the limit, before the connection is consulted; it does not truncate.

ErrForbidden, not ErrUnsupported, when the bot may not rename itself. The platform has the concept and the deployment lacks the grant, which is fixable, and that is the distinction ErrForbidden exists to carry. A platform with no per-space name for a bot implements nothing and AsIndicator answers false.

One thing the provider cannot see. A moderator removing the marker mid-capture is a member update, which on Discord arrives under the privileged members intent this contract does not request for this capability. The marker fails open.

VoiceParticipants: who is in a voice channel

type VoiceParticipants interface {
    Participants(ctx context.Context, channelID ID) ([]VoiceParticipant, error)
    ParticipantCapabilities() VoiceParticipantCapabilities
}

func AsVoiceParticipants(p *Provider) (VoiceParticipants, bool) // asserts on Reader

Declare NeedVoiceParticipants. It is separate from NeedVoiceReceive because occupancy is answerable without capturing anything, and a consumer that only wants to know who is in a room should not have to declare that it will listen to them.

On the Reader, so a read-only scope keeps it. Asking who is present observes and changes nothing, and an observe-only deployment enforcing consent is exactly the caller that needs to know who is in the room.

It is not called presence. On Discord that word means online, idle or do-not-disturb, and needs a privileged intent this contract never asks for. Channel occupancy arrives on a different, unprivileged one. A capability named "presence" invites an implementer to reach for the privileged intent and a reviewer not to question it.

Participants does not require having joined. A caller may ask who is in a room before deciding whether to enter it, which is the order a consumer seeking consent wants.

An empty slice with a nil error means nobody is here, and nothing else may mean that. Three situations would otherwise share one answer (the room is empty, this provider cannot see, this deployment is not permitted), and a consumer conflating them concludes there is nobody to ask and proceeds. So:

Error When
ErrUnsupported this provider cannot answer at all
ErrForbidden the platform refuses this deployment, which somebody can fix
ErrChannelDenied the channel is outside the scope's allowlist, checked before anything touches the platform
ErrInvalidArgument a malformed identifier, or one naming a real, allowed channel that carries no voice
ErrNotFound no such channel exists
ErrNotConnected asked before Connect
an error matching no sentinel the provider could not determine its own permission

The allowlist check comes first because a voice channel is a channel, and a caller must not reach past the allowlist by naming one. Without it the capability would enumerate the people in any channel in the space, a worse leak than the audio the allowlist exists to gate.

The text-channel case is ErrInvalidArgument rather than an empty slice because the obvious implementation returns nothing and thereby asserts that nobody is in a text channel.

The last row is deliberately not ErrForbidden, which asserts a refusal that has not been established, and not ErrUnsupported, which is documented as permanent and which callers are told to treat as the capability being absent. Saying "never" about "not yet" makes a caller fall back for good on a cold cache that would have warmed.

ParticipantCapabilities is named that way because Go cannot overload by return type. A provider is entitled to implement Reader and Actor on one concrete type, and that type may already carry VoiceReceiver.Capabilities.

VoiceParticipantCapabilities: which fields mean anything

type VoiceParticipantCapabilities struct {
    ReportsMuteState bool // the four mute and deafen booleans
    ReportsName      bool // Member.Name
    ReportsRoles     bool // Member.Roles
}

Each flag exists for the reason Sequenced does: a zero value that cannot be told apart from a real one is how this contract came to publish a loss figure no receiver could produce. False is not a lesser version of true.

ReportsRoles is separate from ReportsName because platforms come apart here. Google Meet gives a participant a display name and keeps meeting-space roles on a different resource entirely, so one flag would make such a provider claim both or withhold a name it has. Nor is it enough that an empty Roles fails closed through Member.HasAnyRole: an empty Roles on a platform without roles cannot be told apart from an empty Roles on a platform that has them, held by somebody with none, and the second is a signal a consumer may act on.

VoiceParticipant: one person in the channel

type VoiceParticipant struct {
    ID     ID
    Member Member

    SelfMuted, SelfDeafened   bool
    SpaceMuted, SpaceDeafened bool
}

ID is always populated. It is the one field every platform with occupancy can fill, and a consumer enforcing consent needs an identifier even where identity is undeclared.

Member is who they are, as of observation. Member.ID always equals ID; Name is meaningful only when ReportsName is true and Roles only when ReportsRoles is. It is carried here rather than left to MemberInspector because on Discord this data arrives on the unprivileged voice-states intent, in an event the provider already handles. Forcing a consumer to compose with MemberInspector to show a name would make it declare NeedMemberLookup and buy the privileged members intent for data the platform had already handed over. Message.Author is the precedent.

Roles can go stale. Role changes arrive on the privileged intent this capability does not request, so a long-seated participant's roles may be out of date, and stale roles fail open where absent ones fail closed. A caller authorising on roles uses MemberInspector, the contract's current-value query.

The self flags are statements by the person; the space flags are statements about them by somebody else. They are kept apart because for a consent model they are not interchangeable. Somebody who has deafened themselves has arguably declined to hear the room, which is a different fact from a moderator having silenced them.

There is no arrival time. No platform this contract has met reports one, and a field that is always zero is worse than an absent one. A consumer that needs it observes a join and timestamps it.

ReactionObserver: hearing a reaction land

type ReactionObserver interface {
    Reactions() <-chan Reaction
}

func AsReactionObserver(p *Provider) (ReactionObserver, bool) // asserts on Reader

Declare NeedReactions. On Discord it buys the unprivileged GUILD_MESSAGE_REACTIONS intent. It is the event half of Actor.React: the bot can offer a reaction, and this is how it hears one used, on its own replies or on anybody's message in the scope.

On the Reader, so a read-only scope keeps it, as VoiceParticipants is and for the same reason: a reaction arriving is observed. An observe-only deployment that posts nothing can still be asked, by a person, to notice a reaction on somebody else's message.

A second stream rather than a wider Messages(). A sum type on the one channel every consumer already ranges would break all of them for a feature most do not want, and would make the reactions intent a cost of reading messages at all. The channel carries the same rules as Messages(): one useful consumer, never nil once the scope is minted, the provider never blocks its read loop on a slow one.

Reaction: one change, in one of four shapes

type ReactionChange uint8

const (
    ReactionAdded        ReactionChange = iota
    ReactionRemoved
    ReactionsCleared     // every reaction on the message
    ReactionEmojiCleared // every reaction of one emoji
)

func AddedReaction(ref Ref, parent ID, emoji string, user ID) Reaction
func RemovedReaction(ref Ref, parent ID, emoji string, user ID) Reaction
func ClearedReactions(ref Ref, parent ID) Reaction
func ClearedEmoji(ref Ref, parent ID, emoji string) Reaction

func (r Reaction) WithMember(m Member) Reaction
func (r Reaction) WithVariant(v string) Reaction

func (r Reaction) Change() ReactionChange
func (r Reaction) Ref() Ref
func (r Reaction) ParentID() ID
func (r Reaction) Emoji() string
func (r Reaction) Variant() string
func (r Reaction) UserID() ID
func (r Reaction) Member() (Member, bool)

Reaction is opaque, built by a provider through one of four constructors, for the reason VoiceFrame is: each change carries a different set of facts, and the constructors make that structural rather than a list of "empty when" clauses. A cleared message names no emoji and no account; a removal names an account and usually no member. What is conditional is conditional in the signature (Member() returns (Member, bool)) and nothing else is.

Change Emoji() UserID() Member()
ReactionAdded set set true when the platform sent one
ReactionRemoved set set almost always false
ReactionsCleared empty empty false
ReactionEmojiCleared set empty false

Four changes rather than added-or-removed because platforms send four, and a consumer keeping per-person state needs all of them: a moderator wiping a message is a ReactionsCleared, and a store that only knew the pair would keep every verdict on it. Removal matters. A retracted verdict is not the same as no verdict.

UserID is whose reaction it is, not who changed it. A moderator removing somebody else's reaction is reported against that somebody, which is what a per-person store wants.

Member() is there when the platform sent one. On Discord an added reaction arrives with the member on the unprivileged intent, and a removed one with the account only. Carrying it follows VoiceParticipant.Member: sending the consumer to MemberInspector for data the platform already handed over would make it buy the privileged members intent, and on a read-only scope MemberInspector is not reachable at all. Returning it as (Member, bool) rather than a field follows Member.IsBot: a zero member on a removal would read as a person with no roles. Roles are as of the reaction; a consumer authorising on them uses MemberInspector, the current-value query. Discord does occasionally omit the member on an add, so false on an Added is a value to handle, not a provider bug.

Emoji() round-trips with React by string equality, so a consumer that offered 👍 recognises 👍 coming back. Two limits: a custom emoji on Discord is name:id and the name half is mutable, so a consumer holding one across a rename compares the id half; and a custom emoji that has been deleted arrives with no name, so Emoji() can be empty on an Added or Removed. Change() is the discriminator, never the emptiness of a field.

Variant() is how a platform's second form of one emoji stays honest. Discord's super reactions are not an animation on an ordinary reaction: one person can hold both forms of 👍 on one message, and removing one is not retracting the other. The variant is the platform's own word (Discord's is burst) and empty for the ordinary form. A consumer keying per-person state on (UserID, Emoji, Variant) is exact; one keying on (UserID, Emoji) is wrong only for people who used both forms. A bot cannot super-react, so the reaction it offered never comes back with a variant.

Ref() and ParentID() are the message's, resolved as for a Message. A reaction in a thread carries the thread as Ref().ThreadID and the parent channel as ParentID(), admitted on the parent's behalf exactly as a message in the thread is. Join it to the Ref your reply returned on MessageID and nothing else: on a channel-cache miss the provider leaves ThreadID empty rather than guessing, so the two need not be equal field for field.

What the stream does not promise

The bot's own reactions never arrive. Only the provider knows which account it authenticated as, and the contract exposes that identity nowhere else, so this is the layer that can exclude them. Other bots' reactions do arrive: a provider cannot tell on a removal whether the account is a bot, so a filter could only drop a bot's Added and would then deliver its Removed, a retraction of something the consumer never saw. Member().IsBot on the Added lets a consumer that cares skip it, and a consumer that skipped the Added has nothing for the Removed to orphan.

Live, filtered like Messages(), and best-effort like it. Nothing before Connect is replayed and there is no call to fetch what is on a message. A reconnect that starts a fresh session drops what was buffered in the gap, and ConnState.LastReconnectLostEvents says that it happened but not what was in it; a full consumer buffer drops the event at hand with no signal at all. A dropped Removed or Cleared leaves a verdict in your store that is no longer on the message. The four changes are what following the platform needs; they are not a guarantee of exactness, and the repair (a "who has reacted" query) is deferred until a consumer needs exact state.

Not authorisation. A reaction is one character from an account in the space. A policy that takes a reaction as the trigger, verifies the account through MemberInspector and authorises on the result has the shape Moderator's doc comment asks for; treating the reaction, or the Member that rode in with it, as the check does not.

What Validate rejects, and with which error

PromptSpec, FormSpec and CommandSpec each carry a Validate() error. Duplicate or empty keys are the failure worth catching before anything is posted: a moderation card carries dismiss, delete and ban, and routing an ambiguous key means taking the wrong action against a person.

Type Condition Error
PromptSpec Content is empty ErrEmptyContent
PromptSpec no choices ErrNoChoices
PromptSpec a choice's Key is empty ErrEmptyChoiceKey
PromptSpec a choice's Label is empty ErrEmptyChoiceLabel
PromptSpec two choices share a Key ErrDuplicateChoiceKey
FormSpec Title is empty ErrEmptyTitle
FormSpec no fields ErrNoFields
FormSpec a field's Key is empty ErrEmptyFieldKey
FormSpec two fields share a Key ErrDuplicateFieldKey
CommandSpec Name is empty ErrEmptyCommandName
CommandSpec a name is not 1–32 runes of lowercase letters, digits, - or _ ErrInvalidCommandName
CommandSpec a Description is empty ErrEmptyDescription
CommandSpec a Description exceeds 100 runes ErrInvalidDescription
CommandSpec Options beside Subcommands or Groups ErrOptionsWithSubcommands
CommandSpec more than 25 options, subcommands or groups at one level ErrTooManyOptions
CommandSpec an option's Name is empty ErrEmptyOptionName
CommandSpec two options, or two subcommands and groups, share a Name at one level ErrDuplicateOptionName
CommandSpec a required option follows an optional one ErrOptionOrder
CommandSpec an option's Type is not one this contract defines ErrUnknownOptionType
CommandSpec a group has no subcommands ErrEmptyGroup
CommandSpec names and descriptions exceed 8000 runes in total ErrCommandTooLarge

The name, description, option and duplicate rules apply at every level: command, subcommand and group. Each of the command rules was previously discovered as a 400 from the platform, after connecting, which reads as a configuration fault rather than as the caller's own spec being wrong.

Checks run in the order listed and stop at the first failure, so a spec with two problems reports one. Errors about a member of a slice are wrapped with its index and key, so errors.Is still matches the sentinel while the message says which one:

choice 2 ("delete"): chatplatform: choice keys must be unique

What Validate does not check

  • FieldSpec.Label may be empty. Choice.Label may not. The asymmetry is real: a form field with no label validates and posts.
  • No length, character-set or platform-limit check on prompts or forms. A key or label the platform will reject passes Validate and fails at the provider. Commands are the exception: their names, descriptions and counts are bounded above, because each bound was once a 400 on a live gateway.
  • Nothing about RequiredRoles, Ephemeral, Style, MaxLen or Multiline.
  • Whether a provider can carry an option's Type; ask Commands.SupportsOption.

Nothing in this module calls Validate for you. No constructor and no contract method invokes it; providers call it before touching the wire, and a caller assembling a spec from user input should call it too.