comms

package module
v0.31.0 Latest Latest
Warning

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

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 9 Imported by: 0

README

comms

A chat platform contract for Go. Receive, reply and moderate across Discord, Slack or anything else, with no vendor SDK in the core

Go Reference Pipeline phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules. Docs: comms.go.phpboyscout.uk


Writing a bot that reads a Discord channel should not put a Discord SDK in your dependency graph, and supporting Slack later should not mean a second code path.

c, _ := comms.NewClient(ctx, "discord", comms.ClientConfig{
    Token: tok,
    Needs: []comms.Need{comms.NeedMessages},
})
defer c.Close()

p, _ := c.Provider(ctx, guild, comms.WithAllowedChannels(chans...))

for msg := range p.Reader.Messages() {
    p.Actor.ReplyInThread(ctx, msg.Ref(), "help", answer)
}

Your code depends on comms.Reader, never on a gateway client.

Design

  • No vendor SDK in the core. Every platform client lives behind a provider module boundary, so accepting a Reader (or authoring a provider) costs you this module and its one dependency, the toolkit's error package, which is itself standard-library-only. A depfootprint guard enforces it.
  • Reading and acting are different interfaces. A Reader observes; an Actor changes what people see. Construct read-only and there is no Actor at all, so an observe-only mode is enforced by the type system rather than by remembering to check a flag.
  • A registry, not a switch. Providers register at init() under a plain string key, so a platform this module has never heard of ships as your module with nothing contributed here.
  • Capabilities are opt-in. Moderation, member lookup, interactive components, slash commands, voice and reactions are among ten optional interfaces found by type assertion, on the Actor, except seeing who is in a voice channel and hearing reactions land, which are on the Reader so a read-only scope keeps them. A bridge with none of them is a legitimate provider.
  • One provider, one space. A provider serves a single guild, workspace or network, fixed at construction, so the contract never has to name a concept that differs on every platform.
  • A conformance harness. The compiler checks your method set; it cannot check that you honour an allowlist, return the right sentinel, or refuse to build an Actor when asked for read-only. RunProviderConformance does.

Plain channel posts, attachments, direct messages and channel history are deliberately absent; read What this contract does not do before planning anything around this module.

Providers

Platform Module
Discord comms-discord

Provider modules are thin adapters and carry no separate docs site; their documentation lives on the core site.

Install

go get gitlab.com/phpboyscout/go/comms

Status

Pre-1.0. The public API may change in a minor release.

Licence

MIT; see LICENSE.

Documentation

Overview

Package comms 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

Constants

This section is empty.

Variables

View Source
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("comms.not_connected", "comms: 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("comms.unsupported", "comms: 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("comms.forbidden", "comms: the bot lacks permission for this request")

	// ErrChannelDenied is returned when a caller names a channel outside this
	// scope — as a Ref to most methods, or as a bare channel id to
	// VoiceReceiver.Join. Outside means not in the allowlist, or, under
	// PublicChannels, not public, not of a requested kind, or of visibility the
	// provider cannot yet establish. Providers enforce the scope themselves; a
	// caller must not be able to reach past it by naming a channel directly.
	ErrChannelDenied = errors.NewSentinel("comms.channel_denied", "comms: channel outside this scope")

	// 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("comms.not_found", "comms: not found")

	// ErrAlreadyRegistered is returned by Register when a factory already
	// exists under that name.
	ErrAlreadyRegistered = errors.NewSentinel("comms.already_registered", "comms: 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("comms.already_scoped", "comms: this space already has a live scope on this client")

	// ErrInvalidName is returned by Register for an empty provider name.
	ErrInvalidName = errors.NewSentinel("comms.invalid_name", "comms: provider name must not be empty")

	// ErrNilFactory is returned by Register for a nil factory.
	ErrNilFactory = errors.NewSentinel("comms.nil_factory", "comms: 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 comms#8 for the validation sentinels above, which
	// could fold into this and are deliberately not being changed today.
	ErrInvalidArgument = errors.NewSentinel("comms.invalid_argument", "comms: 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("comms.empty_content", "comms: message content must not be empty")
	ErrNoChoices          = errors.NewSentinel("comms.no_choices", "comms: prompt must offer at least one choice")
	ErrEmptyChoiceKey     = errors.NewSentinel("comms.empty_choice_key", "comms: choice key must not be empty")
	ErrEmptyChoiceLabel   = errors.NewSentinel("comms.empty_choice_label", "comms: choice label must not be empty")
	ErrDuplicateChoiceKey = errors.NewSentinel("comms.duplicate_choice_key", "comms: choice keys must be unique")
	ErrEmptyTitle         = errors.NewSentinel("comms.empty_title", "comms: form title must not be empty")
	ErrNoFields           = errors.NewSentinel("comms.no_fields", "comms: form must have at least one field")
	ErrEmptyFieldKey      = errors.NewSentinel("comms.empty_field_key", "comms: field key must not be empty")
	ErrDuplicateFieldKey  = errors.NewSentinel("comms.duplicate_field_key", "comms: 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("comms.no_voice_session", "comms: 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("comms.voice_busy", "comms: a voice operation is already in progress")

	// Command validation.
	ErrEmptyCommandName    = errors.NewSentinel("comms.empty_command_name", "comms: command name must not be empty")
	ErrEmptyDescription    = errors.NewSentinel("comms.empty_description", "comms: command description must not be empty")
	ErrEmptyOptionName     = errors.NewSentinel("comms.empty_option_name", "comms: option name must not be empty")
	ErrDuplicateOptionName = errors.NewSentinel("comms.duplicate_option_name", "comms: 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("comms.invalid_command_name",
		"comms: command name must be 1-32 runes of lowercase letters, digits, hyphens or underscores")
	ErrInvalidDescription = errors.NewSentinel("comms.invalid_description",
		"comms: description must be 1-100 runes")
	ErrTooManyOptions = errors.NewSentinel("comms.too_many_options",
		"comms: at most 25 options, subcommands or groups at one level")
	ErrOptionsWithSubcommands = errors.NewSentinel("comms.options_with_subcommands",
		"comms: a command has options or subcommands, never both")
	ErrEmptyGroup = errors.NewSentinel("comms.empty_group",
		"comms: a subcommand group must contain at least one subcommand")
	ErrOptionOrder = errors.NewSentinel("comms.option_order",
		"comms: required options must precede optional ones")
	ErrUnknownOptionType = errors.NewSentinel("comms.unknown_option_type",
		"comms: option type is not one this contract defines")
	ErrCommandTooLarge = errors.NewSentinel("comms.command_too_large",
		"comms: names and descriptions 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

func Register(name string, f Factory) error

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 := comms.Register("discord", New); err != nil {
        panic("comms-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.
	//
	// It reads only a thread the scope reads: one whose parent channel the
	// allowlist admits. A channel that is not a thread is ErrInvalidArgument,
	// because a channel's backlog is never read, and a thread outside the
	// allowlist is ErrChannelDenied.
	ThreadHistory(ctx context.Context, threadID ID, limit int) ([]Message, error)
}

Actor changes what people see. Everything here is observable by somebody.

type AllowlistEditor

type AllowlistEditor interface {
	// AllowChannels admits channels, and the threads under them, to the
	// scope. Every id is checked first: one malformed id refuses the whole call
	// with ErrInvalidArgument and changes nothing. Adding a channel already
	// allowed is not an error. After the scope is closed it returns
	// ErrNotConnected.
	AllowChannels(ids ...ID) error

	// DisallowChannels removes channels, and the threads under them, from the
	// scope. It refuses and is idempotent as AllowChannels is. An event from a
	// removed channel is dropped, so a prompt posted there before the removal
	// never receives its answer, unless the scope admits interactions from
	// anywhere (WithInteractionsAnywhere), when only reads stop.
	DisallowChannels(ids ...ID) error
}

AllowlistEditor changes which channels a live scope reads (spec 0030), from the next event on, without reconnecting.

It governs what the allowlist always has: what the scope reads, and every call that takes a bare channel id, such as VoiceReceiver.Join, VoiceParticipants.Participants, ChannelManager, ChannelInspector, Poster.Post and Actor.ThreadHistory. A call taking a Ref is never governed, so removing a channel does not stop the bot replying, reacting, prompting or moderating there through a Ref, and a voice session already joined there carries on until the consumer leaves.

func AsAllowlistEditor

func AsAllowlistEditor(p *Provider) (AllowlistEditor, bool)

AsAllowlistEditor returns the provider's AllowlistEditor, 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 NeedAllowlistChanges was not declared, when the provider cannot edit its allowlist, and on a scope minted with PublicChannels, which has no allowlist to edit: its Reader reports so with a ReadsPublicChannels method returning true.

type Arg

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 BoolArg

func BoolArg(name string, v bool) Arg

BoolArg carries a true or false the platform rendered as a toggle.

func ChannelArg

func ChannelArg(name string, id ID) Arg

ChannelArg carries a channel the PLATFORM resolved, not a name somebody typed. That distinction is the whole reason option types exist.

func ChannelArgOf

func ChannelArgOf(name string, id ID, kind ChannelKind) Arg

ChannelArgOf carries a resolved channel together with its kind, for a provider whose platform resolves both.

func IntArg

func IntArg(name string, v int64) Arg

IntArg carries a whole number the platform parsed and validated.

func MemberArg

func MemberArg(name string, m Member) Arg

MemberArg carries a user option naming a member of the space, as the platform resolved them (spec 0033). Providers use it in place of UserArg when they have the member.

func NumberArg

func NumberArg(name string, v float64) Arg

NumberArg carries a fractional number the platform parsed and validated.

func RoleArg

func RoleArg(name string, id ID) Arg

RoleArg carries a role the platform resolved.

func StringArg

func StringArg(name, v string) Arg

StringArg carries text the user typed, or text a caller asked for by declaring OptionString.

func UserArg

func UserArg(name string, id ID) Arg

UserArg carries a user the platform resolved.

type Args

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

func NewArgs(args ...Arg) Args

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) Bool

func (a Args) Bool(name string) (bool, bool)

Bool returns a true-or-false argument.

func (Args) Channel

func (a Args) Channel(name string) (ID, bool)

Channel returns a channel the platform resolved.

func (Args) ChannelKind

func (a Args) ChannelKind(name string) (ChannelKind, bool)

ChannelKind returns the kind of a channel the platform resolved, beside Channel. ChannelUnknown with true means the channel arrived but its kind did not; false means the option is absent or is not a channel.

Re-check it even where the option asked the platform to narrow its picker: narrowing is a convenience, never the authority (CommandOption.ChannelKinds).

func (Args) Int

func (a Args) Int(name string) (int64, bool)

Int returns a whole-number argument.

func (Args) Member

func (a Args) Member(name string) (Member, bool)

Member returns the member of the space a user option names, as the platform resolved them, so DisplayName is always filled when it answers true. It answers false when the option is absent, when the person is not a member of the space, or when the provider resolved only an id; User still gives the id. The names are chosen by the person and are untrusted.

func (Args) Number

func (a Args) Number(name string) (float64, bool)

Number returns a fractional-number argument.

func (Args) Role

func (a Args) Role(name string) (ID, bool)

Role returns a role the platform resolved.

func (Args) String

func (a Args) String(name string) (string, bool)

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.

func (Args) User

func (a Args) User(name string) (ID, bool)

User returns a user the platform resolved.

type Author

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.

func AsAuthor

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

AsAuthor returns the provider's Author, if it has one.

False when the platform cannot change what a bot said, when this build has no such support, and when NeedAuthoring was not declared — which is the reason to check first if this is unexpectedly false.

type ChannelInfo

type ChannelInfo struct {
	ID   ID
	Name string
	// Kind is ChannelUnknown for a category: nothing can be done in one.
	Kind       ChannelKind
	IsCategory bool
	// Category is the category the channel sits in, empty when it is in none
	// or is one.
	Category ID
}

ChannelInfo is a channel as the platform reported it when asked.

type ChannelInspector

type ChannelInspector interface {
	// Channel reads one channel. It refuses an empty or malformed id with
	// ErrInvalidArgument, then a channel outside the scope's allowlist with
	// ErrChannelDenied, before reaching the platform. A thread is denied unless
	// its own id is allowed, and so is a channel created but not yet allowed.
	// Before Connect it returns ErrNotConnected; a channel the platform does
	// not know is ErrNotFound, and one the bot cannot see is ErrForbidden.
	Channel(ctx context.Context, id ID) (ChannelInfo, error)
}

ChannelInspector reads a channel's current details from the platform (spec 0033).

Every call reads fresh, never from a cache, because the question it exists for is "is this still the name I gave it?". There is no conditional rename: between reading a name and ChannelManager.Rename a person can rename the channel, a window of one request's round trip.

func AsChannelInspector

func AsChannelInspector(p *Provider) (ChannelInspector, bool)

AsChannelInspector returns the provider's ChannelInspector, if it has one.

Asserts on Provider.Reader, so a read-only scope has it, as SpaceInspector is. Answers false when NeedChannelDetails was not declared.

type ChannelKind

type ChannelKind int

ChannelKind is what a consumer can do in a channel, in terms every platform can report honestly (spec 0027).

The values are what a consumer acts on, not a mirror of any platform's own channel types. A kind a provider cannot place reports ChannelUnknown, which is also the zero value, so a provider that does not report kinds reports "don't know" rather than a wrong answer.

const (
	// ChannelUnknown means the provider could not tell.
	ChannelUnknown ChannelKind = iota
	// ChannelText is a channel people post messages in.
	ChannelText
	// ChannelVoice is a channel people talk in, where everyone who joins may
	// speak. VoiceReceiver.Join and VoiceParticipants accept it.
	ChannelVoice
	// ChannelThread is a thread under another channel, on a platform that
	// addresses a thread as a channel of its own. A platform that models a
	// thread as a reply chain reports the parent's kind and Ref.ThreadID.
	ChannelThread
	// ChannelStage is a channel with an audience: whoever joins listens, and
	// only invited speakers talk. VoiceReceiver.Join accepts it, joining as
	// audience, so VoiceSession.CanSend reports false until promoted.
	ChannelStage
	// ChannelAnnouncement is a text channel whose posts are broadcast to other
	// spaces that follow it. Told apart from ChannelText because a consumer
	// reading conversation does not want broadcasts (spec 0028).
	ChannelAnnouncement
	// ChannelForum is a channel that holds only threads, each a post, so a
	// message never arrives in it directly, only in its threads.
	ChannelForum
)

func (ChannelKind) String

func (k ChannelKind) String() string

String names the kind, for logs and error text.

type ChannelManager

type ChannelManager interface {
	// CreateCategory creates a category, visible as v says.
	CreateCategory(ctx context.Context, name string, v Visibility) (ID, error)

	// CreateChannel creates a text or voice channel. The bot keeps its own
	// access to a private channel it creates.
	CreateChannel(ctx context.Context, spec ChannelSpec) (CreatedChannel, error)

	// ShowChannel lets the named members and roles take part in a channel.
	ShowChannel(ctx context.Context, channel ID, to Visibility) error

	// HideChannel takes away what ShowChannel gave. It does not hide a channel everyone can
	// see, and it refuses to name the bot itself.
	HideChannel(ctx context.Context, channel ID, from Visibility) error

	// Rename renames a channel or category.
	Rename(ctx context.Context, channel ID, name string) error

	// Move puts a channel into a category. The channel keeps its own
	// visibility.
	Move(ctx context.Context, channel, category ID) error
}

ChannelManager creates channels and changes who can see them (spec 0032). It never deletes anything.

Every method that names an existing channel or category refuses one outside the scope's allowlist with ErrChannelDenied, before reaching the platform. A created channel is not allowlisted; the consumer adds it with AllowlistEditor, then manages it.

func AsChannelManager

func AsChannelManager(p *Provider) (ChannelManager, bool)

AsChannelManager returns the provider's ChannelManager, if it has one.

Answers false without NeedChannelManagement, on a read-only scope, and on a PublicChannels scope, which has no allowlist to bound what is managed.

type ChannelSpec

type ChannelSpec struct {
	Name string
	// Kind is ChannelText, ChannelVoice or ChannelStage. On a stage,
	// Visibility is the audience; Floor gives the floor (spec 0035).
	Kind ChannelKind
	// Category is optional, and must be in the scope's allowlist.
	Category   ID
	Visibility Visibility
}

ChannelSpec is a channel to create.

func (ChannelSpec) Validate

func (s ChannelSpec) Validate() error

Validate refuses, with ErrInvalidArgument, an empty name, a kind other than text or voice, or an invalid Visibility.

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

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

func NewClient(_ context.Context, name string, cfg ClientConfig) (Client, error)

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

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

	// Mentions is what the bot's posts may notify, for every scope this client
	// mints (spec 0031). The zero value notifies nobody: a provider MUST stop
	// its platform notifying anyone from what it posts, including text echoed
	// from a user, unless this allows it.
	Mentions MentionPolicy
}

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

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

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"
)

func (Codec) String

func (c Codec) String() string

String implements fmt.Stringer.

func (Codec) Valid

func (c Codec) Valid() bool

Valid reports whether a codec has been named at all. It says nothing about whether anyone can decode it.

type CommandGroup

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

	// ChannelKinds asks the platform to offer only these kinds in an
	// OptionChannel's picker. Empty means any channel. Valid only on
	// OptionChannel, and ChannelUnknown is not a kind to narrow to.
	//
	// A convenience, never the authority, as with CommandSpec.RequiredRoles: a
	// provider that cannot narrow still registers the command, and a caller
	// re-checks Args.ChannelKind either way.
	ChannelKinds []ChannelKind
}

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

	// AnswersPublicly declares that the answers to this command, and to its
	// subcommands, are public, so a provider may acknowledge an invocation
	// publicly. The zero value is private, which is the safe default for every
	// existing caller.
	AnswersPublicly bool
}

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

func AsCommands(p *Provider) (Commands, bool)

AsCommands returns the provider's Commands, if it has one.

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".

func (ConnState) Healthy

func (c ConnState) Healthy() bool

Healthy reports whether the connection is usable now. It is deliberately independent of LastReconnectLostEvents: a resumed-with-loss session is healthy, and the loss is a separate signal worth alerting on in its own right.

type CreatedChannel

type CreatedChannel struct {
	ID   ID
	Kind ChannelKind
}

CreatedChannel is a channel the platform made. Kind is read from the platform's answer, not echoed from the request.

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.

func Lookup

func Lookup(name string) (Factory, bool)

Lookup returns the factory registered under name.

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 Floor

type Floor interface {
	// InviteToSpeak invites a member in the stage's audience to speak. Until
	// they accept they are still audience. An empty id is ErrInvalidArgument;
	// before Connect it is ErrNotConnected.
	InviteToSpeak(ctx context.Context, stage, member ID) error

	// ReturnToAudience takes the floor back from a member, or withdraws an
	// invitation. It refuses as InviteToSpeak does.
	ReturnToAudience(ctx context.Context, stage, member ID) error
}

Floor gives and takes the floor on a stage (spec 0035).

Speaking on a stage is not a permission a channel can grant: the bot invites a member who is in the stage's audience, and the member accepts on their platform, once per time they join. Nobody is put on air without agreeing, and nobody can put themselves there. A human moderator can still invite people through the platform, so a consumer that records by consent filters by member, not by who is speaking.

Both methods take a bare channel id, so the scope's allowlist bounds them: a stage outside it is ErrChannelDenied before reaching the platform.

func AsFloor

func AsFloor(p *Provider) (Floor, bool)

AsFloor returns the provider's Floor, if it has one. It answers false without NeedStageFloor, on a read-only scope, and on a PublicChannels scope, which has no allowlist to bound it.

type FormSpec

type FormSpec struct {
	// Key identifies the form, and comes back on its submission in
	// Interaction.ChoiceKey, so a form is routed as a button is (spec 0036).
	// Optional: empty means the consumer routes it some other way. Not
	// checked here, since a form has one key and empty is valid; a provider
	// refuses a key longer than its platform can carry with
	// ErrInvalidArgument.
	Key    string
	Title  string
	Fields []FieldSpec

	// AnswersPublicly declares that the answer to this form's submission is
	// public, so a provider may acknowledge the submission publicly. The zero
	// value is private, which is the safe default for every existing caller.
	AnswersPublicly bool
}

FormSpec is a small set of text inputs, opened in response to an interaction.

func (FormSpec) Validate

func (f FormSpec) Validate() error

Validate reports whether the form's values can be read back unambiguously.

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.

func (ID) String

func (i ID) String() string

String returns the identifier as a plain string.

func (ID) Valid

func (i ID) Valid() bool

Valid reports whether the identifier is populated. It says nothing about whether the platform knows it — only that it is not the zero value.

type Indicator

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

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

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

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
	// ChannelKind is the kind of Ref.ChannelID, the channel the person acted
	// in. ChannelUnknown where the provider cannot tell.
	ChannelKind ChannelKind
	// By is who acted. Authorisation is decided from By.Roles — never from a
	// claim in message content, and never from By.Name.
	By Member

	// OutsideAllowlist is true when the interaction came from a channel the
	// scope does not read, which only WithInteractionsAnywhere admits (spec
	// 0037). A thread counts as read when its parent is. A provider that can
	// deliver an interaction from an unread channel MUST set it; the zero value
	// is right only for one that delivers solely from read channels.
	OutsideAllowlist bool

	// ChoiceKey is the key of the choice selected (ChoiceSelected) or the form
	// submitted (FormSubmitted, from FormSpec.Key). Choices and forms share
	// one namespace, so check Type before matching on it.
	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 is the account's own name: the username, on platforms that have
	// one. Stable across spaces, and rarely what people in a space call the
	// person.
	Name string

	// DisplayName is what the platform shows for this member in this space.
	//
	// On Discord that is the server nickname, then the global display name,
	// then the username, which is the platform's own precedence. A provider
	// fills it on every Member it builds, with Name where the platform has no
	// such concept, so a consumer reads DisplayName and never needs a fallback
	// of its own. It is empty only where Name is: a VoiceParticipant whose
	// provider reports ReportsName false carries neither.
	//
	// It exists because a label built from Name is often unrecognisable at
	// the table it is shown to. A username is chosen once and globally; a
	// nickname is chosen for the room.
	DisplayName 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 and DisplayName are for display. Authorisation is decided from Roles and nothing else: both names are user-controlled on most platforms and neither is an identity.

func (Member) HasAnyRole

func (m Member) HasAnyRole(roles ...ID) bool

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 MentionPolicy

type MentionPolicy struct {
	// Members allows a prompt or a post to notify the members it names in
	// PromptSpec.Notify or PostSpec.Notify, and nobody else.
	Members bool
}

MentionPolicy is what a posted message may notify (spec 0031). The zero value notifies nobody, whatever the message's text contains.

func (MentionPolicy) Check

func (m MentionPolicy) Check(p PromptSpec) error

Check refuses, with ErrInvalidArgument, a prompt that would notify someone this policy does not allow. A provider calls it before reaching the platform, so a consumer learns of the misconfiguration instead of a prompt that silently reaches nobody.

func (MentionPolicy) CheckNotify

func (m MentionPolicy) CheckNotify(notify []ID) error

CheckNotify is Check for any message naming members to notify, such as a PostSpec (spec 0034).

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.

func (Message) Ref

func (m Message) Ref() Ref

Ref returns what to act upon for this message, so a caller never assembles identifiers by hand. Getting ThreadID wrong is how a reply lands somewhere nobody is reading.

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

func AsModerator(p *Provider) (Moderator, bool)

AsModerator returns the provider's Moderator, if it has one.

type Need

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"

	// NeedSpaceDetails is SpaceInspector: reading the space's current name.
	//
	// Buys no inbound event and, on Discord, no intent. It is declared so a
	// consumer's needs stay the whole list of what it uses.
	NeedSpaceDetails Need = "space_details"

	// NeedAllowlistChanges is AllowlistEditor: changing which channels a live
	// scope reads.
	//
	// Buys no inbound event and, on Discord, no intent. Declared so a consumer
	// that changes what it reads at runtime says so.
	NeedAllowlistChanges Need = "allowlist_changes"

	// NeedChannelDetails is ChannelInspector: reading one allowed channel's
	// current name, kind and category.
	//
	// Buys no inbound event and, on Discord, no intent. Separate from
	// NeedSpaceDetails, so declaring that one never grants channel reads.
	NeedChannelDetails Need = "channel_details"

	// NeedPosting is Poster: posting into a channel on the consumer's own
	// initiative.
	//
	// Buys no intent on Discord. Declared so a deployment that must never
	// speak unprompted is built without it.
	NeedPosting Need = "posting"

	// NeedStageFloor is Floor: inviting members to speak on a stage.
	//
	// Buys no intent on Discord, but the bot needs Mute Members across the
	// guild, a power over every voice channel, so it is declared apart from
	// channel management.
	NeedStageFloor Need = "stage_floor"

	// NeedChannelManagement is ChannelManager: creating channels and
	// categories, and changing who can see them.
	//
	// Buys no intent on Discord; the bot needs Manage Channels and Manage
	// Roles granted when it is authorised.
	NeedChannelManagement Need = "channel_management"

	// NeedRoleManagement is RoleManager: creating roles and giving them to
	// members. Separate from NeedChannelManagement because a role is a
	// privilege no allowlist bounds.
	//
	// Buys no intent on Discord; the bot needs Manage Roles.
	NeedRoleManagement Need = "role_management"
)

func (Need) String

func (n Need) String() string

String implements fmt.Stringer.

type OptionType

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 PostSpec

type PostSpec struct {
	Content string

	// Notify lists the members this post is for. The provider addresses them
	// and notifies exactly them, as PromptSpec.Notify does. It requires
	// ClientConfig.Mentions.Members; without it the post notifies nobody.
	Notify []ID
}

PostSpec is a message to post.

func (PostSpec) Validate

func (p PostSpec) Validate() error

Validate refuses empty content with ErrEmptyContent, and an empty or repeated Notify id with ErrInvalidArgument.

type Poster

type Poster interface {
	// Post posts p into channel and returns the Ref of the message, which
	// Author.Replace and Delete accept. It refuses an invalid PostSpec, then
	// an empty or malformed channel with ErrInvalidArgument, then a channel
	// outside the allowlist with ErrChannelDenied, before reaching the
	// platform. Before Connect it returns ErrNotConnected.
	Post(ctx context.Context, channel ID, p PostSpec) (Ref, error)
}

Poster posts a message into a channel on the consumer's own initiative (spec 0034): a greeting in a channel the consumer created, or a summary. An answer to a question belongs in ReplyInThread, in a thread.

It takes a bare channel id, so it is bounded by the scope's allowlist as every such call is: a channel outside it is ErrChannelDenied before anything reaches the platform, and a thread is postable only when its own id is allowed.

func AsPoster

func AsPoster(p *Provider) (Poster, bool)

AsPoster returns the provider's Poster, if it has one. It answers false when NeedPosting was not declared, and on a read-only scope, which has no Actor.

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

	// Notify lists the members this prompt is for. The provider addresses them
	// in the posted message and notifies exactly them, whatever the content
	// says. It requires ClientConfig.Mentions.Members (spec 0031).
	Notify []ID
}

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 (*Provider) Declared

func (p *Provider) Declared(n Need) bool

Declared reports whether n was asked for by the Client this scope came from.

Nil-safe, like the As... helpers that use it: a nil Provider declares nothing.

type ProviderOption

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

func WithAllowedChannels(ids ...ID) ProviderOption

WithAllowedChannels sets the channels this scope may read from.

func WithInteractionsAnywhere

func WithInteractionsAnywhere() ProviderOption

WithInteractionsAnywhere lets people command the bot from any channel of the space it serves, without widening what the scope reads.

func WithPublicChannels

func WithPublicChannels(kinds ...ChannelKind) ProviderOption

WithPublicChannels asks for every channel of these kinds that the whole space can read, decided per event by the provider. The kinds are required: a mode meaning "every kind" would widen silently as a platform added kinds.

func WithReadOnly

func WithReadOnly() ProviderOption

WithReadOnly asks for an observing scope, yielding a nil Actor.

type Reaction

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

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

AddedReaction is one account putting emoji on the message at ref.

func ClearedEmoji

func ClearedEmoji(ref Ref, parent ID, emoji string) Reaction

ClearedEmoji is every reaction of one emoji on the message at ref going at once.

func ClearedReactions

func ClearedReactions(ref Ref, parent ID) Reaction

ClearedReactions is every reaction on the message at ref going at once.

func RemovedReaction

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

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

func (r Reaction) Change() ReactionChange

Change is which of the four things happened.

func (Reaction) Emoji

func (r Reaction) Emoji() string

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

func (r Reaction) Member() (Member, bool)

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

func (r Reaction) ParentID() ID

ParentID is the parent channel when the message is in a thread, as Message.ParentID is, and empty otherwise.

func (Reaction) Ref

func (r Reaction) Ref() Ref

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

func (r Reaction) UserID() ID

UserID is the account whose reaction it is. Empty for the two cleared changes, which name nobody.

func (Reaction) Variant

func (r Reaction) Variant() string

Variant is the platform's name for a second form of the emoji, and empty for the ordinary form. See WithVariant.

func (Reaction) WithMember

func (r Reaction) WithMember(m Member) Reaction

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

func (r Reaction) WithVariant(v string) Reaction

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

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

func (c ReactionChange) String() string

String implements fmt.Stringer.

type ReactionObserver

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

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: mint the scope with WithReadOnly and there is no Actor to misuse.

type Ref

type Ref struct {
	ChannelID ID
	MessageID ID
	ThreadID  ID
}

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.

That acknowledgement MUST NOT make visible anything the caller's answer asks to keep private. Where a provider cannot yet know the answer's visibility, it acknowledges privately (spec 0026).

type RoleManager

type RoleManager interface {
	// CreateRole creates a role with no permissions, that cannot be
	// mentioned, existing only to be named in a Visibility.
	CreateRole(ctx context.Context, name string) (ID, error)

	// GrantRole gives a member a role.
	GrantRole(ctx context.Context, member, role ID) error

	// RevokeRole takes a role from a member.
	RevokeRole(ctx context.Context, member, role ID) error
}

RoleManager creates roles and gives them to members (spec 0032). It never deletes a role.

A role can carry any privilege below the bot's own, so GrantRole is privilege escalation in one call, and roles belong to the whole space, so no channel allowlist bounds them. It MUST NOT be reachable from anything a person typed or a model produced.

func AsRoleManager

func AsRoleManager(p *Provider) (RoleManager, bool)

AsRoleManager returns the provider's RoleManager, if it has one. It answers false where AsChannelManager does, gated on NeedRoleManagement instead.

type Scope

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. It is the set the scope was minted with: an
	// AllowlistEditor changes the live scope, never this record of it.
	AllowedChannels []ID

	// PublicChannels, when set, asks for every channel of these kinds that the
	// whole space can read, and the public threads under them, decided per
	// event by the provider (spec 0028). A thread is judged by its parent, and a
	// private thread is never read. A provider that cannot judge what the whole
	// space can read refuses such a scope with ErrUnsupported.
	//
	// Set it with WithPublicChannels. It cannot be combined with
	// AllowedChannels; Validate refuses the pair.
	PublicChannels []ChannelKind

	// 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

	// InteractionsAnywhere admits commands, choices and forms from any channel
	// of the space, not only those the scope reads (spec 0037). Reads are not
	// widened: messages and reactions still come only from read channels, and
	// every call that returns content through a bare channel id still refuses
	// one outside the allowlist. An interaction from an unread channel is
	// marked Interaction.OutsideAllowlist. It does nothing on a read-only scope,
	// which receives no interactions.
	InteractionsAnywhere 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

func NewScope(space ID, opts ...ProviderOption) Scope

NewScope applies options to a space, for a provider implementing Client.

func (Scope) ChannelAllowed

func (s Scope) ChannelAllowed(id ID) bool

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.

Under PublicChannels it answers false for everything, because whether a channel is public is platform state a Scope does not hold. The provider judges it.

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.

It answers about the allowlist the scope was minted with. A scope changed through an AllowlistEditor answers from its live allowlist, which this value does not follow. To ask about an interaction's channel, read Interaction.OutsideAllowlist, which is decided against the live allowlist.

func (Scope) ReadsPublicChannels

func (s Scope) ReadsPublicChannels() bool

ReadsPublicChannels reports whether this scope asked for the public mode.

func (Scope) Validate

func (s Scope) Validate() error

Validate reports a scope no provider can serve, wrapping ErrInvalidArgument: the public mode combined with an allowlist, or asked for with no kinds or with ChannelUnknown. Client.Provider calls it before anything else.

type SpaceInfo

type SpaceInfo struct {
	// ID repeats the scope's space, so a SpaceInfo passed on alone still says
	// which space it describes.
	ID   ID
	Name string
}

SpaceInfo is what any platform can report about a space. Name is a snapshot, the name when the call was answered; nothing reports a rename.

type SpaceInspector

type SpaceInspector interface {
	// Space returns the space's current details. Returns ErrNotConnected
	// before Connect.
	Space(ctx context.Context) (SpaceInfo, error)
}

SpaceInspector reads details of the space a scope serves (spec 0029).

func AsSpaceInspector

func AsSpaceInspector(p *Provider) (SpaceInspector, bool)

AsSpaceInspector returns the provider's SpaceInspector, 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 NeedSpaceDetails was not declared, as well as when the provider cannot answer.

type Status

type Status int

Status is a connection's coarse state.

const (
	// StatusDisconnected means there is no live session.
	StatusDisconnected Status = iota
	// StatusReconnecting means a session was lost and recovery is in progress.
	StatusReconnecting
	// StatusConnected means a session is live and receiving.
	StatusConnected
)

func (Status) String

func (s Status) String() string

String implements fmt.Stringer.

type Subcommand

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 Visibility

type Visibility struct {
	Members []ID
	Roles   []ID
}

Visibility names the members and roles who can take part in a channel. Empty means no restriction of its own: visible to whoever can see its category, or to everyone outside one.

func (Visibility) Empty

func (v Visibility) Empty() bool

Empty reports whether v names nobody.

func (Visibility) Validate

func (v Visibility) Validate() error

Validate refuses, with ErrInvalidArgument, an empty or repeated id, or more than 100 names.

type VoiceCapabilities

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

	// Audience reports that the participant is in a stage's audience, so they
	// listen and cannot be heard until invited to speak. Meaningful only when
	// ReportsAudience is true, and only in a ChannelStage. Kept apart from
	// SpaceMuted: a stage holding someone in the audience and a moderator
	// muting them are different facts to a consumer seeking consent.
	Audience 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

type VoiceParticipantCapabilities struct {
	// ReportsMuteState reports whether the four mute and deafen booleans are
	// meaningful.
	ReportsMuteState bool

	// ReportsName reports whether Member.Name and Member.DisplayName are
	// filled in. They travel together: DisplayName falls back to Name, so a
	// provider that can report one can report both.
	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

	// ReportsAudience reports whether VoiceParticipant.Audience is filled in.
	// False on a platform with no audience concept, where Audience false must
	// not be read as "on stage".
	ReportsAudience 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

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

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

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.
	//
	// A provider that knows the channel's kind refuses one that is neither
	// ChannelVoice nor ChannelStage with ErrInvalidArgument, naming the kind,
	// before anything reaches the platform (spec 0027). On a ChannelStage the
	// bot joins as audience.
	//
	// 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

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

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 comms.VoiceFrame) {
    _ = tx.Send(ctx, synthesise(f))
})

Never send from inside a sink. Hand the frame to your own goroutine and send from there. This follows from 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

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

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

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

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.

Directories

Path Synopsis
Package dense parses one line of dense chat-command text into positionals and flags, in the grammar spec 0024 fixes: positionals first, then flags that take every following word, with presence kept apart from values.
Package dense parses one line of dense chat-command text into positionals and flags, in the grammar spec 0024 fixes: positionals first, then flags that take every following word, with presence kept apart from values.
Package test provides a conformance harness for comms providers.
Package test provides a conformance harness for comms providers.

Jump to

Keyboard shortcuts

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