Capability interfaces¶
Seventeen interfaces beyond Actor, each optional, each found by type-asserting
Provider.Actor or Provider.Reader. The five on the Reader are the ones
that observe and change nothing, so a read-only scope keeps them:
VoiceParticipants, ReactionObserver, SpaceInspector, ChannelInspector
and AllowlistEditor, which governs reading. A provider implementing none of
them is legitimate.
| Interface | Helper | Methods |
|---|---|---|
VoiceParticipants (on Reader) |
AsVoiceParticipants |
Participants, ParticipantCapabilities |
ReactionObserver (on Reader) |
AsReactionObserver |
Reactions |
SpaceInspector (on Reader) |
AsSpaceInspector |
Space |
ChannelInspector (on Reader) |
AsChannelInspector |
Channel |
AllowlistEditor (on Reader) |
AsAllowlistEditor |
AllowChannels, DisallowChannels |
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 |
ChannelManager |
AsChannelManager |
CreateCategory, CreateChannel, ShowChannel, HideChannel, Rename, Move |
RoleManager |
AsRoleManager |
CreateRole, GrantRole, RevokeRole |
Poster |
AsPoster |
Post |
Floor |
AsFloor |
InviteToSpeak, ReturnToAudience |
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 AsSpaceInspector(p *Provider) (SpaceInspector, bool) // asserts on Reader
func AsAllowlistEditor(p *Provider) (AllowlistEditor, 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 twelve are
nil-safe in two directions: a nil *Provider, and a provider whose Actor
(or, for the four on the Reader, whose Reader) is nil, 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:
- The capability was never declared.
Provider.Needsgates 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 theClientwas constructed with before suspecting the provider. - The provider does not implement it, because its platform has no such concept.
- The scope is read-only, so there is no
Actorat all. That does not rule outVoiceParticipantsorReactionObserver, the two capabilities on theReader.
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:
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¶
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.
That acknowledgement must never show more than your answer would. If you
answer privately, nobody else sees the acknowledgement either. Where the provider
cannot yet know how you will answer, it acknowledges privately. A command or
form whose answers are public says so with AnswersPublicly, and then the
acknowledgement can be public too.
Interaction: what a person did¶
type Interaction struct {
Type InteractionType
Token ResponseToken
Ref Ref
ChannelKind ChannelKind
By Member
OutsideAllowlist bool
ChoiceKey string
Values map[string]string
Command string
Path []string
Args Args
}
func (i Interaction) Value(key string) string // Values[key]
OutsideAllowlist is true for an interaction from a channel the scope does not
read, which only a scope with WithInteractionsAnywhere admits
(spec 0037). Answer such an interaction with Respond. Reading the
channel, through ThreadHistory, ChannelInspector or any other bare-id call,
is still refused.
Which fields are populated depends on Type:
Type |
String form | Populated |
|---|---|---|
ChoiceSelected |
choice_selected |
ChoiceKey |
FormSubmitted |
form_submitted |
Values, and ChoiceKey from FormSpec.Key |
CommandInvoked |
command_invoked |
Command, Path, Args |
Type, Token, Ref, ChannelKind and By are set for all three. Any other
InteractionType value renders as "unknown". ChannelKind is the kind of
Ref.ChannelID; see ChannelKind.
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, ChannelKind, User, Member, 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.
Member reads a user option as the person the platform resolved, with their
names, from chat-platform v0.27.0
(spec 0033). It answers true only for a member of the space,
so DisplayName is always filled when it does. For anyone else, or from a
provider that resolved only an id, it answers false and User still gives the
id. The names are chosen by the person, so treat them as untrusted, as you do a
message.
Authorisation is decided from By.Roles. Never from a claim in message
content, and never from By.Name.
ChannelKind: what a person can do in a channel¶
type ChannelKind int
const (
ChannelUnknown ChannelKind = iota // the provider could not tell
ChannelText // people post messages
ChannelVoice // people talk; everyone who joins may speak
ChannelThread // a thread under another channel
ChannelStage // an audience listens; invited speakers talk
ChannelAnnouncement // a text channel whose posts are broadcast to followers
ChannelForum // holds only threads, each one a post
)
The kinds are what you can act on, not a copy of any platform's channel types.
A channel a provider cannot place reports ChannelUnknown, which is also the
zero value, so a provider that does not report kinds says "don't know" rather
than something wrong.
You meet a kind in two places: Interaction.ChannelKind, the channel the
person acted in, and Args.ChannelKind, a channel option they picked.
VoiceReceiver.Join accepts ChannelVoice and ChannelStage. On a stage the
bot joins the audience, so VoiceSession.CanSend is false until someone
invites it to speak.
ChannelAnnouncement and ChannelForum arrived in chat-platform v0.22.0 with
PublicChannels,
so a scope can ask for conversation without broadcasts, and for forum posts by
their forum. Before that release a Discord announcement channel reported
ChannelText and a forum ChannelUnknown; a picker narrowed to ChannelText
now leaves announcement channels out.
ChannelThread appears only where a platform addresses a thread as a channel
of its own, as Discord does. A platform that models a thread as a reply chain
reports the parent's kind and carries the thread in Ref.ThreadID.
PromptSpec and Choice¶
type PromptSpec struct {
Content string
Choices []Choice
Ephemeral bool
Notify []ID
}
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.
Notify names the members the prompt is for. The provider addresses them in the
posted message, in the platform's own syntax, and notifies exactly them: a
mention written into Content, or echoed from a user, notifies nobody. It needs
ClientConfig.Mentions.Members;
without it the prompt is refused with ErrInvalidArgument. A platform with no
way to notify a named member refuses it with ErrUnsupported, rather than
writing a name it would have to look up.
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 {
Key string
Title string
Fields []FieldSpec
AnswersPublicly bool
}
type FieldSpec struct {
Key string
Label string
Value string
Multiline bool
Required bool
MaxLen int
}
FormSpec.Key names the form, and comes back on its submission in
Interaction.ChoiceKey, so you route a form exactly as you route a button. It is
optional. Choices and forms share that one field, so check Type before matching
on it. From chat-platform v0.30.0
(spec 0036).
A field's Key is what the submitted value comes back under, in
Interaction.Values.
Keys have a length limit, set by the platform. A provider refuses a choice,
form or field key it cannot carry with ErrInvalidArgument before sending
anything. On Discord that is 100 characters for a choice key or a field key, and
79 for a form key, counted in Unicode code points. Keep keys short if you want
them portable.
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.
AnswersPublicly declares that your answer to the submission will be public. It
defaults to false, meaning private, so leaving it unset never exposes an answer
you meant to keep private.
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
AnswersPublicly bool
}
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
ChannelKinds []ChannelKind
}
ChannelKinds narrows a channel option's picker to the kinds you list,
for example []ChannelKind{ChannelVoice, ChannelStage} for "a channel I can
join". Empty means any channel. It is a convenience and never the authority,
as with RequiredRoles: a platform that cannot narrow still registers the
command, and a picker is only a UI. So re-check Args.ChannelKind before
acting on the channel you were given.
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.
AnswersPublicly says who sees a command's answers. Leave it false and the
provider treats every answer as private until you say otherwise, so an answer
that takes a while is acknowledged privately too. Set it on a command whose
answers are for the whole channel, and the provider can acknowledge publicly,
which on Discord keeps the "user used /command" header above your answer.
It covers the command's subcommands as well.
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:
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):
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 := comms.AsVoiceReceiver(p)
if !ok {
return errNoVoice
}
session, err := rx.Join(ctx, "voice-channel-id", func(f comms.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 comms.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
NeedMessagescannot 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.
Stream: when the generator should not be on the wire's goroutine¶
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 comms.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 comms.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:
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
VoiceFrameis unattributed. A provider building one by struct literal gets an unattributed frame, which fails safe. AttributedFramewith an emptyIDyields 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¶
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 and Member.DisplayName
ReportsRoles bool // Member.Roles
ReportsAudience bool // Audience
}
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
Audience 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 and DisplayName are 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.
Audience says somebody is in a stage's audience: they can hear, and
cannot be heard until invited up. It means something only when
ReportsAudience is true, and only in a ChannelStage. It is not folded into
SpaceMuted, because a stage holding somebody in the audience and a moderator
muting them are different facts to a consumer seeking consent.
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.
Poster: posting on the bot's own initiative¶
type Poster interface {
Post(ctx context.Context, channel ID, p PostSpec) (Ref, error)
}
type PostSpec struct {
Content string
Notify []ID
}
func AsPoster(p *Provider) (Poster, bool)
Declare NeedPosting. On Discord it buys no intent. From chat-platform v0.28.0
(spec 0034).
It posts a message into a channel and returns its Ref, which
Author.Replace and Delete accept. Use it for what the bot says in its own
right, such as a greeting in a channel it created. An answer to a question
belongs in ReplyInThread, in a thread.
- It takes a bare channel id, so the allowlist bounds it. A channel outside
it is
ErrChannelDeniedbefore any request. A thread is postable only when its own id is allowed, and a channel you created only once you have added it withAllowlistEditor. - Silent unless it names members.
Notifyworks asPromptSpec.Notifydoes: it needsClientConfig.Mentions.Members, and notifies exactly the members it names. - Refused before reaching the platform, in order:
- an invalid
PostSpec(ErrEmptyContent, orErrInvalidArgumentfor a badNotifyid), then an empty or malformed channel (ErrInvalidArgument); - a channel outside the allowlist,
ErrChannelDenied; Notifyon a client withoutMembers,ErrInvalidArgument;- before
Connect,ErrNotConnected. - Content is a plain string, like everything else an
Actorsends.
Prompt takes a Ref and is not bounded by the allowlist, so a prompt can still
reach a channel a post cannot.
ChannelInspector: one channel's current name¶
type ChannelInspector interface {
Channel(ctx context.Context, id ID) (ChannelInfo, error)
}
type ChannelInfo struct {
ID ID
Name string
Kind ChannelKind // ChannelUnknown for a category
IsCategory bool
Category ID // the category it sits in; empty if none, or if it is one
}
func AsChannelInspector(p *Provider) (ChannelInspector, bool) // asserts on Reader
Declare NeedChannelDetails, which is separate from NeedSpaceDetails. On
Discord it buys no intent. From chat-platform v0.27.0
(spec 0033).
Every call reads fresh from the platform, never a cache, because the question it exists for is "is this still the name I gave it?". It costs one request a call.
Bounded by the allowlist. A malformed id is ErrInvalidArgument, then a
channel outside the allowlist is ErrChannelDenied, before any request:
- a thread is denied unless its own id is allowed;
- a channel you created is denied until you add it with
AllowlistEditor; - on a
PublicChannelsscope only the public channels it admits are answered, never a category.
Before Connect it returns ErrNotConnected. A channel the platform does not
know is ErrNotFound, and one the bot cannot see is ErrForbidden.
There is no conditional rename. Between reading a name and
ChannelManager.Rename, a person can rename the channel, and the platform
offers nothing to close that gap. It is one request's round trip wide.
SpaceInspector: the space's current name¶
type SpaceInspector interface {
Space(ctx context.Context) (SpaceInfo, error)
}
type SpaceInfo struct {
ID ID
Name string
}
func AsSpaceInspector(p *Provider) (SpaceInspector, bool) // asserts on Reader
Declare NeedSpaceDetails. On Discord it buys no intent, because the guild,
with its name, already arrives on the gateway. From chat-platform v0.23.0
(spec 0029).
On the Reader, so a read-only scope keeps it. Reading a name observes.
Name is a snapshot, the name when the call was answered. Nothing tells you
about a rename: ask again when you want to know. A portal that shows a server's
name might refresh it every few weeks.
ID repeats the scope's own space on purpose, so a SpaceInfo handed on to
a store or a log still says which space it describes.
SpaceInfo holds only what every platform can report, an identifier and a
display name. More fields arrive when a consumer needs them, and adding a field
breaks nobody.
Space returns ErrNotConnected before Connect. What else it returns is the
provider's: see the providers reference.
AllowlistEditor: changing what a live scope reads¶
type AllowlistEditor interface {
AllowChannels(ids ...ID) error
DisallowChannels(ids ...ID) error
}
func AsAllowlistEditor(p *Provider) (AllowlistEditor, bool) // asserts on Reader
Declare NeedAllowlistChanges. On Discord it buys no intent. From chat-platform
v0.24.0
(spec 0030).
It admits channels to a scope that is already serving, or removes them, without closing the scope, so a voice session on it carries on. Each event is judged against the allowlist when it is handled, so a change applies from the next one.
- One malformed id refuses the whole call with
ErrInvalidArgument, and nothing changes. Adding a channel already allowed, or removing one that isn't, is not an error. - Threads follow their parent, as they always have. Allowing a channel admits its threads.
- An empty allowlist still reads nothing.
- After
Close, both methods returnErrNotConnected. - One scope only. A sibling scope on the same client is never affected.
What removing a channel does and does not do. The allowlist governs what
the scope reads, and every call that takes a bare channel id:
VoiceReceiver.Join, VoiceParticipants.Participants, ChannelManager,
ChannelInspector, Poster.Post and Actor.ThreadHistory. After DisallowChannels, those refuse the
channel. A call that takes a Ref is never governed, so the bot can still
reply, react, prompt, delete and time out there through a Ref. A voice session already joined there carries on until you call
Leave. A prompt posted there before the removal never receives its answer,
because its answer is an event from a channel the scope no longer reads. On a scope
with WithInteractionsAnywhere, interactions still arrive from a removed
channel, so removal stops reads, not answers.
Not on a PublicChannels scope, which has no allowlist: AsAllowlistEditor
answers false there. A provider's Reader reports the mode with
ReadsPublicChannels() bool. The Scope value a scope was minted from keeps
the mint-time allowlist, and doesn't follow later changes.
ChannelManager and RoleManager: setting up a space¶
type ChannelManager interface {
CreateCategory(ctx context.Context, name string, v Visibility) (ID, error)
CreateChannel(ctx context.Context, spec ChannelSpec) (CreatedChannel, error)
ShowChannel(ctx context.Context, channel ID, to Visibility) error
HideChannel(ctx context.Context, channel ID, from Visibility) error
Rename(ctx context.Context, channel ID, name string) error
Move(ctx context.Context, channel, category ID) error
}
type RoleManager interface {
CreateRole(ctx context.Context, name string) (ID, error)
GrantRole(ctx context.Context, member, role ID) error
RevokeRole(ctx context.Context, member, role ID) error
}
type Visibility struct {
Members []ID
Roles []ID
}
type ChannelSpec struct {
Name string
Kind ChannelKind // ChannelText, ChannelVoice or ChannelStage
Category ID // optional
Visibility Visibility
}
type CreatedChannel struct {
ID ID
Kind ChannelKind
}
Declare NeedChannelManagement and NeedRoleManagement respectively; on
Discord neither buys an intent, but the bot needs Manage Channels and Manage
Roles when it is authorised. From chat-platform v0.26.0
(spec 0032).
Create and grant, never delete. Nothing here removes a channel or a role.
Visibilitynames who can take part. A non-empty one makes the channel private: hidden from everyone else, and open to the named members and roles to read and post, or to join and speak. An empty one gives the channel no restriction of its own, so inside a category it follows the category, and outside one everyone can see it. The bot keeps its own access to every private channel it creates.ShowChannelandHideChannelchange one channel, name by name.HideChanneltakes away whatShowChannelgave. It does not hide a channel that everyone can see.Movekeeps a channel's own visibility. Moving out of every category is not offered.- The allowlist bounds what is managed.
ShowChannel,HideChannel,Rename,Moveand aChannelSpec.Categoryare refused withErrChannelDeniedoutside the scope's allowlist. A created channel is not allowlisted: add it withAllowlistEditor, then manage it. - Neither is offered on a read-only or
PublicChannelsscope. - A refusal names what the call needed.
ErrForbiddensays which permissions the operation needed, because the platform does not say which one was missing.
RoleManager is privilege escalation in one call. A role carries whatever
it allows, and roles belong to the whole space, so no allowlist bounds them. Like
Moderator, it must not be reachable from anything a person typed or a model
produced. A created role has no permissions and cannot be mentioned; it exists
to be named in a Visibility.
Floor: who speaks on a stage¶
type Floor interface {
InviteToSpeak(ctx context.Context, stage, member ID) error
ReturnToAudience(ctx context.Context, stage, member ID) error
}
func AsFloor(p *Provider) (Floor, bool)
Declare NeedStageFloor. From chat-platform v0.29.0
(spec 0035).
A stage is created with ChannelManager.CreateChannel and Kind:
ChannelStage, and its Visibility is the audience: who can see it and listen.
Who speaks is not set on the channel, because no permission makes a member
a speaker. It is given one member at a time:
InviteToSpeakinvites a member who is in the stage. They accept on their platform, once each time they join, and until then they are still audience. Nobody is put on air without agreeing, and nobody can put themselves there.ReturnToAudiencetakes the floor back, or withdraws an invitation.- For "the table can speak", invite each table member as they join. Joins
arrive through
VoiceParticipants. - The stage is bounded by the allowlist, like every call taking a bare
channel id. An empty id is
ErrInvalidArgument, and a stage outside the allowlistErrChannelDenied, before reaching the platform. Neither call is offered on a read-only orPublicChannelsscope.
Who speaks is not only who you invited. A person with moderation rights can invite others through the platform. A consumer that records by consent filters by member, not by who is speaking.
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 |
PostSpec |
Content is empty |
ErrEmptyContent |
PostSpec |
a Notify id is empty, or named twice |
ErrInvalidArgument |
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 |
PromptSpec |
a Notify id is empty, or named twice |
ErrInvalidArgument |
ChannelSpec |
Name is empty, or Kind is not ChannelText, ChannelVoice or ChannelStage |
ErrInvalidArgument |
Visibility |
an id is empty or named twice, or more than 100 are named | ErrInvalidArgument |
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 |
an option sets ChannelKinds but is not OptionChannel |
ErrInvalidArgument |
CommandSpec |
an option's ChannelKinds includes ChannelUnknown |
ErrInvalidArgument |
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 stop at the first failure, so a spec with two problems reports one; the
order is per option rather than the table's, so an unknown Type is reported
before a misplaced required option on the same spec. 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:
What Validate does not check¶
FieldSpec.Labelmay be empty.Choice.Labelmay 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
Validateand 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,MaxLenorMultiline. - Whether a provider can carry an option's
Type; askCommands.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.