Documentation
¶
Overview ¶
Package chatplatform is a platform-agnostic contract for chat bots.
It defines what a bot needs from a chat platform — receive messages, reply in a thread, react, and optionally moderate or join a voice channel — without naming any particular platform. Providers live in their own modules and register themselves at init(), so this package carries no vendor SDK and no HTTP stack.
Reading and acting are separate ¶
Reader observes. Actor changes what people see. They are separate interfaces because that makes an observe-only deployment structural: mint a scope with WithReadOnly and Provider.Actor is nil, so the process cannot post regardless of the logic above it. It is per scope, so one tenant may observe while another acts on the same connection.
Capabilities are optional ¶
Moderator, MemberInspector, Interactive, Commands, VoiceReceiver and VoiceSender are discovered by type assertion. A provider implementing none of them is legitimate — a read-only bridge, or a platform with no equivalent concept.
Voice is two interfaces rather than one so a consumer can take the half it needs: a bot that records and never speaks, and one that speaks and ignores what it hears, are both legitimate, and which you are building is not something this contract knows.
A client is a transport, a provider is a scope ¶
A "space" is a guild, workspace or network. Client owns a connection and mints one Provider per space on it, so serving four tenants costs one transport rather than four — which is what a platform carrying many spaces over a single socket expects.
Fixing the space on the provider keeps the concept out of every method signature, and out of a contract that would otherwise have to pick one platform's word for it. What a caller declares to the client instead is Need: the capabilities it intends to use, so a provider asks the platform for those and nothing more.
Index ¶
- Variables
- func Register(name string, f Factory) error
- func Registered() []string
- func Unregister(name string)
- type Actor
- type Arg
- type Args
- func (a Args) Bool(name string) (bool, bool)
- func (a Args) Channel(name string) (ID, bool)
- func (a Args) Int(name string) (int64, bool)
- func (a Args) Number(name string) (float64, bool)
- func (a Args) Role(name string) (ID, bool)
- func (a Args) String(name string) (string, bool)
- func (a Args) User(name string) (ID, bool)
- type Author
- type Choice
- type ChoiceStyle
- type Client
- type ClientConfig
- type Codec
- type CommandGroup
- type CommandOption
- type CommandSpec
- type Commands
- type Configdeprecated
- type ConnState
- type Factory
- type FieldSpec
- type FormSpec
- type ID
- type Indicator
- type IndicatorLimits
- type Interaction
- type InteractionType
- type Interactive
- type Member
- type MemberInspector
- type Message
- type Moderator
- type Need
- type OptionType
- type PromptSpec
- type Provider
- type ProviderOption
- type Reaction
- func (r Reaction) Change() ReactionChange
- func (r Reaction) Emoji() string
- func (r Reaction) Member() (Member, bool)
- func (r Reaction) ParentID() ID
- func (r Reaction) Ref() Ref
- func (r Reaction) UserID() ID
- func (r Reaction) Variant() string
- func (r Reaction) WithMember(m Member) Reaction
- func (r Reaction) WithVariant(v string) Reaction
- type ReactionChange
- type ReactionObserver
- type Reader
- type Ref
- type ResponseToken
- type Scope
- type Status
- type Subcommand
- type VoiceCapabilities
- type VoiceFormat
- type VoiceFrame
- type VoiceInterruption
- type VoiceParticipant
- type VoiceParticipantCapabilities
- type VoiceParticipants
- type VoiceReceiver
- type VoiceSender
- type VoiceSession
- type VoiceSink
- type VoiceStats
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotConnected is returned by a capability whose Reader has no live // session — usually an Actor's, and also a read-side one such as // VoiceParticipants.Participants. ErrNotConnected = errors.NewSentinel("chat-platform.not_connected", "chatplatform: not connected") // ErrUnsupported is returned when a provider or transport can never honour // a request: the platform has no equivalent, or the transport does not // reach the part of the platform that does. Permanent, so it takes // precedence over ErrNotConnected, which is "not yet". // // Prefer omitting an optional capability over implementing it to return // this. The base surface — Reader.Messages and the three Actor methods — // cannot be omitted, which is why a transport that carries audio only // answers it from each of the three and refuses NeedMessages at // construction; see ClientConfig.Needs. ErrUnsupported = errors.NewSentinel("chat-platform.unsupported", "chatplatform: capability not supported by this provider") // ErrForbidden reports that the bot's credential IS valid and lacks the // permission the platform requires for this request. A bot added to a space // without the right to speak, or to delete, or to time somebody out. // // Distinct from ErrUnsupported, and the distinction is the caller's whole // response. Unsupported is structural and permanent: the platform has no // such concept, so try something else. Forbidden is a grant, mutable, and // usually fixable by a person — so the useful response is to say so, to // somebody who can change it, rather than to give up. // // Also distinct from ErrChannelDenied, which is THIS module's allowlist // refusing rather than the platform's permissions. ErrForbidden = errors.NewSentinel("chat-platform.forbidden", "chatplatform: the bot lacks permission for this request") // ErrChannelDenied is returned when a caller names a channel outside the // allowlist — as a Ref to most methods, or as a bare channel id to // VoiceReceiver.Join. Providers enforce the allowlist themselves; a caller // must not be able to reach past it by naming a channel directly. ErrChannelDenied = errors.NewSentinel("chat-platform.channel_denied", "chatplatform: channel not in allowlist") // ErrNotFound is returned when a referenced message, thread or member does // not exist. Distinct from a transport failure: retrying will not help. ErrNotFound = errors.NewSentinel("chat-platform.not_found", "chatplatform: not found") // ErrAlreadyRegistered is returned by Register when a factory already // exists under that name. ErrAlreadyRegistered = errors.NewSentinel("chat-platform.already_registered", "chatplatform: a provider is already registered under this name") // ErrAlreadyScoped is returned by Client.Provider when the space already // has a live scope on that client. // // Distinct from ErrInvalidArgument, and the distinction is what the caller // must do. ErrInvalidArgument means retrying with the same value cannot // help; this one means it can, because closing the scope releases the // space. Nothing is wrong with the argument and nothing needs editing — the // caller already holds a scope for this space and should use it, or close // it first. // // Distinct from ErrAlreadyRegistered, which is the same shape at a // different lifetime: a registry collision is a permanent wiring bug fixed // in code, where this is ordinary runtime state. // // A provider MUST name the space in the message. The sentinel says which // class; only the message says which space. ErrAlreadyScoped = errors.NewSentinel("chat-platform.already_scoped", "chatplatform: this space already has a live scope on this client") // ErrInvalidName is returned by Register for an empty provider name. ErrInvalidName = errors.NewSentinel("chat-platform.invalid_name", "chatplatform: provider name must not be empty") // ErrNilFactory is returned by Register for a nil factory. ErrNilFactory = errors.NewSentinel("chat-platform.nil_factory", "chatplatform: factory must not be nil") // ErrInvalidArgument reports that an argument was malformed and the caller // must fix its own code. The method and the message say which argument; the // sentinel says only that retrying with the same value cannot help. // // Deliberately contract-wide rather than scoped to one area, because // per-area naming is what produced a sentinel per situation elsewhere in // this file. See chat-platform#8 for the validation sentinels above, which // could fold into this and are deliberately not being changed today. ErrInvalidArgument = errors.NewSentinel("chat-platform.invalid_argument", "chatplatform: invalid argument") // Prompt and form validation. Ambiguous keys are the failure these guard: // routing an interaction by a duplicate key means taking the wrong action. ErrEmptyContent = errors.NewSentinel("chat-platform.empty_content", "chatplatform: prompt content must not be empty") ErrNoChoices = errors.NewSentinel("chat-platform.no_choices", "chatplatform: prompt must offer at least one choice") ErrEmptyChoiceKey = errors.NewSentinel("chat-platform.empty_choice_key", "chatplatform: choice key must not be empty") ErrEmptyChoiceLabel = errors.NewSentinel("chat-platform.empty_choice_label", "chatplatform: choice label must not be empty") ErrDuplicateChoiceKey = errors.NewSentinel("chat-platform.duplicate_choice_key", "chatplatform: choice keys must be unique") ErrEmptyTitle = errors.NewSentinel("chat-platform.empty_title", "chatplatform: form title must not be empty") ErrNoFields = errors.NewSentinel("chat-platform.no_fields", "chatplatform: form must have at least one field") ErrEmptyFieldKey = errors.NewSentinel("chat-platform.empty_field_key", "chatplatform: field key must not be empty") ErrDuplicateFieldKey = errors.NewSentinel("chat-platform.duplicate_field_key", "chatplatform: field keys must be unique") // ErrNoVoiceSession is returned when the provider is not in a voice channel // and the request needs it to be. Join first. ErrNoVoiceSession = errors.NewSentinel("chat-platform.no_voice_session", "chatplatform: not in a voice channel") // ErrVoiceBusy is returned when the request conflicts with something already // running. Stop that first, or do not ask. // // From Join it means a voice session is already open: a provider holds one, // and a second Join is refused rather than silently moving, because a move // tears down the DAVE epoch and the SSRC mapping mid-recording and a caller // that joined twice by accident would get a truncated recording and no error // at all. // // From Send and Stream it means a Stream is already running. That guards the // one audio timeline against two writers: interleaved frames from Send and // Stream are not a degraded utterance, they are two utterances chopped // together, and neither speaker said it. ErrVoiceBusy = errors.NewSentinel("chat-platform.voice_busy", "chatplatform: a voice operation is already in progress") // Command validation. ErrEmptyCommandName = errors.NewSentinel("chat-platform.empty_command_name", "chatplatform: command name must not be empty") ErrEmptyDescription = errors.NewSentinel("chat-platform.empty_description", "chatplatform: command description must not be empty") ErrEmptyOptionName = errors.NewSentinel("chat-platform.empty_option_name", "chatplatform: option name must not be empty") ErrDuplicateOptionName = errors.NewSentinel("chat-platform.duplicate_option_name", "chatplatform: option names must be unique") // The rules below refuse a command no platform will register. Each one was // previously discovered as a 400 from the platform, after connecting, on a // live gateway — which reads as a configuration fault rather than as the // caller's own spec being wrong. ErrInvalidCommandName = errors.NewSentinel("chat-platform.invalid_command_name", "chatplatform: command name must be 1-32 runes of lowercase letters, digits, hyphens or underscores") ErrInvalidDescription = errors.NewSentinel("chat-platform.invalid_description", "chatplatform: description must be 1-100 runes") ErrTooManyOptions = errors.NewSentinel("chat-platform.too_many_options", "chatplatform: at most 25 options, subcommands or groups at one level") ErrOptionsWithSubcommands = errors.NewSentinel("chat-platform.options_with_subcommands", "chatplatform: a command has options or subcommands, never both") ErrEmptyGroup = errors.NewSentinel("chat-platform.empty_group", "chatplatform: a subcommand group must contain at least one subcommand") ErrOptionOrder = errors.NewSentinel("chat-platform.option_order", "chatplatform: required options must precede optional ones") ErrUnknownOptionType = errors.NewSentinel("chat-platform.unknown_option_type", "chatplatform: option type is not one this contract defines") ErrCommandTooLarge = errors.NewSentinel("chat-platform.command_too_large", "chatplatform: names, descriptions and choices exceed 8000 runes in total") )
Sentinels use the standard library rather than github.com/cockroachdb/errors, which the wider toolkit prefers. This module asserts a zero third-party dependency graph (see depfootprint_test.go) — a property worth more here than consistency of error library, because it is what lets a consumer accept a Reader without inheriting anything. Providers are free to use richer errors; they wrap these.
Wrapping means ADDING to the error chain, never replacing what the platform said. A provider MUST return an error for which errors.Is answers the sentinel below AND which leaves the platform's own error reachable with errors.As. The sentinel says how a caller must respond; the platform's error says what was actually refused, and a consumer telling somebody to go and fix a permission needs both — "the bot lacks permission for this request" does not name the permission, and only the platform's error does.
Functions ¶
func Register ¶
Register associates a name with a Factory. Safe to call concurrently.
It returns ErrAlreadyRegistered rather than overwriting. Silently replacing would let a blank-imported provider displace another with no diagnostic and initialisation order deciding the winner — so the failure would surface later, as the wrong provider being used, a long way from its cause.
Returning an error rather than panicking leaves the decision with the caller. A provider registering from init() has nothing sensible to do with a failure and should panic at its own call site, where the panic names the module at fault:
func init() {
if err := chatplatform.Register("discord", New); err != nil {
panic("chat-platform-discord: " + err.Error())
}
}
func Registered ¶
func Registered() []string
Registered lists known provider names, sorted, so callers rendering them — a help string, an error listing valid values — get a stable order.
func Unregister ¶
func Unregister(name string)
Unregister removes a factory. It exists for tests, which must be able to leave the registry as they found it; production code registers once from init() and never removes.
Types ¶
type Actor ¶
type Actor interface {
// ReplyInThread posts content in a thread on the referenced message,
// creating the thread if Ref.ThreadID is empty.
//
// Returns a Ref to the MESSAGE that was posted, carrying the channel, the
// message and the thread it landed in. That is what lets a caller act on
// what it just said — react to it, replace it, retract it — and it is why
// this does not return a bare id.
//
// It used to return the thread's id, and that was a trap rather than a
// limitation: a reaction arrives against a message, so a caller storing the
// thread id as a join key got something that looked wired up and silently
// never matched.
ReplyInThread(ctx context.Context, to Ref, threadName, content string) (Ref, error)
// React adds a reaction to the referenced message.
React(ctx context.Context, to Ref, emoji string) error
// ThreadHistory returns up to limit messages from a thread, oldest first.
// Used to carry conversation context somewhere it can be read by people who
// were never in the thread.
ThreadHistory(ctx context.Context, threadID ID, limit int) ([]Message, error)
}
Actor changes what people see. Everything here is observable by somebody.
type Arg ¶ added in v0.12.0
type Arg struct {
// contains filtered or unexported fields
}
Arg is one delivered command argument.
Opaque, and built only by the constructors below, so a provider has to say which kind it delivered. That is the same reasoning as VoiceFrame's unexported fields: a value that could be assembled by hand could be assembled wrongly, and the wrong assembly here is the one that makes a resolved identifier indistinguishable from text somebody typed.
func ChannelArg ¶ added in v0.12.0
ChannelArg carries a channel the PLATFORM resolved, not a name somebody typed. That distinction is the whole reason option types exist.
func NumberArg ¶ added in v0.12.0
NumberArg carries a fractional number the platform parsed and validated.
type Args ¶ added in v0.12.0
type Args struct {
// contains filtered or unexported fields
}
Args is what a command was invoked with.
Every accessor answers (value, ok), and ok is false both when the argument is absent and when it arrived as a different kind. There is no accessor that answers for everything, because a single answer for "resolved a channel" and "could not tell" is the shape this contract has already been caught shipping once.
func NewArgs ¶ added in v0.12.0
NewArgs collects delivered arguments. The last entry wins for a repeated name: CommandSpec.Validate refuses duplicate option names, so a repeat here is a provider bug, and overwriting beats panicking on a gateway read loop.
The map is built here rather than borrowed, so a caller mutating its own slice afterwards cannot change what a consumer reads.
func (Args) String ¶ added in v0.12.0
String returns a text argument.
It answers false for a resolved channel, user or role: those are identifiers the platform resolved, not text, and handing back their id as though somebody had typed it would recreate the ambiguity option types exist to remove. Ask Channel, User or Role for those.
type Author ¶ added in v0.9.0
type Author interface {
// Replace changes what a posted message says.
//
// Wholesale rather than a patch: content and choices are both replaced by
// what is passed, and passing no choices leaves none. That is why it is not
// called Edit.
//
// Providers MUST NOT notify the audience again. A consumer replacing rather
// than reposting is almost always doing it because the reader has been asked
// to look once already, and a provider that achieves the effect by deleting
// and reposting has not implemented this and should not claim it.
//
// The Ref does not change. What was posted is still the same message.
Replace(ctx context.Context, ref Ref, content string, choices []Choice) error
// Delete takes back a message this consumer posted.
//
// The same word as [Moderator.DeleteMessage] deliberately: the operation is
// the same and only the target differs, which the capability already says. A
// different verb here would invite a reader to infer a difference in effect —
// softer, reversible, leaving a tombstone — that no provider implements.
//
// Takes no reason, where Moderator.DeleteMessage does. A reason exists for
// the platform's audit log, and tidying up after yourself is not an audited
// action; requiring one would carry the moderation framing back in through
// the signature.
Delete(ctx context.Context, ref Ref) error
}
Author is what a consumer may do to messages IT posted: replace what they say, or take them back.
NOT to be confused with Message.Author, which is a Member — the person who wrote a message. This is the capability through which a consumer acts on its own, and the collision is worth flagging here because a reader arriving from that field is carrying the other meaning.
The counterpart to Moderator, which acts on other people's messages. That parallel is the boundary: whose message it is decides which capability applies, and nothing else does.
Separate from Moderator for a reason that costs consumers something today. Deleting one's own message currently means declaring NeedModeration — a moderation capability, to tidy up after yourself — and that cost is imposed here rather than by any platform. A consumer that only manages what it said should be able to declare exactly that, and a reviewer reading its Needs should learn something true about it.
Optional because platforms differ on whether a bot may change what it said. A provider that cannot implements nothing, and AsAuthor answers false — the same answer a caller gets for any capability a platform lacks.
type Choice ¶
type Choice struct {
// Key identifies the choice when it comes back. It is matched exactly, so
// it must be stable across restarts — an interaction can arrive long after
// the prompt was posted.
Key string
// Label is what the person reads.
Label string
Style ChoiceStyle
}
Choice is one labelled option on a prompt.
type ChoiceStyle ¶
type ChoiceStyle int
ChoiceStyle hints at how prominent or dangerous a choice is. It is a hint: a platform without styling ignores it, and no behaviour may depend on it.
const ( // StyleDefault is an ordinary choice. StyleDefault ChoiceStyle = iota // StylePrimary is the choice a person most likely wants. StylePrimary // StyleDanger marks a destructive choice — deleting, banning. StyleDanger )
func (ChoiceStyle) String ¶
func (s ChoiceStyle) String() string
String implements fmt.Stringer. Unknown values render as default, since a style is advisory and an unrecognised one must not break rendering.
type Client ¶ added in v0.6.0
type Client interface {
// Provider returns a Provider serving one space over this Client's
// transport.
//
// Providers MUST NOT let one scope observe another's messages,
// interactions or audio, whatever the transport carries underneath.
//
// A space has at most one live scope per Client. Asked for a second while
// the first is live, a provider MUST refuse: return a nil *Provider and an
// error matching ErrAlreadyScoped, naming the space, and leave the existing
// scope untouched. Replacing it silently is the failure this rule exists to
// prevent — the displaced consumer keeps a Provider that looks healthy and
// never receives again.
//
// Closing a scope releases its space, including a scope that never
// connected, so mint-close-mint on one space is a supported pattern rather
// than a lock held for the life of the transport.
//
// Provider MUST be safe to call from several goroutines at once, and the
// refusal above MUST hold under that. Checking whether a space is taken and
// recording that it is are one operation: doing them under separate locks
// lets two callers both pass the check, and no amount of sequential testing
// finds it. Nor does the race detector, since every access is still
// synchronised.
//
// A consumer needing two things to read one space composes that itself from
// the single feed; see Reader.Messages.
Provider(ctx context.Context, space ID, opts ...ProviderOption) (*Provider, error)
// Close closes the transport. It must be safe to call more than once.
Close() error
}
Client owns a connection to a platform and mints scopes on it.
One Client is one transport. Closing it closes the transport and everything over it; closing a Provider closes a scope and must not disturb its siblings.
func NewClient ¶ added in v0.6.0
NewClient looks up a platform by name and builds a transport — the one-call path for a consumer that has a name from configuration.
It does NOT connect. Nothing here reaches the network, which is why ctx is unused: it is accepted so a future registry lookup can be cancellable, and so callers do not have to change shape when one is. Dialling happens on Reader.Connect, and that is where a refusal arrives.
So this returns ErrNotFound when nothing is registered under name, and whatever the provider's own constructor returns for a configuration it cannot accept. It does not return ErrForbidden — a platform cannot refuse a Need before anybody has asked it for one. Check for that at Connect.
type ClientConfig ¶ added in v0.6.0
type ClientConfig struct {
// Token authenticates the bot.
//
// A constructor MUST accept an empty one and leave its validity to Connect,
// which is what lets the conformance harness build a client without a
// credential; it sends none. A transport that does not itself authenticate
// to the platform has no use for it at all.
Token string
// Needs are the capabilities the consumer intends to use.
//
// A provider MUST NOT request platform privileges beyond these. That is what
// turns least privilege from a convention somebody remembers into a property
// of the wiring, and it is why an empty Needs means "nothing declared" rather
// than "everything".
//
// A declared need the platform refuses is ErrForbidden from Connect, not
// from NewClient: the credential is valid, a grant is missing, and a person
// can fix it. It surfaces at Connect because that is where the contract
// first reaches the platform — NewClient validates configuration and asks
// nothing of anybody, so it cannot yet know what was refused.
//
// A need a transport can NEVER serve is different: nobody can grant it, so
// it is refused at construction with ErrUnsupported naming the need, and
// no Client is built. Of the needs defined here only NeedMessages is
// refused that way, by a transport that carries audio only; every other
// one gates an optional capability, which such a transport omits and the
// As... helper reports as absent.
Needs []Need
}
ClientConfig is what a transport is constructed from.
Everything here belongs to the CONNECTION. What belongs to a space is a ProviderOption instead, because two tenants on one transport may legitimately allow different channels, and one may be observe-only while the other is not.
func (ClientConfig) Needed ¶ added in v0.6.0
func (c ClientConfig) Needed(n Need) bool
Needed reports whether n was declared.
Providers use it to decide what to ask the platform for. A zero ClientConfig answers false to everything, which is the safe direction: a provider that cannot tell what is wanted must not assume everything is.
type Codec ¶ added in v0.5.0
type Codec string
Codec names how a frame's payload is encoded.
A string rather than an enumerated constant, for the reason Register takes a string: a platform this module has never heard of must be able to ship a provider without anything being contributed here. A codec nobody anticipated names itself and a consumer that does not recognise it declines, which is a better failure than a provider being unable to describe what it has.
Use the constants below where they fit. Where they do not, prefer the name the platform itself uses.
const ( // CodecOpus is Opus, as Discord delivers it. CodecOpus Codec = "opus" // CodecPCM16 is signed 16-bit little-endian PCM, interleaved across // channels. Google Meet and Microsoft Teams both hand a bot this, having // decoded whatever was on the wire before the bot sees it. // // The endianness and interleaving are part of the name rather than left to // a reader, because a byte slice that is silently the other way round is a // bug nobody hears until it is played. CodecPCM16 Codec = "pcm_s16le" )
type CommandGroup ¶ added in v0.12.0
type CommandGroup struct {
Name string
Description string
Subcommands []Subcommand
}
CommandGroup gathers subcommands under a shared noun.
It carries subcommands and never options of its own — a group is not invokable, only the subcommands beneath it are.
type CommandOption ¶
type CommandOption struct {
Name string
Description string
// Type is what the argument means. The zero value is OptionString, so an
// option written before this field existed keeps its behaviour.
//
// Whether a platform can carry a given type is answerable before
// registering, through Commands.SupportsOption. A provider does NOT
// silently substitute text for a type it cannot carry: it refuses the
// registration, and the caller substitutes OptionString itself if that is
// what it wants. A number that means "resolved" and a number that means
// "the user typed something" must not arrive looking alike.
Type OptionType
// Required options must be listed before optional ones. Discord refuses the
// registration otherwise, and Validate refuses it first.
Required bool
}
CommandOption is one argument to a command.
type CommandSpec ¶
type CommandSpec struct {
Name string
Description string
Options []CommandOption
// Subcommands and Groups share one namespace: on the wire both are options
// of this command, so a group and a subcommand cannot share a name.
Subcommands []Subcommand
Groups []CommandGroup
// RequiredRoles restricts who may invoke the command, where the platform
// can enforce it. A provider that cannot MUST still deliver the
// interaction — the caller re-checks Interaction.By.Roles regardless,
// because platform-side gating is a convenience and never the authority.
RequiredRoles []ID
}
CommandSpec declares a command.
A command carries EITHER options or a nested tree, never both: declaring subcommands makes the base command uninvokable, so a command with options beside them registers cleanly and then does nothing. Subcommands and groups may be mixed freely with each other — "/scout join" and "/scout table new" are both reachable from one command.
func (CommandSpec) Validate ¶
func (c CommandSpec) Validate() error
Validate reports whether the command can be registered and its arguments read back unambiguously.
Every rule here refuses something a platform would refuse on the wire, after connecting, as a provider-shaped 400. The whole point is that a caller learns it from its own spec instead.
type Commands ¶
type Commands interface {
// RegisterCommands declares the complete set, replacing whatever was
// registered before.
//
// Declarative rather than incremental: it is idempotent, safe to run on
// every start, and leaves no way for the registered set to drift from the
// declared one. Partial updates are impossible by design.
RegisterCommands(ctx context.Context, cmds []CommandSpec) error
// SupportsOption reports whether this platform can carry an argument of
// this type. Static: it answers without connecting, and does not change
// between calls.
//
// It exists so a caller learns BEFORE registering that its channel picker
// would have to be a text box, rather than learning it from a user. Four
// promises hold, and the value of the pair depends on all of them:
//
// - OptionString is always supported. A platform that could not carry
// text could not carry a command.
// - An OptionType this contract does not define is never supported.
// - Where this answers true, an argument declared that way is delivered
// through the matching Args accessor whenever it is present. A provider
// does not substitute a different kind.
// - Where it answers false, RegisterCommands REFUSES the spec, wrapping
// ErrUnsupported. It does not quietly register a text box. A caller
// that wants text says so by declaring OptionString.
//
// It says nothing about presence: an accessor answering false for an
// omitted optional argument is ordinary, and always will be.
SupportsOption(OptionType) bool
}
Commands registers the commands a platform offers its users.
func AsCommands ¶
AsCommands returns the provider's Commands, if it has one.
type Config
deprecated
type Config struct {
// Token authenticates the bot.
Token string
// Space is the single guild, workspace or network this provider serves,
// fixed here so no method has to take it — and so the contract never has to
// choose one platform's word for the concept.
Space ID
// AllowedChannels is the exhaustive set of channels a Reader may emit from.
// Empty means none. Providers enforce this themselves; a consumer must not
// be able to reach past it.
AllowedChannels []ID
// ReadOnly asks for an observing provider. A provider MUST return a
// Provider with a nil Actor when this is set, rather than an Actor that
// refuses at call time — the point is that there is nothing to misuse.
ReadOnly bool
// Needs are the capabilities the consumer intends to use, as [ClientConfig].
//
// An EMPTY Needs on this path means the provider's own historic default,
// preserving behaviour from before [Client] existed. That is the blind
// choice [ClientConfig.Needs] exists to prevent, kept alive for exactly one
// release so this deprecated path keeps compiling, and removed with the rest
// of this type.
Needs []Need
}
Config is what the deprecated New is constructed from.
Deprecated: use ClientConfig with NewClient, and pass what belongs to a scope as a ProviderOption. This type mixes the two, which is what made a consumer serving several spaces dial a transport for each. It is removed one minor release after the one that introduced Client.
There are no provider-specific fields. Anything one platform needs and another does not belongs in that provider's own options, or the contract starts carrying one vendor's vocabulary.
func (Config) ChannelAllowed
deprecated
ChannelAllowed reports whether a channel is in the allowlist.
Deprecated: use Scope.ChannelAllowed, which carries the reasoning including the thread rule a provider must implement.
type ConnState ¶
type ConnState struct {
Status Status
// LastReconnectLostEvents reports whether the most recent reconnect started
// a fresh session rather than resuming the previous one.
//
// A fresh session means every event buffered during the gap was DROPPED. For
// a bot that answers questions, those are questions nobody will be answered
// — a failure with no error, no crash and no trace. Nothing else surfaces
// it, so the contract does.
LastReconnectLostEvents bool
// Since is when the current Status began.
Since time.Time
}
ConnState describes a Reader's connection, so a health check can distinguish "the process is alive" from "the platform is reachable".
type Factory ¶
type Factory func(ClientConfig) (Client, error)
Factory builds a Client from a ClientConfig.
A platform's name resolves to a TRANSPORT rather than to a scope, because the transport is the expensive thing and the thing a platform actually owns. Scopes come from Client.Provider.
type FieldSpec ¶
type FieldSpec struct {
// Key identifies the value when the form comes back.
Key string
Label string
// Value prefills the field. This 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.
Value string
Multiline bool
Required bool
// MaxLen is an optional character limit. Zero means the platform's default.
MaxLen int
}
FieldSpec is one text input on a form.
type ID ¶
type ID string
ID is an opaque platform identifier — a channel, message, thread, member or role.
It is a string because every platform's identifiers are string-representable, and a distinct type because a signature should say which of its strings are identifiers. Providers using typed identifiers convert at one boundary rather than at every call site.
type Indicator ¶ added in v0.14.0
type Indicator interface {
// Show sets the bot's displayed name in the space to text, REPLACING
// whatever was there, including a name a space administrator set. It does
// not restore that name later; the cost is accepted so that a fresh
// process with no memory of the previous name can always Show and Clear
// correctly.
//
// Effect-idempotent: repeating it with the same text leaves the same
// name. NOT free: each call is a platform request that consumes rate-limit
// quota and can block on ctx.
//
// Wraps ErrInvalidArgument for empty text and for text longer than
// IndicatorLimits().MaxLength, before the connection is consulted;
// ErrNotConnected before Connect; ErrForbidden when the bot lacks the
// platform's grant to change its own name, which is fixable and therefore
// a different answer from unsupported.
Show(ctx context.Context, text string) error
// Clear removes the bot's space-specific name entirely, so the platform
// shows its account name. It needs no knowledge of what Show set and
// succeeds when there was nothing to clear, so a consumer asserts its
// state on startup by calling whichever of Show or Clear is true and
// never infers its own state from a name it can see — a marker it did not
// set is evidence of a previous instance, not of a working one.
//
// Same errors and the same cost as Show.
Clear(ctx context.Context) error
// IndicatorLimits reports what this platform will accept. Static: it
// answers before Connect and does not change between calls, so a caller
// trims to a declared number rather than one guessed from a platform's
// documentation.
IndicatorLimits() IndicatorLimits
}
Indicator puts the consumer's own words on the name people in the space see for the bot, so a bot capturing audio need not look identical to one sitting idle.
The words are the consumer's because it is the only party that can supply them truthfully. This contract captures audio and delivers frames to a sink; whether they are transcribed, stored, kept for some speakers and not others, or discarded is decided by the consumer and invisible to the provider. A marker the contract labelled "recording" would assert something it cannot know. Capture itself is already visible: it runs from Join returning a session until that session's Done closes, and the platform shows the bot in the channel for that interval.
Whether to show anything, what it says, and when, is the consumer's policy. This interface takes no position, and spec 0020 is where that was decided.
func AsIndicator ¶ added in v0.14.0
AsIndicator returns the provider's Indicator, if it has one.
Answers false when NeedIndicator was not declared, when the platform gives a bot no per-space name, and on a read-only scope, which has no Actor.
type IndicatorLimits ¶ added in v0.14.0
type IndicatorLimits struct {
// MaxLength is the longest text Show accepts, counted in runes.
MaxLength int
}
IndicatorLimits is what a platform will accept from Indicator.Show.
type Interaction ¶
type Interaction struct {
Type InteractionType
Token ResponseToken
// Ref locates what was acted upon.
Ref Ref
// By is who acted. Authorisation is decided from By.Roles — never from a
// claim in message content, and never from By.Name.
By Member
// ChoiceKey is set when Type is ChoiceSelected.
ChoiceKey string
// Values holds submitted fields when Type is FormSubmitted.
Values map[string]string
// Command, Path and Args are set when Type is CommandInvoked.
//
// Command is the top-level name. Path is the verbs beneath it, empty for a
// flat command — so a consumer reads which subcommand ran rather than
// parsing it back out of Command.
Command string
Path []string
Args Args
}
Interaction is a person acting on a prompt, form or command.
func (Interaction) Value ¶
func (i Interaction) Value(key string) string
Value returns a submitted form field, or empty if absent. Safe on a zero Interaction: an interaction carrying no values is ordinary, not exceptional.
type InteractionType ¶
type InteractionType int
InteractionType distinguishes what a person did.
const ( // ChoiceSelected means a choice on a prompt was picked. ChoiceSelected InteractionType = iota // FormSubmitted means a form was filled in and submitted. FormSubmitted // CommandInvoked means a registered command was run. CommandInvoked )
func (InteractionType) String ¶
func (t InteractionType) String() string
String implements fmt.Stringer.
type Interactive ¶
type Interactive interface {
// Prompt posts a message offering choices.
//
// Returns a Ref to the message it posted, so a caller can act on it later —
// replacing a card as answers arrive, or retracting one that has been
// superseded. A bare id was not enough: it carried no channel, so nothing
// could be done with it.
Prompt(ctx context.Context, to Ref, p PromptSpec) (Ref, error)
// OpenForm opens a form in response to an interaction. Most platforms only
// permit this as a direct response, which is why it takes a token rather
// than a Ref.
OpenForm(ctx context.Context, tok ResponseToken, f FormSpec) error
// Respond answers an interaction.
Respond(ctx context.Context, tok ResponseToken, content string, ephemeral bool) error
// UpdateSource replaces the message an interaction came from — a card
// showing who actioned it, with the choices removed.
//
// Without this, buttons stay live after they have been used and a second
// person actions the same thing. Passing nil choices removes them.
UpdateSource(ctx context.Context, tok ResponseToken, content string, choices []Choice) error
// Interactions yields interactions until the session ends.
//
// As with Reader.Messages, the channel has ONE useful consumer: two
// goroutines ranging it each take some of the stream and neither sees all
// of it, silently. Fan out in the consumer if more than one component needs
// them.
//
// Interactions are worse to share than messages, because a ResponseToken is
// not safely broadcast. Two holders answering one interaction both succeed
// on most platforms, and the later write replaces the earlier — so a card
// ends up showing whichever answer landed last, with no error to either
// caller.
Interactions() <-chan Interaction
}
Interactive is components: prompts offering choices, and forms.
The surface stops deliberately short of any platform's component model. There are no rows, no styling beyond a hint, no custom-id encoding and no message flags — reproducing those would make this Discord's API with different names, which is what the boundary exists to prevent.
func AsInteractive ¶
func AsInteractive(p *Provider) (Interactive, bool)
AsInteractive returns the provider's Interactive, if it has one.
type Member ¶
type Member struct {
ID ID
Name string
Roles []ID
// IsBot reports that this account is a bot rather than a person.
//
// It lives here rather than on Message, where it used to, because being a
// bot is a property of an account and not of something the account
// happened to send. A consumer reaching a Member any other way --
// MemberInspector.Member, or a VoiceParticipant -- could not otherwise ask
// a question this contract already answered for message authors.
//
// A BARE BOOL, unlike VoiceCapabilities.Sequenced and its siblings, so a
// caller cannot tell "not a bot" from "this platform does not say". That is
// deliberate and rests on which way the silence fails: an unreported
// Sequenced has a consumer trust numbers that mean nothing, while an
// unreported IsBot has it treat a bot as a person, and the worst outcome
// there is asking a bot for consent it will not give. A consumer that acts
// destructively on false is the case that would overturn this.
//
// TRUE FOR WEBHOOKS on Discord, which this contract does not currently
// separate from bots. A consumer filtering to avoid loops wants them
// together; one attributing authorship may not.
IsBot bool
}
Member is a person on the platform.
Name is for display. Authorisation is decided from Roles and nothing else — a display name is user-controlled on most platforms and is not an identity.
func (Member) HasAnyRole ¶
HasAnyRole reports whether the member holds at least one of the given roles. An empty argument list returns false: an empty allowlist permits nothing.
type MemberInspector ¶
type MemberInspector interface {
// Member returns a member's current identity and roles. Returns ErrNotFound
// if they are not in the space.
Member(ctx context.Context, userID ID) (Member, error)
// MemberJoined returns when a member joined the space.
MemberJoined(ctx context.Context, userID ID) (time.Time, error)
}
MemberInspector supplies the signals that change how an ambiguous message reads — an account created yesterday posting its fourth message is a different situation from a member of two years.
func AsMemberInspector ¶
func AsMemberInspector(p *Provider) (MemberInspector, bool)
AsMemberInspector returns the provider's MemberInspector, if it has one.
type Message ¶
type Message struct {
ID ID
ChannelID ID
// ThreadID is empty when the message is not already in a thread.
ThreadID ID
// ParentID is the channel a thread hangs off, and is empty when the message
// is not in a thread — so ChannelID is the channel to test in that case.
//
// It exists because ChannelAllowed admits a thread on behalf of its parent,
// and a consumer that keeps its own allowlist cannot otherwise agree with
// that decision: a thread's id is its own, never the configured one. Without
// the parent, such a consumer must either drop every thread reply or accept
// every one of them, and the second is not revocable — a channel removed
// from the allowlist would keep leaking through its threads.
//
// Empty when the platform models threads as their own channels but the
// parent could not be resolved. Treat that as "not permitted", matching
// ChannelAllowed: an unresolvable parent is not evidence of permission.
ParentID ID
Content string
Author Member
// Addressed reports that this message speaks to the bot directly: it
// mentions the bot, or it replies to something the bot said.
//
// A boolean rather than a mention list, deliberately. A consumer that must
// answer "was I spoken to" should not have to learn who else was mentioned,
// nor parse a platform's mention syntax out of Content — which is where a
// consumer picks up a dependency on the very wire format this contract
// exists to hide.
//
// This is NOT mention resolution. Nothing user-controlled reaches the
// consumer through it: no display names, no rendered markup, no list of
// third parties. It is one fact the provider is uniquely able to establish,
// because only the provider knows which account it authenticated as.
//
// False when the provider cannot determine its own identity, which fails
// closed: a bot that does not know whether it was addressed must not assume
// it was.
Addressed bool
}
Message is an inbound message, normalised across platforms.
Every field here is UNTRUSTED. Content reaches an LLM prompt, an issue body and a log line. The type deliberately offers no Markdown rendering and no mention resolution — conveniences that would invite a caller to treat it as safe.
type Moderator ¶
type Moderator interface {
// DeleteMessage removes a message. reason is recorded in the platform's
// audit log where one exists.
DeleteMessage(ctx context.Context, ref Ref, reason string) error
// TimeoutMember silences a member for a period. Providers MUST treat a
// non-positive duration as a request to lift an existing timeout rather
// than as an error, so a caller has one way to say "undo this".
TimeoutMember(ctx context.Context, userID ID, d time.Duration, reason string) error
}
Moderator is the destructive surface.
A caller MUST NOT be able to reach these from anything a person typed or a model produced. The path to them belongs behind an authorisation check on a verified interaction, and should share no code with the answering path.
func AsModerator ¶
AsModerator returns the provider's Moderator, if it has one.
type Need ¶ added in v0.6.0
type Need string
Need names a capability a consumer intends to use.
It is declared BEFORE any scope exists, because connection privileges are negotiated when a transport is established and a provider given only a token would have to choose them blind. On Discord these are gateway intents, sent once at identify and unchangeable without reconnecting.
The vocabulary is this contract's own capabilities, named so a consumer can ask for them before it has anything to type-assert on.
A string rather than an enumerated constant, for the reason Register takes a string: a platform this module has never heard of must be able to name what it requires without a release here.
const ( // NeedMessages is message content a consumer did not otherwise earn sight of. // // On Discord this is the one that costs a privileged intent, so not declaring // it is a real property and a strong one — but it is NOT one an end user can // audit, and an earlier version of this comment claimed it was. // // 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 a server owner sees cannot disclose them: it renders scopes // and permissions, and intents are in neither. That is structural rather than // a gap in one client's rendering. // // The screen is not silent on message reading, which is what makes it // misleading rather than merely incomplete. It itemises Read Message History, // whose plain reading is "this bot can read messages" — but that permission is // NECESSARY AND NOT SUFFICIENT, so its presence cannot settle the question. A // bot holding it without the intent gets content: "" back. The screen shows // the necessary half and omits the deciding half. // // What the property IS worth: an undeclared privilege is one the PLATFORM // refuses to serve, so the guarantee does not depend on this consumer's code // being correct. Ask for an intent that was not granted and the gateway closes // with 4014. Nobody has to trust the code, but nobody outside can verify it // from the authorisation flow either. // // The property is also BOUNDED, and stating it as an absolute would be false: // // a bot that does not declare NeedMessages cannot read messages // OTHER THAN those addressed to it or sent by it // // Discord exempts four cases from the privilege regardless: content in // messages the app sent, content in DMs with the app, content in which the // app is mentioned, and the content of a message a message-context-menu // command was used on. The exemption is per MESSAGE rather than per call, so // a consumer holding no privilege still sees content in all four. // // That bound is worth stating carefully because the unbounded version is the // sentence somebody quotes in a privacy conversation, and it does not survive // anyone who knows the platform asking a follow-up question. // // # It governs reading history too, which was measured rather than assumed // // This need also governs reading history over a platform's REST API — // [Actor.ThreadHistory] here. Discord's own pages conflict on the point, the // gateway page saying the intent applies across the APIs while the // message-resource page attaches the empty-content warning to gateway events // alone, so it was tested rather than read. // // Established 2026-08-24 by controlled comparison: the same four // human-authored messages in the same channel, fetched twice with nothing // changed but the privilege. Without it, every content field came back as // the empty string; with it, the same four returned their text. The messages // neither mentioned nor were sent by the application, so no exemption // applied to either run. // // So a provider MUST return [ErrForbidden] from ThreadHistory where the // privilege is required and NeedMessages was not declared. It must not return // messages with empty content, and the reason is sharper than tidiness: the // per-message exemptions above apply here too, so a caller 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 above // is reasoned from the documented rules rather than observed. NeedMessages Need = "messages" // NeedInteractions is Interactive: prompts, forms and their replies. NeedInteractions Need = "interactions" // NeedCommands is Commands: declaring what users can run. NeedCommands Need = "commands" // NeedAuthoring is Author: replacing and deleting messages this consumer // posted. // // Distinct from NeedModeration on purpose. Acting on your own messages is // not moderation, and a consumer that declared moderation to tidy up after // itself would be telling a reviewer something false. NeedAuthoring Need = "authoring" // NeedModeration is Moderator: deleting and timing out. NeedModeration Need = "moderation" // NeedMemberLookup is MemberInspector. NeedMemberLookup Need = "member_lookup" // NeedVoiceReceive is VoiceReceiver. NeedVoiceReceive Need = "voice_receive" // NeedVoiceSend is VoiceSender. NeedVoiceSend Need = "voice_send" // NeedVoiceParticipants is VoiceParticipants: who is in a voice channel. // // 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. NeedVoiceParticipants Need = "voice_participants" // NeedIndicator is Indicator: the consumer's words on the bot's name. // // Buys no inbound event and, on Discord, no intent; the request is REST. NeedIndicator Need = "indicator" // NeedReactions is ReactionObserver: hearing reactions change on messages // in the scope. // // Separate from NeedMessages because it is a second inbound stream with // its own cost — on Discord the unprivileged reactions intent — and a // consumer that only reads messages should not pay it. NeedReactions Need = "reactions" )
type OptionType ¶ added in v0.12.0
type OptionType uint8
OptionType is what an argument means, so a platform can draw the right picker and validate before the command is dispatched.
The contract owns this enumeration rather than mirroring a platform's. These seven are what is worth promising everywhere: each is a concept a chat platform either has, or can be honestly said not to have. A platform's richer types are its own business.
const ( // OptionString is the zero value, so an option declared without a type is // text — which is what every option was before types existed, and what // every platform can carry. OptionString OptionType = iota OptionInteger OptionNumber OptionBoolean OptionChannel OptionUser OptionRole )
type PromptSpec ¶
type PromptSpec struct {
Content string
Choices []Choice
// Ephemeral asks that only the person who triggered it can see the result,
// where the platform supports that. Providers that cannot MUST post
// normally rather than fail — losing privacy is better than losing the
// answer, and the caller cannot recover from a refusal here.
Ephemeral bool
}
PromptSpec is a message offering a set of choices.
func (PromptSpec) Validate ¶
func (p PromptSpec) Validate() error
Validate reports whether the prompt can be routed unambiguously.
Duplicate or empty keys are the failure worth catching: a moderation card carries dismiss, delete and ban, and routing an ambiguous key means taking the wrong action against a person.
type Provider ¶
type Provider struct {
Name string
Reader Reader
Actor Actor
// Needs is what the Client this scope came from was constructed with, and
// it GATES CAPABILITY DISCOVERY: an As... helper answers false for anything
// not listed here, however capable the Actor is.
//
// A provider populates it from its own ClientConfig and otherwise ignores
// it. That is the point — the alternative was every provider varying its
// Actor's method set to match, which is one concrete type per combination of
// capabilities and doubles with each one added, so no author would do it and
// the contract's promise would quietly not hold.
//
// A provider copies it EXACTLY. The two ways of getting that wrong fail very
// differently, and only one of them announces itself.
//
// Too FEW yields a provider with no capabilities: a liveness failure, loud at
// a consumer's startup check, and somebody investigates.
//
// Too MANY opens a discovery gate that should be shut, and nothing anywhere
// fails. The consumer reaches a capability it declined, least privilege
// quietly stops holding, and every test still passes — because a capability
// that works is not what a test looks for. That is a safety failure, and it
// is why the conformance harness asserts set equality rather than that
// everything declared is present.
Needs []Need
}
Provider is what a platform supplies for one scope. Actor is nil for a read-only scope; optional capabilities are found through the As... helpers.
func New
deprecated
New builds a provider for one space, owning a transport of its own.
Deprecated: use NewClient and Client.Provider. Calling New once per space dials a transport per space, which on a platform carrying many spaces over one connection — Discord — meets connection rate limits in production rather than in a test with one space. Client exists so serving several spaces costs one transport. New is removed one minor release after the one that introduced it.
The returned Provider OWNS its transport: closing its Reader closes the connection underneath. That is why looping this is expensive, and it is also why it does not leak.
type ProviderOption ¶ added in v0.6.0
type ProviderOption func(*Scope)
ProviderOption configures one scope. Options are per scope and never widen a sibling's: a shared transport carries several, and each is answerable only for what it was given.
func WithAllowedChannels ¶ added in v0.6.0
func WithAllowedChannels(ids ...ID) ProviderOption
WithAllowedChannels sets the channels this scope may read from.
func WithReadOnly ¶ added in v0.6.0
func WithReadOnly() ProviderOption
WithReadOnly asks for an observing scope, yielding a nil Actor.
type Reaction ¶ added in v0.15.0
type Reaction struct {
// contains filtered or unexported fields
}
Reaction is one change to the reactions on a message.
Opaque, and built through one of four constructors, because each change carries a different set of facts: a cleared message names no emoji and no account, a removal names an account and usually no member. A plain struct would document that with "empty when" clauses a consumer has to remember; the constructors make it structural, and the accessors say in their signatures which facts are conditional.
func AddedReaction ¶ added in v0.15.0
AddedReaction is one account putting emoji on the message at ref.
func ClearedEmoji ¶ added in v0.15.0
ClearedEmoji is every reaction of one emoji on the message at ref going at once.
func ClearedReactions ¶ added in v0.15.0
ClearedReactions is every reaction on the message at ref going at once.
func RemovedReaction ¶ added in v0.15.0
RemovedReaction is user's emoji leaving the message at ref. user is whose reaction it was; a moderator removing somebody else's is reported against that somebody.
func (Reaction) Change ¶ added in v0.15.0
func (r Reaction) Change() ReactionChange
Change is which of the four things happened.
func (Reaction) Emoji ¶ added in v0.15.0
Emoji is the reaction, in the form Actor.React takes, so a consumer recognises its own offer coming back by string equality. Empty for ReactionsCleared, and on a platform whose custom emoji can be deleted it may be empty on any change; Change is the discriminator, never emptiness.
func (Reaction) Member ¶ added in v0.15.0
Member returns who the account is, and whether the platform said.
The second return is the point. A caller must destructure to reach the member, so "the platform did not say" cannot be read as "not a bot, with no roles". Roles carried here are as of the reaction; a consumer authorising on them should use MemberInspector, the contract's current-value query.
func (Reaction) ParentID ¶ added in v0.15.0
ParentID is the parent channel when the message is in a thread, as Message.ParentID is, and empty otherwise.
func (Reaction) Ref ¶ added in v0.15.0
Ref is the message the change is on, with ThreadID set when the message is in a thread. Join it to the Ref an Actor returned by MessageID and nothing else: on a channel-cache miss a provider may leave ThreadID empty, so the two need not be equal field for field.
func (Reaction) UserID ¶ added in v0.15.0
UserID is the account whose reaction it is. Empty for the two cleared changes, which name nobody.
func (Reaction) Variant ¶ added in v0.15.0
Variant is the platform's name for a second form of the emoji, and empty for the ordinary form. See WithVariant.
func (Reaction) WithMember ¶ added in v0.15.0
WithMember attaches the member the platform sent beside the change, whose ID is UserID. Ignored on a change that names no account, so a cleared message cannot be made to claim one.
A provider attaches a member only when one actually arrived. On Discord an added reaction usually carries one and a removed reaction never does, and "usually" is the platform's word rather than this contract's: a zero member substituted for an absent one is the bare-bool trap Member.IsBot describes.
func (Reaction) WithVariant ¶ added in v0.15.0
WithVariant marks a second form of the emoji that a platform lets one account hold beside the ordinary form, in the platform's own word for it. Ignored on a change that names no emoji.
Free text rather than an enumeration, because this contract cannot name the forms other platforms have and the provider can. Discord's is "burst": its reaction object carries separate flags for the ordinary and burst forms, so one person can hold both of one emoji on one message, and a consumer keying per-person state on (UserID, Emoji) alone reads the removal of one as the retraction of both.
type ReactionChange ¶ added in v0.15.0
type ReactionChange uint8
ReactionChange is which of the four things happened to a message's reactions.
const ( // ReactionAdded is one account putting one emoji on the message. ReactionAdded ReactionChange = iota // ReactionRemoved is one account's emoji leaving the message. The account // is whose reaction it was, which is not necessarily who removed it. ReactionRemoved // ReactionsCleared is every reaction on the message going at once. ReactionsCleared // ReactionEmojiCleared is every reaction of one emoji going at once. ReactionEmojiCleared )
func (ReactionChange) String ¶ added in v0.15.0
func (c ReactionChange) String() string
String implements fmt.Stringer.
type ReactionObserver ¶ added in v0.15.0
type ReactionObserver interface {
// Reactions yields reaction changes until the session ends.
//
// A provider MUST NOT emit a reaction from a channel outside the scope's
// allowlist, admitting a thread of an allowed channel exactly as it admits
// a message in one; MUST NOT block the platform's read loop when the
// consumer is slow; and MUST NOT deliver a reaction the bot itself added
// or removed, because only the provider knows which account it
// authenticated as and the contract exposes that identity nowhere else.
//
// Live only. Reactions present before Connect are not replayed, a
// reconnect that starts a fresh session drops what was buffered in the
// gap, and a full consumer buffer drops the event at hand with no signal
// at all. State built from this stream is therefore best-effort: a dropped
// Removed leaves the consumer believing a reaction is on a message when it
// is not, and nothing here says so.
//
// As with Reader.Messages, the channel has ONE useful consumer.
Reactions() <-chan Reaction
}
ReactionObserver delivers each change to the reactions on a message in the scope: the event half of what Actor.React offers.
Discovered on Provider.Reader, as VoiceParticipants is, because a reaction arriving is something observed. That placement is what lets a read-only scope keep it: an observe-only deployment that posts nothing can still be asked, by a person, to notice a reaction on somebody else's message.
func AsReactionObserver ¶ added in v0.15.0
func AsReactionObserver(p *Provider) (ReactionObserver, bool)
AsReactionObserver returns the provider's ReactionObserver, if it has one.
Asserts on Provider.Reader, not Provider.Actor, which is what makes this reachable from a read-only scope. Answers false when NeedReactions was not declared, as well as when the provider cannot answer.
type Reader ¶
type Reader interface {
// Connect establishes a session and returns once it is usable.
Connect(ctx context.Context) error
// Messages yields inbound messages until the session ends. Implementations
// MUST NOT emit messages from channels outside the configured allowlist,
// and MUST NOT block the platform's read loop when the consumer is slow.
//
// The channel has ONE useful consumer. Two goroutines ranging over it do
// not both receive: each takes some of the stream and neither sees all of
// it, and nothing reports that this is happening. A consumer that needs two
// components reading one space ranges the channel once and fans out itself.
//
// This is stated rather than solved. A space has one scope per Client
// (see Client.Provider), so the contract declines to broadcast and owes the
// consumer a plain warning instead — the failure is silent, and it is the
// same silence that rule exists to remove.
Messages() <-chan Message
// State reports the current connection state.
State() ConnState
// Close releases the session. It must be safe to call more than once, and
// safe on a Reader that never connected.
Close() error
}
Reader observes a platform. A Reader can see and nothing else.
Separating this from Actor is what makes an observe-only deployment structural: build with Config.ReadOnly and there is no Actor to misuse.
type Ref ¶
Ref identifies something to act upon without exposing platform types.
ThreadID is empty to mean "the channel"; where an Actor method creates a thread, an empty ThreadID asks for one.
type ResponseToken ¶
type ResponseToken string
ResponseToken is an opaque handle for replying to an interaction.
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 before responding — a caller that must retrieve documents and call a model cannot meet a three-second deadline, and no consumer should have to know one exists.
type Scope ¶ added in v0.6.0
type Scope struct {
// Space is the guild, workspace or network this scope serves.
Space ID
// AllowedChannels is the exhaustive set of channels a Reader may emit from.
// Empty means none.
AllowedChannels []ID
// ReadOnly asks for an observing scope. A provider MUST return a Provider
// with a nil Actor when this is set, rather than an Actor that refuses at
// call time — the point is that there is nothing to misuse.
ReadOnly bool
}
Scope is one space and the limits on it.
Built by NewScope from the options a caller passed to Client.Provider, so a provider applies them in one place rather than interpreting each itself.
func NewScope ¶ added in v0.6.0
func NewScope(space ID, opts ...ProviderOption) Scope
NewScope applies options to a space, for a provider implementing Client.
func (Scope) ChannelAllowed ¶ added in v0.6.0
ChannelAllowed reports whether a channel is in this scope's allowlist. It fails closed: an empty allowlist permits nothing, because a watchlist that silently means "everything" is the wrong default for reading people's messages.
Threads ¶
This answers about the id it is given and nothing else. A provider whose platform models a thread as its own channel MUST additionally admit a thread whose PARENT is allowed, because the alternative is incoherent: a bot can be told to watch a channel, reply in a thread on a message there, and then never see the replies — able to start a conversation it cannot hear.
Admitting the thread does not widen the allowlist. The parent was named, the thread hangs off a message in it, and a thread cannot be created anywhere its parent is not.
Under a shared transport ¶
A scope's allowlist is NOT widened by a sibling's. Where one Client carries several scopes, this is the check keeping them apart, and a provider that consulted the wrong scope's list would leak one tenant's channels to another.
type Subcommand ¶ added in v0.12.0
type Subcommand struct {
Name string
Description string
Options []CommandOption
}
Subcommand is a verb under a command, or under a group.
It carries options and never further subcommands: nesting is exactly one level of grouping deep, which is all any platform supports today.
type VoiceCapabilities ¶ added in v0.5.0
type VoiceCapabilities struct {
// Format is the shape of frames this provider delivers and accepts.
Format VoiceFormat
// Attributes reports whether the platform says which participant produced
// audio.
//
// False is not a lesser version of true. It means every frame arrives
// unattributed FOREVER, and a consumer that cannot act on unidentified
// audio — one enforcing consent, say — should refuse to start rather than
// drop every frame and wonder why. Distinguishing this from the ordinary
// lag before a speaker is known is the whole reason it is declared.
Attributes bool
// Sequenced reports whether frames carry a usable sequence number.
//
// That is all it reports. Carrying sequence numbers is NOT the same
// capability as being able to measure loss, and conflating the two is how
// this contract came to publish a loss figure no receiver can produce.
//
// False on every platform that hands a bot decoded audio rather than
// packets, which is most of them: the sequence numbers exist on the wire
// and are consumed before the bot boundary. When false, VoiceFrame.Sequence
// is meaningless.
Sequenced bool
// ReportsInterruptions reports whether this provider can tell a caller that
// inbound audio stopped and restarted, and for how long.
//
// False is not a claim that interruptions do not happen. It means this
// provider cannot see them, so VoiceSession.Interruptions will always be
// empty and that emptiness carries no information — exactly the reason
// VoiceFrame.Speaker returns a second value rather than a bare identifier.
//
// A consumer whose recording is a source rather than a by-product, and which
// therefore cannot tolerate an unmarked gap in its timeline, should check
// this at startup and refuse rather than discover it later from a recording
// that looks complete.
ReportsInterruptions bool
}
VoiceCapabilities describes what a provider's voice support can actually do.
Answerable WITHOUT joining anything, which is the point: a consumer that cannot work without per-speaker attribution should discover that at startup and refuse, rather than joining a channel and inferring it from a stream of audio it can never use.
Declaring these is what lets platforms of very different capability coexist without the weakest of them setting the contract for all of them. The pattern is the estate's: whole capabilities are answered by implementing an interface, so the type system checks them once; properties that vary WITHIN a capability are declared here, because the type system cannot check them and every caller would otherwise discover them by hitting them.
type VoiceFormat ¶ added in v0.5.0
type VoiceFormat struct {
// Codec is how the payload is encoded. A caller that cannot handle it
// should decline rather than guess: bytes in an unrecognised codec are
// indistinguishable from bytes in a recognised one.
Codec Codec
// SampleRate in hertz, 48000 on Discord and 16000 on some platforms that
// hand a bot decoded audio.
SampleRate int
// Channels per frame: 1 for mono, 2 for stereo.
Channels int
// FrameDuration is how much audio one frame carries, 20ms on Discord. This
// is the interval Send paces to.
FrameDuration time.Duration
// MaxPayloadBytes is the largest single payload the transport will carry,
// or 0 where the provider states no limit.
//
// It exists because an encoder configured for a high bitrate can produce a
// frame the transport refuses or truncates, and neither failure is audible
// as itself: it presents as a dropout. Discord's transport caps an Opus
// frame at 1400 bytes, which a caller has no way to discover from the
// other three fields.
//
// Zero means unspecified rather than zero-length, so it is not part of
// Valid: a provider that does not know its transport's ceiling is
// legitimate, and a caller respecting a limit that is not declared has
// nothing to respect.
MaxPayloadBytes int
}
VoiceFormat describes the frames a voice session carries.
It exists because a caller producing audio has to produce it in the shape the provider paces. VoiceSender.Send blocks until a frame's turn on the wire, and "a frame" is a duration: hand over frames twice as long as the session expects and the audio plays at half speed, with nothing failing anywhere to say so.
Without this a consumer reads the platform's documentation and hardcodes the numbers, which is a consumer holding a fact about a platform this contract exists to keep it away from. Asking the provider is the whole point.
It matters inbound too, though less obviously. Cutting a recording only on frame boundaries needs the frame duration, and a consumer that assumes one is assuming a platform constant.
func (VoiceFormat) SamplesPerFrame ¶ added in v0.5.0
func (f VoiceFormat) SamplesPerFrame() int
SamplesPerFrame is how many samples per channel one frame holds, which is what an encoder wants configuring. Returns 0 for a zero-valued format rather than dividing by zero.
func (VoiceFormat) Valid ¶ added in v0.5.0
func (f VoiceFormat) Valid() bool
Valid reports whether the format describes something an encoder could be configured from. A provider that has not filled it in yields false, which is worth checking before trusting the numbers: a zero format silently configures an encoder to produce nothing.
type VoiceFrame ¶ added in v0.5.0
type VoiceFrame struct {
// contains filtered or unexported fields
}
VoiceFrame is one Opus frame as it arrived, with the RTP metadata that came with it and either a speaker or an explicit statement that the speaker is not yet known.
The payload is NOT decoded, reordered, reassembled or resampled. Three things depend on that and all three break if a provider gets helpful: a recording must concatenate back to the original stream byte-exactly to be worth re-transcribing; a cut may fall only on a frame boundary; and the sequence number and timestamp are the only signal a consumer has that the stream is discontinuous at all — which is how a defect that discarded 3-11% of audio in a widely used library stayed hidden and read as network trouble.
Its fields are unexported on purpose. A frame is built by AttributedFrame or UnattributedFrame, so a provider has to say which it is, and the zero value reads as unattributed rather than as attributed to nobody.
func AttributedFrame ¶ added in v0.5.0
func AttributedFrame(speaker ID, sequence uint16, timestamp uint32, payload []byte) VoiceFrame
AttributedFrame builds a frame whose speaker is known.
An empty speaker yields an UNATTRIBUTED frame rather than one attributed to nobody. That is not tidiness: a provider whose SSRC-to-user lookup missed has an empty identifier to hand, and the whole point of the attributed/ unattributed distinction is lost if that path can produce a frame a consumer reads as identified.
The frame takes its own copy of opus, so the caller may reuse its receive buffer immediately. Providers do reuse one, and a frame that aliased it would hand a consumer whatever arrived next — silently, and long after the frame looked correct.
func UnattributedFrame ¶ added in v0.5.0
func UnattributedFrame(sequence uint16, timestamp uint32, payload []byte) VoiceFrame
UnattributedFrame builds a frame whose speaker the platform has not yet named.
This is an ordinary occurrence rather than an error. Audio arrives before the mapping that says whose it is, on every session join, and the gap has been measured in seconds. What such a frame carries is not that speaker's audio in any useful sense — it is undecryptable, or plaintext nobody has vouched for — so a consumer that cannot act on unidentified audio should drop it, and a consumer counting capture quality should count it.
Like AttributedFrame, the frame takes its own copy of opus.
func (VoiceFrame) Payload ¶ added in v0.5.0
func (f VoiceFrame) Payload() []byte
Payload returns the frame's encoded audio, in the codec the session declares. It is the frame's own storage rather than the provider's, so it stays valid after the sink returns; do not mutate it.
Not named for a codec, because it is not always the same one. Two of the three platforms with a documented bot-facing audio API hand over decoded PCM rather than the codec that was on the wire.
func (VoiceFrame) Sequence ¶ added in v0.5.0
func (f VoiceFrame) Sequence() uint16
Sequence returns the frame's sequence number, where the platform provides one.
DO NOT COMPUTE LOSS FROM GAPS IN IT. The obvious implementation — treat any delta above 1 as a gap — is wrong on an ordinary connection by three orders of magnitude, because UDP reorders routinely and a packet arriving after a later one produces a delta near 65535. Measured on a healthy 55-second call: 398 frames received, 262,148 reported lost. Surviving that needs RFC 3550 A.1 cycle extension and misordering bounds, and even then the result counts padding and decrypt failures as loss, which is why VoiceStats no longer publishes such a number.
It is here to order frames, to deduplicate them, and to notice a discontinuity. Not to prove a recording is complete: equal sequence numbers say nothing about the payloads beside them, and what is missing from a span is exactly what a receiver cannot characterise.
Meaningless unless VoiceCapabilities.Sequenced is true, and zero on platforms that hand a bot decoded audio rather than packets — the numbers exist on the wire and are consumed before the bot boundary.
func (VoiceFrame) Speaker ¶ added in v0.5.0
func (f VoiceFrame) Speaker() (ID, bool)
Speaker returns who produced the frame, and whether that is known.
The second return is the point. A caller must destructure to reach the identifier, so "I do not know yet" cannot be mistaken for a speaker, and the obvious code — take the speaker, give up if there is none — is also the correct code.
func (VoiceFrame) Timestamp ¶ added in v0.5.0
func (f VoiceFrame) Timestamp() uint32
Timestamp returns the frame's media timestamp, which advances with audio rather than with wall clock: a run of frames from one speaker is contiguous in it, and a silence is a jump.
Subject to the same caveat as Sequence.
type VoiceInterruption ¶ added in v0.8.0
type VoiceInterruption struct {
// At is when the last frame before the gap was delivered.
At time.Time
// contains filtered or unexported fields
}
VoiceInterruption is a period during which no audio arrived.
It exists because a recording that is silently short is worse than one with a hole in it that is marked. Everything downstream that aligns to a clock — a citation into a transcript, a segment boundary, a later pass by a recogniser that does not exist yet — depends on the recording representing real time. A gap nothing records is a splice, and nothing downstream can detect one.
Why an instant and a duration, and 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 stored as a measured value.
Handing a caller two instants to subtract does not work, and the failure is the worst shape available. A time.Time carries a monotonic reading and Sub uses it, so an in-process subtraction is immune to a wall-clock step — but serialising the value strips that reading, and so does storing it or handing it across a process boundary. A caller that subtracts immediately gets the right answer in every test it will ever write, and the wrong one in production once the values have been through storage. Measured: with a backward thirty-second clock step between two stamps, subtracting them as wall-clock values returns minus thirty seconds.
The consumer that needs this is usually a later one. A recording kept as a source rather than a by-product is read back by a process that was not running when the gap happened, so anything computed in-process and not stored is gone.
Both edges are frame boundaries ¶
At is when the last frame BEFORE the gap was delivered to the sink, and At.Add(Duration) is when the first frame AFTER it was. Neither edge is the moment the provider noticed, and the distinction is not pedantry: detection lags the silence, so anchoring there would show audio continuing past the last frame the caller actually holds, and the far edge would drift the other way. The two errors do not cancel.
Built by PlatformInterruption or ConsumerDelayedInterruption, so a provider has to say which it is, and the zero value reads as the one a caller must correct rather than the one it may trust.
func ConsumerDelayedInterruption ¶ added in v0.8.0
func ConsumerDelayedInterruption(at time.Time, d time.Duration) VoiceInterruption
ConsumerDelayedInterruption records a gap that spans a session boundary, where the old session died and a successor was joined.
The duration runs from the end of the old session to the join of the new one, so it includes however long the CALLER took to notice, back off and retry. The provider cannot separate the two: it knows when the caller rejoined and not when the caller became able to.
That matters more than it sounds. A caller that treats this as pure platform loss will record a twenty-minute break its own operator chose as twenty minutes of audio it failed to capture.
func PlatformInterruption ¶ added in v0.8.0
func PlatformInterruption(at time.Time, d time.Duration) VoiceInterruption
PlatformInterruption records a gap observed inside a session that survived it, where nothing of the caller's own scheduling is in the measurement.
func (VoiceInterruption) Span ¶ added in v0.8.0
func (i VoiceInterruption) Span() (time.Duration, bool)
Span returns how long the interruption lasted, and whether that duration is wholly the platform's.
The second return is the point, and it is the same argument as VoiceFrame.Speaker: a duration is meaningless without the fact that says how to read it, so a caller must destructure to reach the number and cannot take it at face value.
TRUE means the gap was observed inside a session that survived, so nothing of the caller's is in it. FALSE means the caller's own rejoin latency is included and only the caller can subtract it, holding as it does its own decision timestamps.
type VoiceParticipant ¶ added in v0.13.0
type VoiceParticipant struct {
// ID is who, and 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.
ID ID
// Member is who they are. Member.ID always equals ID; Name is meaningful
// only when ReportsName is true and Roles only when ReportsRoles is true.
//
// 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 — so 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: identity travels beside an
// observation without anybody calling MemberInspector.
//
// AS OF OBSERVATION, and 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 should use
// MemberInspector, which is the contract's current-value query.
Member Member
// SelfMuted and SelfDeafened are statements by the person.
SelfMuted, SelfDeafened bool
// SpaceMuted and SpaceDeafened are statements about them by somebody else.
//
// Kept apart from the self flags 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.
SpaceMuted, SpaceDeafened bool
}
VoiceParticipant is one person in a voice channel.
There is deliberately 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.
type VoiceParticipantCapabilities ¶ added in v0.13.0
type VoiceParticipantCapabilities struct {
// ReportsMuteState reports whether the four mute and deafen booleans are
// meaningful.
ReportsMuteState bool
// ReportsName reports whether Member.Name is filled in.
ReportsName bool
// ReportsRoles reports whether Member.Roles is filled in.
//
// 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. One flag would make such a provider claim
// both or withhold a name it has.
//
// It is NOT 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.
ReportsRoles bool
}
VoiceParticipantCapabilities declares which parts of a VoiceParticipant this provider fills in.
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.
type VoiceParticipants ¶ added in v0.13.0
type VoiceParticipants interface {
// Participants reports who is in the channel now.
//
// It 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.
//
// The 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:
//
// - ErrUnsupported when this provider cannot answer at all;
// - ErrForbidden when the platform refuses this deployment, which is a
// determinate refusal somebody can fix;
// - ErrChannelDenied when the channel is outside the scope's allowlist,
// checked BEFORE anything touches the platform, because a voice channel
// is a channel and a caller must not reach past the allowlist by naming
// one. Without this the capability would enumerate the people in any
// channel in the space, which is a worse leak than the audio the
// allowlist exists to gate;
// - ErrInvalidArgument for a malformed identifier, and for one that names
// a real, allowed channel carrying no voice — otherwise the obvious
// implementation returns an empty slice and asserts that nobody is in a
// text channel;
// - ErrNotFound when no such channel exists;
// - ErrNotConnected when asked before Connect;
// - an ordinary error matching NO sentinel when the provider could not
// determine its own permission. 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.
Participants(ctx context.Context, channelID ID) ([]VoiceParticipant, error)
// ParticipantCapabilities says which of a participant's fields mean
// anything.
//
// Named ParticipantCapabilities rather than Capabilities 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. This contract must not make that
// uncompilable.
ParticipantCapabilities() VoiceParticipantCapabilities
}
VoiceParticipants reports who is in a voice channel.
It is deliberately NOT called presence. On Discord that word means online, idle or do-not-disturb status and requires a privileged intent this contract never asks for; channel occupancy is a different thing 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, which would spend the least-privilege property for a question that never needed it.
Discovered on Provider.Reader rather than Provider.Actor, because asking who is present observes and changes nothing. That placement is what lets a read-only scope use it — and an observe-only deployment enforcing consent is exactly the caller that needs to know who is in the room.
func AsVoiceParticipants ¶ added in v0.13.0
func AsVoiceParticipants(p *Provider) (VoiceParticipants, bool)
AsVoiceParticipants returns the provider's VoiceParticipants, if it has one.
Asserts on Provider.Reader, not Provider.Actor, which is what makes this reachable from a read-only scope. Answers false when NeedVoiceParticipants was not declared, as well as when the provider cannot answer.
type VoiceReceiver ¶ added in v0.5.0
type VoiceReceiver interface {
// Capabilities describes what this provider's voice support can do, so a
// caller can adapt rather than discover limitations by hitting them.
//
// Answerable with no connection and before joining, so a consumer whose
// requirements the platform cannot meet can decline at startup.
Capabilities() VoiceCapabilities
// Join enters the voice channel and begins delivering frames to sink,
// returning a handle for leaving it.
//
// The sink is supplied here rather than set afterwards on the returned
// session, because audio arrives immediately: a two-step join would leave a
// window in which frames must be buffered or dropped, and buffering them is
// exactly what VoiceSink exists to avoid.
//
// channelID is opaque, and what it names varies more than the parameter's
// name suggests. Platforms scope a call to a channel, a room, or the whole
// session, and one whose audio is not channel-scoped uses whatever does
// identify its call. A platform where connecting IS joining accepts the
// identifier it has and treats the call as already open.
//
// Providers MUST return ErrForbidden where the platform refuses to admit the
// bot on permission, which is a different answer from a channel this module
// declines to allow, and ErrChannelDenied for a channel outside the
// configured allowlist — a voice channel is a channel, and a caller must not
// reach past the allowlist by naming one — and ErrInvalidArgument for a nil
// sink, rather than accepting a join that will panic on the first frame.
//
// One session at a time, and a second Join is REFUSED with ErrVoiceBusy
// rather than silently moving. Moving would tear down the encryption epoch
// and the speaker mapping mid-recording, so a caller that joined twice by
// accident would get a truncated recording and no error to explain it.
Join(ctx context.Context, channelID ID, sink VoiceSink) (VoiceSession, error)
}
VoiceReceiver joins a voice channel and delivers what it hears.
func AsVoiceReceiver ¶ added in v0.5.0
func AsVoiceReceiver(p *Provider) (VoiceReceiver, bool)
AsVoiceReceiver returns the provider's VoiceReceiver, if it has one.
A provider built without the platform's voice support answers false here, and so does one on a platform with no voice concept. That is deliberate: a caller asks one question and gets one answer, rather than having to distinguish "cannot" from "was not built to".
It also answers false when NeedVoiceReceive was not declared to the Client, which is the reason to check first if this is unexpectedly false.
type VoiceSender ¶ added in v0.5.0
type VoiceSender interface {
// Send transmits one Opus frame, blocking until its turn on the wire.
//
// Providers MUST return ErrForbidden where the platform refuses on
// permission — VoiceSession.CanSend answers that in advance, but only
// advisorily — ErrNoVoiceSession when not in a channel, rather than
// discarding the frame quietly, and ErrVoiceBusy while a Stream is
// running — two writers pacing into one timeline produce interleaved audio
// that is nobody's.
//
// That error is also the answer to overlaying two sources, such as speech
// over a music bed. A platform carries one stream from one sender, so
// overlaid audio is not two streams sent at once, it is one stream that
// already contains both. Mix in the sample domain and encode once: the
// caller has both sources before encoding, so mixing there is both possible
// and better than anything downstream, where it would mean decoding and
// re-encoding what was already encoded.
//
// Safe to call concurrently with a sink running. NOT safe to call from more
// than one goroutine at a time: the frames would interleave for the same
// reason.
Send(ctx context.Context, payload []byte) error
// Stream sends every frame the caller writes to frames, pacing them onto
// the wire, and returns when the caller closes frames, the context is
// cancelled, or the connection fails.
//
// It exists because Send alone makes the caller's generator and the wire the
// same goroutine, so a generator that stalls stalls the wire. Handing over a
// channel separates them: the caller writes ahead as fast as it 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 the caller's choice
// rather than a number this contract picks. Unbuffered means the generator
// runs in lockstep with the wire; capacity n lets it run n frames ahead.
// Writing blocks once the provider is that far behind, which is the
// backpressure a caller wants — it is the signal that generation has
// outpaced real time.
//
// Errors are returned rather than delivered on a channel, which is the whole
// reason this takes a channel instead of returning one: a failed write to a
// channel is indistinguishable from a successful one, so a connection lost
// mid-utterance would be silent.
//
// frames := make(chan []byte, 50) // a second of jitter absorption
// go func() {
// defer close(frames)
// for f := range synth.Frames() {
// select {
// case frames <- f:
// case <-ctx.Done(): // Stream has stopped reading
// return
// }
// }
// }()
//
// if err := tx.Stream(ctx, frames); err != nil { ... }
//
// That select is not decoration. When Stream returns early — the connection
// failed, the context was cancelled, the channel was left — THE PROVIDER
// STOPS READING. A producer still writing into a full channel then blocks
// for ever, and it never learns why, because the error was returned on a
// different goroutine from the one that is now stuck.
//
// A producer must therefore select on the same context it passed to Stream.
// The version without the select is the one a reader writes first and it
// deadlocks silently, so it is worth writing out rather than describing.
//
// Providers MUST return ErrForbidden where permission is refused,
// ErrNoVoiceSession when not in a channel, and ErrVoiceBusy if a stream is
// already running. A nil channel is ErrInvalidArgument rather than a stream
// that never ends.
//
// Closing frames ends the stream; it does not leave the channel. Leaving is
// VoiceSession.Leave.
//
// 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. A caller shutting down may therefore either close frames and
// wait, or leave and let Stream return — it does not have to sequence them.
//
// A channel that is momentarily EMPTY is not the end of the utterance. Only
// closing it ends one. The distinction matters because a generator falling
// behind real time is the case this method exists for: a provider that read
// an empty channel as "finished" would stop the transmission and start it
// again when frames resumed, which on a platform showing who is talking
// means the indicator flickering through every hesitation. Providers keep the
// transmission open across a gap and supply whatever the platform needs to
// stay coherent, as D17 already has them do.
//
// The consequence for a caller is worth knowing: a generator that dies
// without closing the channel holds the transmission open until the context
// is cancelled or the channel is left. Close the channel in a defer.
Stream(ctx context.Context, frames <-chan []byte) error
}
VoiceSender sends audio into the channel the provider is in.
Separate from VoiceReceiver so a consumer can decline it. There is no send-only posture: sending requires being in the channel, and getting there is VoiceReceiver.Join.
The provider paces, not the caller ¶
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 a caller looping as fast as it can generate would flood the socket rather than be heard.
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 exactly the kind of thing this module exists not to hand out once per consumer. The consequence is the one worth having: the naive loop is the correct loop.
for frame := range synth.Frames() {
if err := tx.Send(ctx, frame); err != nil {
return err
}
}
Sending and receiving run at the same time ¶
A provider holds one connection carrying both directions, and nothing here serialises them: a sink may be called while Send is blocked, and Send may be called while a sink is running. Both are expected.
One shape is a trap, and it is the obvious one to write for a bot that answers what it hears:
// WRONG. Send blocks for the length of the utterance, and it is blocking
// the receive path while it does, so everything said meanwhile is lost.
rx.Join(ctx, ch, 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 VoiceSink's rule that blocking a sink blocks the provider reading from the platform, but a blocking Send makes it much easier to do by accident, so it is worth saying twice.
A caller sends audio and nothing else ¶
Platforms ask for more than audio around a transmission. One wants to be told 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, and a caller should never construct any of it. A consumer that has to know a platform's magic silence frame is a consumer holding a platform detail, which is the thing this contract exists to prevent — the same argument that gives the provider pacing.
So the only thing a caller supplies is encoded audio, in the shape VoiceSession.Format describes. Starting, stopping and whatever the platform needs to hear in between happen underneath.
func AsVoiceSender ¶ added in v0.5.0
func AsVoiceSender(p *Provider) (VoiceSender, bool)
AsVoiceSender returns the provider's VoiceSender, if it has one.
Answers false when NeedVoiceSend was not declared, as well as when the provider cannot send.
type VoiceSession ¶ added in v0.5.0
type VoiceSession interface {
// Leave departs the channel. It must be safe to call more than once.
Leave(ctx context.Context) error
// Stats reports what this session has seen. Cheap enough to poll.
Stats() VoiceStats
// Interruptions reports the periods during which no audio arrived, oldest
// first, for as long as this session lives.
//
// Read rather than delivered, which is the opposite of how frames arrive and
// is deliberate. VoiceSink is a callback because a buffer would decide to
// keep a frame before the consumer had said whether it may, and a caller
// enforcing consent needs to refuse audio before anything retains it. None
// of that applies here: an interruption is not audio, nobody's consent
// governs it, and it is metadata about absence.
//
// What licenses reading it late is that the value is SELF-PLACING. It says
// where it belongs (VoiceInterruption.At), so a caller whose archive is
// ordered on real time can read it after the audio either side and still put
// it in the right place. Append order does not matter when every record says
// where it goes. A future value crossing this boundary that is NOT
// self-placing cannot borrow that argument.
//
// A caller that reads late has lost nothing, where a caller that reads
// frames late has lost audio it can never recover.
//
// Always empty when VoiceCapabilities.ReportsInterruptions is false, which
// is NOT the same as no interruptions having occurred. Read the two together
// or not at all.
Interruptions() []VoiceInterruption
// CanSend reports whether this deployment is currently permitted to send
// audio into the channel it joined.
//
// It exists so the ordinary case — a bot added to a space without the right
// to speak — is discoverable without provoking a failure. A caller can say
// so to somebody who can fix it, at the moment it joins, rather than at the
// moment it first has something to say.
//
// ADVISORY, AND NEVER THE AUTHORITY. A permission can be revoked between
// this call and the next Send, so a caller must handle ErrForbidden from
// Send and Stream regardless. This contract already applies that rule to
// CommandSpec.RequiredRoles for the same reason: platform-side gating is a
// convenience, and the refusal is what decides.
//
// False where the provider cannot determine the answer, which fails closed
// in the harmless direction: the caller is told to check a permission that
// may already be granted, and the worst outcome is somebody looking at a
// settings page. A caller may still attempt the send.
CanSend() bool
// Format describes the frames this session carries, so a caller can
// configure an encoder from the provider rather than from a platform's
// documentation.
//
// It hangs off the session because framing is a property of the joined
// channel rather than of the provider, which has an ordering consequence: a
// caller cannot know what to encode until it has joined. Audio prepared
// before joining may therefore need re-encoding, and a caller with a fixed
// clip to play is better off holding it as samples than as frames.
//
// STABLE for the life of the session. A provider on a platform that
// renegotiates framing mid-call must end the session rather than change
// what this returns, because nothing here carries a format change and a
// caller that read it once has no way to learn it was wrong.
Format() VoiceFormat
// Done closes when the session has ended, for any reason.
//
// It exists because the ordinary way a capture ends is not an error a
// caller sees. Discord tears the voice gateway down with NO callback when
// a moderator disconnects the bot, so without this a consumer learns its
// recording stopped by noticing frames stopped — which is
// indistinguishable from nobody talking. Measured: after a forced
// disconnect, frames ceased, Interruptions stayed empty across a
// 46-second gap, Stats froze, and Leave returned nil on a session that
// had been thrown out.
//
// It is a RECEIVE BARRIER, and that is the load-bearing half. Before it
// closes: every VoiceSink invocation has returned, no further invocation
// can begin, and Stats and Interruptions have reached their final values.
// Without that a consumer observing Done has no licence to make its final
// read, and a recording's last frames race the record that closes over
// them.
//
// A provider cannot forward this from the platform on every path — the
// forced-disconnect case fires no connection-lifecycle callback — so it
// watches for the state change itself, and needs a liveness fallback
// beside that watcher or the worst case is unbounded rather than slow.
Done() <-chan struct{}
// Err reports why the session ended, and is nil after a clean Leave.
//
// Meaningful only once Done is closed, and stable across repeated calls.
//
// FIRST CAUSE WINS. A Leave racing a platform-initiated termination must
// not overwrite a latched non-nil error with nil: Leave stays idempotent
// and still returns nil to its own caller, but what ended the session is
// decided once. A consumer that cannot tell "I left" from "I was removed
// and then left" is the defect this pair exists to close.
Err() error
}
VoiceSession is a joined voice channel.
type VoiceSink ¶ added in v0.5.0
type VoiceSink func(VoiceFrame)
VoiceSink receives one frame.
It is NEVER called concurrently. Calls are totally ordered, and a provider MUST serialise them however many receive paths it has underneath — a platform handing over one decoded stream per participant does not get to fan them into this callback from a goroutine each.
That is a stronger promise than synchronous, and it is stated separately because the two are easy to conflate: several goroutines can each call synchronously. It matters because the order of these calls is the ONLY total order a consumer has. Frames do not self-place — RTP sequence and timestamp spaces belong to a source, start at random offsets, and this contract does not carry the source identifier, so two speakers' numbers are not comparable — and on a platform where VoiceCapabilities.Sequenced is false there is no per-speaker ordering either. A consumer carrying frames anywhere else numbers them here, and nothing downstream can reconstruct that.
It is called SYNCHRONOUSLY, once per frame, on the provider's receive path, and the provider retains nothing after it returns. That is what lets a consumer refuse a frame before anything keeps it — a caller enforcing consent or excluding a speaker needs the decision to happen here rather than as a filter over something already written down.
The cost is deliberate and is the consumer's to carry: blocking in a sink blocks the provider reading from the platform, and audio arriving during the block is lost at the socket. A consumer that needs to do slow work must hand the frame to its own queue and return.
This is why voice does not deliver on a channel the way Reader.Messages does. A channel is a buffer, and a buffer decides to keep the frame before the consumer has said whether it may. Reader requires implementations not to block the read loop when a consumer is slow, and voice cannot honour that rule without breaking the retention one; where they conflict, retention wins.
A consumer that wants inbound audio AS a stream should build the channel itself, which is the point rather than an inconvenience: the filter goes above the buffer, where it belongs.
rx.Join(ctx, channelID, func(f VoiceFrame) {
speaker, known := f.Speaker()
if !known || !permitted(speaker) {
return // refused before anything holds it
}
select {
case audio <- f: // f owns its payload, so holding it is safe
default: // shed rather than stall the receive path
}
})
Offering that channel from the contract instead would put the buffer above the filter, and then either the provider keeps frames a consumer was never allowed to keep, or it takes a filter function as an argument — which is this callback with extra steps.
Note the asymmetry with VoiceSender.Stream, which does take a channel. It is not inconsistency: buffering audio the process itself produced 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.
type VoiceStats ¶ added in v0.5.0
type VoiceStats struct {
// Received counts frames delivered to the sink.
Received uint64
// Unattributed counts delivered frames whose speaker was not yet known.
// Not an error: a capture-quality signal. These frames ARRIVED — nothing
// was dropped — and the count says only that attribution lagged.
Unattributed uint64
}
VoiceStats is what a session has RECEIVED. There are no send-side counters, and the asymmetry is deliberate rather than an omission.
These two summarise what the session saw on the receive path. A consumer holding every frame could count them itself — that was NOT true of the loss counter that used to sit here, which is the only thing a consumer genuinely could not reconstruct, and it is gone for the reason below.
There are no send-side counters because on that side the caller already knows: it is the caller's own generator that stalled, and its own frames that were not produced in time. A counter there would report the caller's behaviour back to it.
THERE IS NO LOSS COUNTER, and its absence is a finding rather than a gap. Loss 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, and no arithmetic over the arrivals separates them. Two byte-indistinguishable streams can carry different true loss. A number was published here for several releases and could not be trusted: on Discord it read about 1% on calls that had lost nothing, and its value tracked a transport defect rather than the network.
Do not reconstruct one from VoiceFrame.Sequence. See that method's documentation for why the obvious attempt fails by three orders of magnitude.