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:
- 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.
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:
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 := 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
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 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:
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
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:
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.