Skip to content

Providers

A provider teaches this module how to talk to one platform. Providers ship as their own modules and register themselves; nothing is contributed to this repository.

Provider modules are thin adapters and carry no separate docs site, so this page is where their behaviour is documented.

Which providers exist

Platform Module Registers as Capabilities
Discord chat-platform-discord discord Moderator, MemberInspector, Interactive, Commands, Author, VoiceParticipants, Indicator, ReactionObserver§, VoiceReceiver, VoiceSender
Discord, audio only chat-platform-discord/voicenode not registered; reports discord-voicenode VoiceReceiver, VoiceSender

Voice from chat-platform-discord v0.6.0, and only in a build with CGO. See voice. It is absent rather than failing, so AsVoiceReceiver answers false in a build without it. VoiceParticipants is not build-tagged: it is answered from the gateway's voice-state cache and is present in every build.

Indicator from chat-platform-discord v0.12.0, paired with this module's v0.14.0.

§ ReactionObserver from chat-platform-discord v0.13.0, paired with this module's v0.15.0.

voicenode from chat-platform-discord v0.14.0, paired with this module's v0.16.0, and CGO only. It is a second Client in the same module with no gateway, no REST and no token, built by hand with voicenode.New rather than found in the registry. See the Discord voice node.

That is the complete list. There is no Slack, Matrix, IRC or Teams provider. The contract is written so those can exist, and none has been written yet. If you need one, author it; it does not need to live here and it does not need anybody's release.

How to enable a provider

Blank-import the module and reach it by name:

import _ "gitlab.com/phpboyscout/go/chat-platform-discord"

factory, ok := chatplatform.Lookup("discord")

The import is what makes the name resolvable, because registration happens in the provider's init(). A binary that does not import the provider gets false from Lookup and ErrNotFound from New.

Registered() returns every registered name, sorted, which is what to use when validating a configured platform string and reporting the alternatives. See The provider registry.

Which sentinels the Discord provider actually returns

The contract defines sentinels; providers decide which ones they use. For the Discord provider:

Sentinel Returned? When
ErrNotConnected yes any Actor or Interactive method before Connect succeeds; RegisterCommands, Participants, Show and Clear likewise
ErrNotFound yes an empty identifier, a malformed response token, a member with no join date, a voice channel the cache does not hold, or a 404 from Author.Replace and Author.Delete
ErrForbidden yes a 403 from Author and Indicator methods; a voice join the bot lacks permission for, and a session Discord closes with 4014; a channel Participants cannot see
ErrChannelDenied yes VoiceReceiver.Join and VoiceParticipants.Participants named a channel outside the allowlist, the two methods that take a bare channel id. No message-taking method checks it; the allowlist is applied to inbound events
ErrInvalidArgument yes a malformed identifier, a nil sink or frame channel, and Indicator.Show given empty text or text over 32 runes
ErrUnsupported no every capability the provider declares is implemented

Anything else that goes wrong arrives as a wrapped Discord REST or gateway error prefixed discord:. That includes a member who is not in the guild: a 404 from MemberInspector.Member surfaces as a wrapped REST error rather than ErrNotFound, so do not rely on errors.Is(err, chatplatform.ErrNotFound) to mean "no such member". Only the Author methods map a 404, because there it can only mean the message is gone.

Discord-specific errors

Two sentinels are defined by the provider rather than the contract. Matching them means importing the provider module directly, which is a coupling worth noticing before you add it.

Error Meaning
discord.ErrFormWindowClosed OpenForm was called after the interaction had already been acknowledged
discord.ErrTooManyChoices a prompt offers more than 25 choices

ErrFormWindowClosed is deliberately distinct from ErrUnsupported: the capability exists, the moment passed. A caller that cannot tell them apart will retry something that can never succeed.

What the Discord provider needs before it works

Beyond a bot token and the configuration every provider takes:

  • The bot must be in the guild named by the scope's space, and it must be that guild's snowflake.
  • Privileged intents must be enabled for the application in Discord's developer portal. The provider requests GUILD_MESSAGES, MESSAGE_CONTENT and GUILD_MEMBERS; the last two are privileged, and Discord will not deliver them otherwise. A bot that connects and receives messages with an empty Content is almost always missing the Message Content intent. NeedReactions requests GUILD_MESSAGE_REACTIONS, which is not privileged and does not bring message content with it.
  • Permissions in the channels you allowlist: reading, sending and creating public threads, and for the Moderator capability, managing messages and moderating members. Indicator needs Change Nickname on the guild, and refuses with ErrForbidden without it.

None of this is validated by the provider. A missing permission surfaces as a wrapped Discord error at the moment you use it.

How Discord identifiers map onto ID

Every chatplatform.ID is a Discord snowflake rendered as a decimal string.

Input Result
a valid snowflake, e.g. "1531227937678954747" accepted
"" an error wrapping ErrNotFound, since the caller had nothing to act on
anything else an error wrapping ErrInvalidArgument, with the parse error beside it for errors.As

The scope's space and every entry in its allowlist are parsed at construction, so a malformed one fails before any network call. A malformed channel is rejected rather than skipped: dropping it silently would produce an allowlist that is not the one the operator wrote.

Custom emoji passed to React use Discord's own name:id form. Unicode emoji are passed through as-is.

Which messages the Discord provider delivers

An inbound message reaches Messages() only if both hold:

  1. Its channel is in the scope's allowlist, or it is a thread whose parent channel is.
  2. Its author is not a bot.

Bot-authored messages are dropped by default, including the bot's own. There is no Config field to change this. The provider exposes a WithBotMessages() option, which is a test affordance deliberately kept out of Config so no production configuration file can reach it.

WithClient(*bot.Client) is the other test affordance: it injects a pre-built disgo client so the provider can be driven through a fake gateway. It carries one precondition the provider enforces at construction: an injected client serving voice, voice participants or a control hook must cache voice states (cache.FlagVoiceStates), or every snapshot reads empty: CanSend fails closed on every call and a broker's reconciliation releases every claim it inherits, and nothing about either looks like a missing cache flag from the outside. The refusal is ErrInvalidArgument, naming the flag. A text-only client is not asked for it.

Discord models a thread as its own channel, so a message posted inside a thread carries the thread's ID as its channel. The provider resolves the thread and its parent from the channel cache and admits the message on the parent's behalf, which is what lets the bot hear replies in the threads it creates. On such a message Message.ThreadID is the thread and Message.ParentID is the parent, so a Ref taken from Message.Ref() continues the thread rather than opening another.

A cache miss yields neither, and the allowlist falls back to an exact match on the channel ID, so a miss can only ever be more restrictive. ParentID is left empty rather than defaulted to the thread's own ID, because a consumer tests ParentID against its allowlist and a plausible wrong ID would admit the wrong thing.

The inbound channel is buffered at 256 messages. When it is full the provider drops rather than blocking the gateway read loop. Losing one message is bad; losing the session loses every message after it. Nothing reports a drop.

Which reactions the Discord provider delivers

A reaction reaches Reactions() under the same allowlist rule as a message, threads of an allowed channel included, with the parent resolved from the channel cache exactly as above. Its Ref() has the shape Message.Ref() and ReplyInThread produce, so it joins to either by MessageID.

Two filters differ from Messages(). The bot's own reactions are never delivered, compared against the identity the session authenticated as; the drop fails closed, so a reaction that arrives before the session knows who it is (not a state a live session reaches, since READY carries the identity) is dropped rather than delivered unrecognisable. Other bots' reactions are delivered, because on a removal Discord does not say whether the account is a bot, and dropping a bot's add while delivering its removal would retract a verdict the consumer never saw.

The four guild reaction events map to the four ReactionChange values. Member() is set on an add that carried a member and never on a removal: Discord sends none there, and when it omits one on an add the provider attaches nothing rather than a zero member. Discord's burst (super) reaction arrives as Variant() == "burst" on add and remove; it is a second reaction one account can hold beside the ordinary form, not an animation. A cleared emoji carries no variant because the event carries none.

Emoji() is what React takes: the Unicode character, or name:id for a custom emoji. A custom emoji that has since been deleted arrives with an empty Emoji(), because Discord sends its id without a name; Change() remains the discriminator.

Direct-message reactions never arrive, because that intent is not requested. The feed is buffered at 256 and drops silently when full, as Messages() does.

How the Discord provider handles interactions

Interactions are only delivered when the provider is writable. A read-only provider receives none, because interactions are answers the bot sends and an observer has no business receiving them. They are also filtered by the channel allowlist, and only slash commands are delivered; other application-command types are ignored.

The acknowledgement is lazy. Discord fails an interaction left unanswered for three seconds, and the contract requires providers to acknowledge on receipt so a caller can retrieve documents and call a model without meeting that deadline. But Discord accepts a form only as the initial response, so acknowledging eagerly would make OpenForm permanently impossible.

The provider therefore gives the caller two seconds of first refusal on the response slot and acknowledges on its behalf only if the caller does not take it. What each method then does depends on whether the slot is still free:

Method Slot free After the acknowledgement
Respond initial response follow-up message
UpdateSource update the source message edit the original response
OpenForm opens the form discord.ErrFormWindowClosed

An interaction stays answerable for fifteen minutes, Discord's own limit, after which the provider forgets it.

Discord's limits on prompts and forms

Limit Value What happens past it
choices per prompt 25 (five rows of five) discord.ErrTooManyChoices; nothing is posted
buttons per row 5 choices wrap onto the next row automatically
interaction lifetime 15 minutes the token is forgotten
initial-response window 2 seconds for the caller, 3 for Discord the provider acknowledges for you

PromptSpec.Ephemeral is ignored by Prompt. Discord has no ephemeral form of a message nobody has interacted with yet, and the contract requires posting normally rather than failing. The ephemeral argument to Respond is honoured, which is where privacy is actually available.

ChoiceStyle maps to Discord's button styles, and anything unrecognised renders as the neutral style rather than failing.

What the Discord provider does with commands

RegisterCommands declares guild-scoped commands on the scope's space, not global ones: guild commands apply immediately where global ones propagate on Discord's own schedule, and a provider serving one space has no business declaring commands everywhere.

Each spec is validated before anything is sent, so one malformed command rejects the whole batch and leaves the registered set untouched.

Option types map to Discord's own, so a channel argument is the channel picker and arrives as an id Discord resolved. Subcommands and groups map to Discord's, including a command carrying both at once.

CommandSpec.RequiredRoles is not passed to Discord. Do the role check yourself on Interaction.By.Roles. There are no choice lists and no autocomplete.

Calling RegisterCommands with an empty slice removes every command, because the call is a complete replacement.

How the Discord provider survives a reconnect

The provider is built on disgoorg/disgo, chosen because it honours resume_gateway_url, the endpoint Discord nominates for resuming a session. Reconnecting to the generic gateway risks the resume being refused, which degrades to a fresh identify and drops every event buffered during the outage.

ConnState.LastReconnectLostEvents is recorded from the gateway frames themselves, not derived afterwards: a RESUMED frame means the gap was replayed, a second READY means it was not. Discord reissues a session ID either way, so nothing about the live session distinguishes them after the fact. The full reasoning is in Reconnects and lost events.

The regression test that proves a real peer-side disconnect resumes lives in the provider module behind an integration build tag, and needs live credentials.

A voice session reconnects on its own terms, and the answer is not the same one. See interruptions: a voice gateway that resumes internally leaves everything alive and the gap invisible, which is why voice reports gaps explicitly rather than letting you infer them from a session ending.

How the Discord provider does voice

It is a build-time capability, and that is not a limitation you can work around

Discord refuses a voice connection from a bot that does not negotiate DAVE, its end-to-end encryption:

websocket: close 4017: E2EE/DAVE protocol required

Negotiating it needs libdave, which is a C library, which means CGO. So a build without CGO has no voice capability at all rather than one that returns an error:

rx, ok := chatplatform.AsVoiceReceiver(provider)
if !ok {
    // Either this platform has no voice, or this binary was built without
    // CGO. One question, one answer — you do not have to tell them apart.
}

The guarantee is build-time and stops there. libdave.so links dynamically, so a CGO-enabled binary built where the shared object is missing at run time does not degrade to no-voice: it fails to start, before main. "Built with CGO" and "libdave present at run time" are two different guarantees and only the first is visible to the type system.

Voice arrived in chat-platform-discord v0.6.0, which requires this module at v0.8.0 or later.

Installing libdave is a prebuilt download from Discord's releases, about 9MB, roughly a second. It resolves through pkg-config, so the .so and the header alone are not enough: a dave.pc has to exist and PKG_CONFIG_PATH point at it. Without that you get a missing package error that reads like a Go problem rather than a toolchain one.

What it declares

VoiceCapabilities{
    Format: VoiceFormat{
        Codec:           CodecOpus,
        SampleRate:      48000,
        Channels:        2,
        FrameDuration:   20 * time.Millisecond,
        MaxPayloadBytes: 1400,
    },
    Attributes:           true,
    Sequenced:            true,
    ReportsInterruptions: true,
}

Answerable before joining anything, so a consumer whose requirements Discord cannot meet declines at startup.

Attributes and Sequenced are both true because Discord hands a bot raw RTP with the transport metadata still attached. That is unusual rather than normal: most platforms decode before the bot boundary and consume the sequence numbers on the way, which is why the contract carries both as declarations instead of assuming them.

Which intent voice costs

IntentGuildVoiceStates, and it is not privileged. Declaring any of NeedVoiceReceive, NeedVoiceSend or NeedVoiceParticipants requests it; declaring several requests it once. NeedVoiceParticipants is listed beside the two capture needs rather than folded into them, so a consumer that only wants to know who is in a room reaches the intent without declaring that it will listen to them.

A bot that records voice and takes slash commands therefore requests nothing privileged at all. The platform, not this code, is what makes that stick: an ungranted privileged intent closes the gateway.

What you cannot conclude from that. A bot that never declares a voice need gets no voice intent, and without it the provider cannot open a voice connection, because Discord will not serve it. That guarantee does not depend on your code being correct. It is not something a server owner can audit: privileged intents are not part of an OAuth2 authorisation request (they travel in the gateway IDENTIFY payload and are toggled in the developer portal), so no authorisation screen renders one.

Joining, and why a second Join is refused

session, err := rx.Join(ctx, channelID, sink)

The gates apply in this order, and the order is deliberate:

Condition Error
the channel id is unparseable ErrInvalidArgument
the channel is outside your allowlist ErrChannelDenied
the sink is nil ErrInvalidArgument
a voice session is already open ErrVoiceBusy

The allowlist is checked before anything touches Discord, because Join takes a bare channel id rather than a Ref. Nothing upstream has checked it, and a voice channel is a channel.

A second Join is refused rather than treated as a move. Moving tears 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 at all.

Sending waits for encryption, and this is the part worth understanding

Send and Stream block until end-to-end encryption is actually established, not merely until you are connected.

They have to. During the MLS handshake window, which is right after joining and after a channel move, the encryption layer runs in passthrough: it forwards frames unmodified rather than refusing them. Frames handed over in that window are transport-encrypted but not end-to-end encrypted, so the party able to decrypt them is Discord, which is the only thing DAVE exists to prevent. Nothing fails, and nothing says so.

The wait is cancellable: leaving the channel, or cancelling your context, ends it rather than stranding you.

Frames may arrive before the speaker is known

Discord identifies audio by SSRC and sends the SSRC-to-user mapping separately. Frames that arrive before the mapping does are delivered unattributed. You get the audio and an explicit "not known yet" rather than a guess:

speaker, known := frame.Speaker()
if !known {
    return // cannot check consent against an unknown speaker
}

How many arrive that way is not a constant, and you should not calibrate against one. It depends on whether somebody starts speaking before or after the mapping resolves. Measured across three runs on the same channel with the same speaker: 10, then 0, then 9. A join where the mapping is already known before anybody speaks delivers none at all.

Design for the mechanism (some frames may be unattributed, and you decide what to do with them) rather than for a number. VoiceStats.Unattributed counts them if you want to know.

Interruptions

The provider reports gaps in inbound audio, and reports them for both ways a voice connection comes back, including the quiet one.

When the voice gateway resumes internally, the connection, the encryption session and your VoiceSession all survive. You see a gap in frames and no session end at all. That is the common case, and a provider that only reported the case where the session dies would stay silent on it, leaving a recording short by however long the resume took with nothing marking it.

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

platformOnly is false when the gap spans a session boundary: the old session died and you joined a successor. That duration runs from the old session ending to the new one starting, so it includes however long you took to notice, back off and retry. The provider knows when you rejoined, not when you became able to, so only you can subtract it.

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

A deliberate Leave followed later by a Join is not an interruption. You chose that silence and already know its shape.

A padding defect discarded 3-11% of audio, and this provider carries the fix

An earlier release of this provider reported loss through VoiceStats.Lost. That field no longer exists, and the number it published was not what it claimed.

Two regimes, because conflating them is easy and misleading. On a stock disgo release, carrying the padding defect below, the counter read about 5.6% on a 55-second call, and those frames really were missing, because a padded packet failed to decrypt and never arrived. On a build carrying the fix, where that channel is closed, the counter still read about 1% on a call that had lost nothing: with the decrypt failures gone, what remained was padding-only packets, which consume a sequence number and deliver no media.

That second number is the one that mattered, because no arithmetic over the arrivals could distinguish it from real loss. The contract now publishes no loss figure at all; see VoiceStats for why the quantity is not identifiable at the provider boundary.

The underlying defect is real and worth knowing about regardless.

It comes from upstream, not from here. disgo tested the RTP padding bit as 0x04 where RFC 3550 section 5.1 puts it at 0x20, so a padded packet reached the decryption layer with its padding still attached and the decrypt failed. The lost frames and the decrypt failures are the same frames. Reported as disgoorg/disgo#593, fixed in #594, merged to master, and in no disgo release as of this page.

Since v0.9.0 this provider requires the fixed commit directly. go.mod names a pseudo-version of disgo master rather than a release, so the fix reaches every consumer with nothing to configure and no replace to carry. The alternative was every application adding its own replace, which fails silently the moment somebody forgets it. When disgo tags a release carrying the fix, the provider re-releases with an ordinary require; the pseudo-version is a bridge, not a position.

Measured through the since-removed counter at 0.00% with the fix against 3.2–11% without it. Read that as "the padding defect's channel closes", which is what it establishes, and not as "no frames go missing". The same counter still read about 1% on other fixed-build calls, and it could not tell padding from loss in either direction.

What is not yet known. The fix removes a loss channel measured at 3–11% of frames. No measurement of steady-state loss after the fix exists. Every figure quoted here was taken while the counter still existed.

"How much loss remains" is therefore an open question, and this provider cannot answer it for you. That is not a missing feature: padding, silence and genuine loss are not separable from what a receiver observes, so the number this provider used to publish was answering a question it could not see.

A crashed process leaves a phantom member

If your process dies without leaving (a panic, a SIGKILL, a container evicted), Discord goes on showing the bot in the voice channel. It clears on Discord's own session timeout, not promptly, and nothing in this module can change that: no library gets to run cleanup through a signal that terminates it.

It is worth knowing because of what people conclude from it. A visible marker in the channel is how anybody present can tell a bot is listening, so a stale one is read as "still recording" minutes after the process died. Visible state that is wrong is worse than none, because it is trusted.

If that matters to you, reconcile at startup rather than only on shutdown, because a crashed process never reached its shutdown path. The bot's voice state is readable at GET /guilds/{guild}/voice-states/{user}, and clearing it is PATCH /guilds/{guild}/members/{user} with {"channel_id": null}.

The Discord voice node

gitlab.com/phpboyscout/go/chat-platform-discord/voicenode, from v0.14.0. A chatplatform.Client that holds a voice channel and nothing else, for the topology where the process capturing audio is not the process holding the bot's gateway session. A transport can carry audio only is the reasoning and Run a voice node without a gateway is the procedure; this section is the facts.

What it is built from

type Config struct {
    Control Control       // the link to the gateway holder; nil is refused
    Self    snowflake.ID  // the bot's user id; zero is refused
    Needs   []chatplatform.Need
}

func New(cfg Config) (chatplatform.Client, error)

There is no token field, and the wire protocol below does not add one. Control is three methods, Open, UpdateVoiceState (Opcode 4 by proxy) and Close, and the two gateway events a join waits on come back through voicenode.Events, which the returned client implements:

type Events interface {
    HandleVoiceStateUpdate(guild, channel snowflake.ID, sessionID string)
    HandleVoiceServerUpdate(guild snowflake.ID, token, endpoint string)
}

A claim ended by something other than the request itself, and the link's own health, reach the node through a second interface it also implements:

type Lifecycle interface {
    HandleVoiceLeft(guild snowflake.ID, cause error)
    HandleLinkState(state chatplatform.ConnState)
}

cause is voicenode.ErrRemovedFromVoice for a kick and link.ErrClaimLost for everything else the broker can end a claim for: an expiry, a supersession, a reset, or a removal it could not attribute to a request.

Provider.Name is discord-voicenode: it is Discord, and it is not the Discord provider, and a log line should say which.

The shipped Control is voicenode/link, the worker's side, talking over a gitlab.com/phpboyscout/go/messaging bus to voicenode/broker, the process holding the gateway session. Run a voice node without a gateway is the wiring; this is the constructors, the constants and the subjects.

// link.go
func New(cfg Config) (*Link, error) // Bot, Self, Worker required; Clock, CloseTimeout, Logger default

func (l *Link) Subscriptions() []messaging.SubscriptionSpec
func (l *Link) Bind(bus Publisher)
func (l *Link) Attach(node chatplatform.Client) error // once, before Open
func (l *Link) Open(ctx context.Context) error
func (l *Link) Close() error
func (l *Link) UpdateVoiceState(ctx context.Context, guild snowflake.ID, channel *snowflake.ID, mute, deaf bool) error
func (l *Link) State() chatplatform.ConnState
func (l *Link) Boot() string

const HelloInterval       = 5 * time.Second   // how often the link says hello
const RepeatInterval      = 250 * time.Millisecond // how often an unresolved request is republished
const ReconnectingAfter   = 10 * time.Second  // silence before the link reports itself reconnecting
const DefaultCloseTimeout = 5 * time.Second   // bounds Close's leaves together

var ErrClaimLost error // the broker ended this claim for a reason the link did not ask for
// broker.go
func New(store Store, cfg Config) (*Broker, error) // Bot required; Clock, QueueDepth, Logger default

func (b *Broker) Subscriptions() []messaging.SubscriptionSpec
func (b *Broker) Bind(bus Publisher)
func (b *Broker) Attach(client *bot.Client) error // the provider's ControlHook
func (b *Broker) ResumeState(ctx context.Context) (ResumeState, error) // before Run only
func (b *Broker) Run(ctx context.Context) error // blocks; one broker per bot
func (b *Broker) Boot() string

const Lease             = 15 * time.Second // a claim's lease; three HelloIntervals fit inside it
const SweepInterval     = 5 * time.Second  // how often the broker judges what it holds
const SendTimeout       = 5 * time.Second  // bounds one store call and one Opcode 4
const DefaultQueueDepth = 4096             // sized for an identify plus a few seconds of traffic

Store (voicenode/broker/store.go) is what the broker persists: a record per guild, a record per worker name, and one resume checkpoint, each under compare-and-swap (Revision, ErrConflict). NewMemoryStore() is the only shipped implementation and holds nothing across a restart; broker/storetest is the suite a durable one (the deploying binary's to write, JetStream or otherwise) must pass. ErrNoRecord is what an absent key returns, and is not a failure.

The bus subjects, namespaced by the bot's snowflake id so two bots on one carrier never collide (voicenode/internal/wire):

Subject Direction
voice.<bot>.join.<guild> worker → broker: a request for one guild
voice.<bot>.hello.<worker> worker → broker: the heartbeat and lease renewal
voice.<bot>.events.<worker> broker → worker: claimed, refused, left, forwarded state and server updates

Godoc for both packages has the full field and method list: pkg.go.dev/gitlab.com/phpboyscout/go/chat-platform-discord/voicenode/link and pkg.go.dev/gitlab.com/phpboyscout/go/chat-platform-discord/voicenode/broker.

func WithControl(h ControlHook) Option      // gives the gateway to h, takes voice away from this client
func WithResumeState(sessionID string, sequence int, resumeURL string) Option
func ConnectControl(ctx context.Context, c chatplatform.Client) error

WithControl requires NeedVoiceParticipants, because the broker reads the guild voice-state intent that need requests, and refuses NeedVoiceReceive or NeedVoiceSend beside it, since a control client joins nothing itself. ConnectControl opens the gateway of a client built with WithControl: a broker holds no scope, so Reader.Connect is not reachable, and this is the one free function instead. *broker.Broker implements ControlHook.

What it refuses, and where

Condition Error Where
Control nil, or Self zero ErrInvalidArgument New
NeedMessages declared ErrUnsupported, naming the need New
ReplyInThread, React, ThreadHistory ErrUnsupported, connected or not the Actor
a scope on a guild that already has a live one ErrAlreadyScoped Provider
Provider after Close ErrNotConnected Provider
Join on a channel outside the allowlist ErrChannelDenied before the link
Join with an unparseable id or a nil sink ErrInvalidArgument before the link
Join before Connect ErrNotConnected before the link
a second Join while one is live ErrVoiceBusy before the link
Send or Stream with no session ErrNoVoiceSession the Actor
a worker name outside [A-Za-z0-9_-]+ ErrInvalidArgument link.New
a second broker (lost a compare-and-swap) ErrConflict broker.Run returns it

Every other need is accepted and carried onto the scope; only NeedVoiceReceive and NeedVoiceSend open anything. Messages() is non-nil, never yields, and closes when the scope or the client closes. ConnState reports the control link: Connect calls Control.Open and is connected if that returns nil.

What it lacks

The node shares the provider's join and session code, so a session's Send, Stream, Stats, Interruptions and Leave behave as documented above. Where it differs is everything that needs the gateway session or the caches beside it:

Provider Node, over the shipped link
VoiceSession.CanSend reads the member cache always false
a moderator disconnects the bot ErrRemovedFromVoice, from the gateway event ErrRemovedFromVoice, from the broker's own gateway event, delivered through Lifecycle.HandleVoiceLeft
the broker loses the claim otherwise (expiry, supersession, a reset) n/a ErrClaimLost, through Lifecycle.HandleVoiceLeft
VoiceParticipants present, from the voice-state cache absent, whatever was declared
a wrong Self cannot happen; learned from the token not detected on the node; the link fails Open with ErrInvalidArgument when its own Self disagrees with the bot the broker names

A node is not registered, so chatplatform.Lookup("discord-voicenode") answers false and chatplatform.NewClient cannot build one. A ClientConfig carries a token; a node's configuration must not.

How the Discord provider shows an indicator

Indicator.Show sets the bot's guild nickname (PATCH /guilds/{guild}/members/@me) and Clear sends {"nick": null} on the same endpoint. The limit is Discord's: 32 characters, counted in runes, so an emoji is one.

A nickname rather than a presence, because presence is set per gateway connection and one connection serves every space a client scopes. A "listening" status set for one guild would show in all of them; a nickname is per guild, so a bot capturing in one space does not announce it in another.

What follows from that is what the contract already says, and the two are worth reading together with the voice-state note above: a nickname persists on the platform after the process that set it dies, and a moderator removing it arrives as GUILD_MEMBER_UPDATE, which this capability does not request the intent for, so the provider cannot see it go. Both fail in the direction of a stale marker over-warning rather than an absent one.

Writing your own provider

See Author a provider for the twelve rules, and the conformance harness for what to check before you trust it.

Full API

Godoc for the contract: pkg.go.dev/gitlab.com/phpboyscout/go/chat-platform

Godoc for the Discord provider: pkg.go.dev/gitlab.com/phpboyscout/go/chat-platform-discord