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 | comms-discord |
discord |
Moderator, MemberInspector, Interactive, Commands, Author, VoiceParticipants, Indicator‡, ReactionObserver§, VoiceReceiver, VoiceSender† |
| Discord, audio only | comms-discord/voicenode |
not registered; reports discord-voicenode |
VoiceReceiver, VoiceSender¶ |
† Voice from comms-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 comms-discord v0.12.0, paired with
this module's v0.14.0.
§ ReactionObserver from comms-discord v0.13.0, paired
with this module's v0.15.0.
¶ voicenode from comms-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:
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 NewClient.
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 (except to Participants, where it is ErrInvalidArgument), a malformed response token, a member with no join date, a voice channel the cache does not hold, a 404 from Author.Replace and Author.Delete, or a 404 from SpaceInspector.Space |
ErrForbidden |
yes | a 403 from Author, Indicator and SpaceInspector 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 scope (outside the allowlist, or under PublicChannels not public, not of a requested kind, or of unknown visibility), the two methods that take a bare channel id. No message-taking method checks it; the scope is applied to inbound events |
ErrInvalidArgument |
yes | a malformed identifier, a nil sink or frame channel, an allowed channel named to Participants or Join that carries no voice, Indicator.Show given empty text or text over 32 runes, and an injected client serving voice without cache.FlagVoiceStates or cache.FlagChannels |
ErrUnsupported |
once | VoiceReceiver.Join on a scope whose client declared no voice need, reachable only by type-asserting the Actor past AsVoiceReceiver; 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, comms.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_MESSAGESandMESSAGE_CONTENTforNeedMessagesandGUILD_MEMBERSforNeedMemberLookup; the last two are privileged, and Discord will not deliver them otherwise. A bot that connects and receives messages with an emptyContentis almost always missing the Message Content intent.NeedReactionsrequestsGUILD_MESSAGE_REACTIONS, which is not privileged and does not bring message content with it.NeedSpaceDetailsrequests nothing: theGUILDSintent it reads from is always requested. - Permissions in the channels you allowlist: reading, sending and creating
public threads, and for the
Moderatorcapability, managing messages and moderating members.Indicatorneeds Change Nickname on the guild, and refuses withErrForbiddenwithout it.ChannelManagerandRoleManagerneed Manage Channels and Manage Roles, which are described below.
None of this is checked by the provider up front. A missing intent surfaces as
ErrForbidden from Connect; a missing permission surfaces as a wrapped Discord
error at the moment you use it, mapped to ErrForbidden only on the paths listed
above.
How Discord identifiers map onto ID¶
Every comms.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.
How the Discord provider names a member¶
Member.Name is the account's username. Member.DisplayName follows Discord's
own precedence: the server nickname if one is set, then the global display
name, then the username. Every Member the provider builds gets both, whether
it arrived on a message, a reaction, a voice state or a MemberInspector
lookup. A bare user with no guild member attached (an interaction from outside
the guild's member list) has no nickname to consult, so its DisplayName is
the global display name or the username.
DisplayName is filled from comms-discord v0.18.0, paired with
this module's v0.17.0. Earlier provider releases leave it empty.
Which messages the Discord provider delivers¶
An inbound message reaches Messages() only if both hold:
- Its channel is in the scope's allowlist, or it is a thread whose parent
channel is. The allowlist can change while the scope runs; see
changing the allowlist.
Under
PublicChannelsthe rule is different; see reading every public channel. - Its author is not a bot.
Bot-authored messages are dropped by default, including the bot's own. There
is no ClientConfig field to change this. The provider exposes a WithBotMessages()
option, which is a test affordance deliberately kept out of ClientConfig 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. From
comms-discord v0.21.0 one serving voice must cache channels too
(cache.FlagChannels), because Join decides a channel's kind from that cache
and every lookup would otherwise miss. Each refusal is ErrInvalidArgument,
naming the flag. A text-only client is asked for neither.
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, unless the scope was minted with WithInteractionsAnywhere, 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 | replaces the acknowledgement the first time, then a follow-up message |
UpdateSource |
update the source message | edit the original response |
OpenForm |
opens the form | discord.ErrFormWindowClosed |
The acknowledgement is private unless you declared otherwise, from
comms-discord v0.20.0. For a command or a form it is Discord's
"thinking…" message. Discord fixes that message's visibility when it is sent,
before your answer exists, so the provider sends it privately unless the
command or form set AnswersPublicly. Your answer then replaces it:
| declared | you answer | the answer is | its header, for everyone else |
|---|---|---|---|
| no | privately | private | none; only you see it |
| no | publicly | public | "Message could not be loaded" |
AnswersPublicly |
publicly | public | "user used /command" |
AnswersPublicly |
privately | private | none; only you see it |
So a command whose answers are for the whole channel should set
AnswersPublicly, or its answers lose the header. A second answer to the same
interaction is a new message with its own visibility, and never replaces the
first. A button press is unaffected, because its acknowledgement shows nothing.
Before v0.20.0 the acknowledgement was always public, and Discord ignores the private flag on a message that already exists. A private answer given after the two seconds was therefore shown to the whole channel (spec 0026).
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 where
privacy is actually available, and it is honoured however long you take to
answer (from comms-discord v0.20.0; see the lazy acknowledgement
above).
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.
CommandOption.ChannelKinds becomes Discord's channel_types, so the picker
offers only those kinds. ChannelText is text channels alone, ChannelForum
covers forum and media channels, and ChannelThread covers all three thread
types. Narrowing to ChannelVoice does
not offer stages; list ChannelStage as well if you can use one. Discord's
picker honours the narrowing, but the contract treats it as best effort, so
check Args.ChannelKind before acting on the answer.
Which kind each Discord channel reports¶
From comms-discord v0.21.0, Interaction.ChannelKind is the kind of
the channel the command was typed in, and Args.ChannelKind is the kind of a
channel the person picked. Both come from the type Discord sends with the
interaction, not from a cache, so neither needs an intent or a lookup.
| Discord channel type | ChannelKind |
|---|---|
| text (0) | ChannelText |
| announcement (5) | ChannelAnnouncement |
| voice (2) | ChannelVoice |
| stage (13) | ChannelStage |
| forum (15), media (16) | ChannelForum |
| announcement thread (10), public thread (11), private thread (12) | ChannelThread |
| category (4), directory (14), and anything newer | ChannelUnknown |
Changed in v0.22.0. v0.21.0 reported an announcement channel as
ChannelText and a forum or media channel as ChannelUnknown. So a picker
narrowed to ChannelText no longer offers announcement channels; ask for
ChannelAnnouncement as well if you want them.
A command typed in a voice channel's own chat reports ChannelVoice, because
on Discord that chat is the voice channel. That is the case that lets a bot
answer "join the channel I'm in" without asking which one.
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 reads every public channel¶
From comms-discord v0.22.0, a scope built with
WithPublicChannels
replaces the allowlist with one check, applied when each event arrives, at every
place the allowlist applies: messages, reactions, interactions (unless the scope
also has WithInteractionsAnywhere), and every call that takes a bare channel
id, such as ThreadHistory, ChannelInspector, Join and Participants.
A channel is public when @everyone can view it. The provider takes the
guild's @everyone role permissions (the role whose id is the guild's), then
applies the channel's own @everyone overwrite, and asks for View Channel.
Role and member overwrites never change what @everyone can do, so a channel
only a role can read is not public. A category's overwrites are not consulted
separately: Discord copies them onto a synced channel, so the channel's own
overwrites are the whole answer. ADMINISTRATOR on @everyone makes every
channel public, because that is Discord's own rule.
The kind must be one you asked for. It is read from the channel cache using the mapping above.
A thread is judged by its parent, for both visibility and kind, so a post
in a public forum arrives when you asked for ChannelForum. A private
thread (12) is never read, even when the bot is a member and its parent is
public.
Every cache miss drops the event: a channel, a thread's parent, or the
@everyone role that the cache does not hold yet. Channels and roles are
always cached, so this happens only before the cache has caught up, such as
just after connecting.
Join and Participants are refused with ErrChannelDenied for a channel
that is private, of a kind you did not ask for, or of unknown visibility. To join
voice, ask for ChannelVoice or ChannelStage. Asking for ChannelVoice also
delivers that voice channel's text chat as messages. Before Connect there is
no cache to judge by, so both say ErrNotConnected instead.
The voice node refuses the mode with ErrUnsupported from
Client.Provider, because it has no gateway to cache channels and roles from.
Measured on 2026-10-02 against a live server, with every result matching the above: spec 0028's live check. Two cases are worth knowing when you test this yourself. Discord sends nothing at all for a private thread the bot is not in, or for a channel it cannot view. So silence there proves nothing about the provider.
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:
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 := comms.AsVoiceReceiver(provider)
if !ok {
// Either this platform has no voice, or this binary was built without
// CGO or with discord_novoice. One question, one answer — you do not
// have to tell them apart.
}
The guarantee is build-time and stops there.
libdave.solinks 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, beforemain. "Built with CGO" and "libdave present at run time" are two different guarantees and only the first is visible to the type system.
A binary that needs CGO for something else, and never uses voice, can leave
voice out with the discord_novoice build tag, from comms-discord
v0.19.0:
The provider then answers exactly as a build without CGO does: no
VoiceReceiver, no VoiceSender, and no voicenode. Nothing links
libdave.so, so the binary needs no libdave to build or to start. Builds that
do not set the tag are unchanged. Spec 0025
has the reasoning.
Voice arrived in comms-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
IDENTIFYpayload and are toggled in the developer portal), so no authorisation screen renders one.
Joining, and why a second Join is refused¶
The gates apply in this order, and the order is deliberate:
| Condition | Error |
|---|---|
| the channel id is unparseable | ErrInvalidArgument |
| the channel is outside your scope | ErrChannelDenied (under PublicChannels, ErrNotConnected before Connect) |
| the sink is nil | ErrInvalidArgument |
| the client declared no voice need | ErrUnsupported |
| the scope is not connected | ErrNotConnected |
| the channel is not in the channel cache | ErrNotFound |
| the channel is neither voice nor stage | ErrInvalidArgument, naming its kind |
| 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.
The kind is checked before anything touches Discord too, from v0.21.0. A text
channel used to fail later, as a provider error; it is now
ErrInvalidArgument with "is text and carries no voice" in the message. The
voice node does not make this check, because it has no channel cache, so a
non-voice channel is not refused before it reaches Discord.
Stages¶
A bot can join a stage channel from comms-discord v0.21.0. Discord
puts it in the audience, so it hears the speakers and cannot speak until
somebody invites it up. CanSend reports false while the bot is in the
audience, and also while it is server muted, rather than only checking the
Speak permission.
Stages carry no end-to-end encryption. Discord negotiates the DAVE protocol off for them, so audio on a stage is transport-encrypted only, and the encryption wait described below ends at once rather than waiting for a group that will never form.
Participants reports a stage's audience as Audience: true and its speakers
as Audience: false, and declares ReportsAudience. Outside a stage
Audience is always false, whatever Discord's suppressed flag says.
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 go out at one per 20 ms whatever rate you hand them over, so a caller encoding faster than real time is still heard at normal speed. The bot tells Discord it is speaking before its first frame, as Discord requires.
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/comms-discord/voicenode, from v0.14.0. A
comms.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 []comms.Need
}
func New(cfg Config) (comms.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 comms.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 control link: voicenode/link and voicenode/broker, from v0.15.0¶
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 comms.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() comms.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/comms-discord/voicenode/link and pkg.go.dev/gitlab.com/phpboyscout/go/comms-discord/voicenode/broker.
The provider's control-link additions¶
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 comms.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.
A control client declines voice at run time, but that cannot unlink it: a CGO
build still carries libdave.so for wiring it will never use. Set
discord_novoice on the broker's build to drop it. Never set it on the
worker's, which needs voice.
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 |
PublicChannels asked for |
ErrUnsupported |
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 |
SpaceInspector |
present, from the guild cache or REST | absent, whatever was declared |
AllowlistEditor |
present | absent, whatever was declared; the allowlist is fixed at mint |
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 comms.Lookup("discord-voicenode")
answers false and comms.NewClient cannot build one. A ClientConfig
carries a token; a node's configuration must not.
How the Discord provider changes a live allowlist¶
From comms-discord v0.24.0, a scope that declared NeedAllowlistChanges
can change its allowlist through
AllowlistEditor.
It costs no intent.
- Each scope has one allowlist, shared by its reader and actor and guarded by
a read-write lock. So a change made from any goroutine is seen by every event
handled after the call returns: messages, interactions, reactions,
JoinandParticipants. A sibling scope on the same client has its own and is never touched. - Every id is parsed before anything changes. An empty or malformed id refuses
the whole call with
ErrInvalidArgument. AfterClose, both methods returnErrNotConnected. - Threads follow their parent, as for the allowlist given at mint.
- Removing a channel stops events,
JoinandParticipantsthere. It does not end a voice session already in it (measured live, with audio still arriving after the removal), and it does not stop replies, reactions or moderation through aRef. - Not on a
PublicChannelsscope: its reader reportsReadsPublicChannels(), soAsAllowlistEditoranswers false. Not on the voice node yet: its allowlist is fixed when its scope is minted.
How the Discord provider reports the guild's name¶
From comms-discord v0.23.0,
SpaceInspector.Space
answers with the guild's ID and current name.
It reads the guild cache first. The provider has kept guilds since this
release. The cache is filled by GUILD_CREATE and updated by GUILD_UPDATE,
both of which arrive on the GUILDS intent the provider always requests. So a
rename shows up on the next call, and a call costs no request.
Before the guild has arrived it asks REST, with GET /guilds/{id}, so a
call made just after connecting gets an answer rather than an error to retry.
| Condition | Error |
|---|---|
before Connect |
ErrNotConnected |
| REST 404 | ErrNotFound |
| REST 403 | ErrForbidden |
A 404 covers two cases you cannot tell apart. Measured on 2026-10-02,
Discord answers 404, code 10004 "Unknown Guild", both for a guild the bot
has left and for an ID that is no guild at all. So ErrNotFound means "not a
guild this bot can see", and retrying will not help.
The voice node does not offer it: it has no gateway and no REST client.
How the Discord provider controls mentions¶
From comms-discord v0.25.0
(spec 0031),
nothing the bot posts notifies anyone unless
ClientConfig.Mentions
allows it. Before that release, a post whose text held @everyone, a role or
<@id> notified them, including text echoed from a user.
Every send that carries text sets Discord's allowed_mentions itself:
ReplyInThread, Prompt, all three ways Respond answers, UpdateSource and
Author.Replace. The value is {"parse":[],"roles":[],"users":[],"replied_user":false}.
It is set on each send because disgo otherwise fills in its own default, which
notifies users, roles and @everyone. A client you inject with WithClient keeps
whatever default you built it with, so a client-wide setting could not be relied
on. A client the provider builds also defaults to the same value, so a send added
later without one still notifies nobody.
The text is sent unchanged, so a <@id> still renders as a mention. Discord
just doesn't notify anyone for it.
With Mentions.Members set, a prompt notifies exactly the members in
PromptSpec.Notify. The provider writes <@id> for each, ahead of the
content, and lists exactly those ids in allowed_mentions.users. parse never
contains users, so a mention typed into the content, or echoed from a user,
still notifies nobody. That also keeps the request clear of Discord's rule that
parse: ["users"] and a users list cannot be sent together.
| Condition | Error, before anything is sent |
|---|---|
Notify without Mentions.Members |
ErrInvalidArgument |
| an id that is not a snowflake | ErrInvalidArgument |
more than 100 ids, Discord's limit on users |
ErrInvalidArgument |
Measured live on 2026-10-03: Discord's returned message lists in mentions
only the users allowed_mentions let through, not every user the text names. A
post naming @everyone and two members, with the zero policy, came back with
mentions empty and mention_everyone false.
How the Discord provider sets up channels and roles¶
From comms-discord v0.25.0
(spec 0032),
the actor offers
ChannelManager and RoleManager.
Neither costs an intent.
The bot needs Manage Channels and Manage Roles, and those are a separate grant. An invite that asks only for what reading and replying need will not include them. The bot also can only grant what it holds, so it needs every permission a private channel gives:
| Kind | What a visible member and role are allowed |
|---|---|
| text | View Channel, Read Message History, Send Messages |
| voice | View Channel, Connect, Speak |
| category | View Channel |
A private channel or category carries an overwrite on @everyone (the role
whose id is the guild's) denying View Channel, one allowing the set above for
each named member and role, and one for the bot itself with the same set. It
never sets Manage Roles, which only an administrator can put on a channel.
An empty visibility sends no overwrites. Discord then copies the category's overwrites onto the new channel, so it follows the category. Measured on 2026-10-03: a text channel created that way in a private category held exactly the category's three overwrites.
ShowChannelsets one overwrite per member or role, andHideChanneldeletes it, through Discord's per-overwrite endpoints. Neither replaces the channel's whole set, so two editors do not overwrite each other. Hiding an overwrite that is already gone succeeds.Renamesends onlyname, andMovesends onlyparent_id. A moved channel keeps its own overwrites, so a private channel moved into a public category stays private (measured). Moving a category, or out of every category, is refused: disgo cannot send thenullthat would need.CreateRolesendspermissions: "0", and the role is neither mentionable nor hoisted.Joinworks on a channel created moments ago. The gateway'sCHANNEL_CREATEmay not have reached the cache when the REST call returns, soJoinasks Discord for the channel's kind when the cache does not have it.
Refused before anything is sent:
| Condition | Error |
|---|---|
| the guild's id as a visibility role, or as the role to grant | ErrInvalidArgument: that is @everyone |
the bot itself in HideChannel |
ErrInvalidArgument: it would lock the bot out |
| a private channel before the bot's own id is known | ErrNotConnected: an overwrite for user 0 would lock the bot out |
| a channel or category outside the allowlist | ErrChannelDenied |
What a refusal from Discord says. A 403 with code 50013 is
ErrForbidden. Its message names everything the call needed: Manage Channels
or Manage Roles, plus every permission it grants. It also notes that the bot's
highest role may sit below the one it is changing, because Discord's answer
does not say which. Code 50001 is ErrForbidden saying the bot cannot see the
channel or guild at all. A 404 is ErrNotFound. The 250-role and 500-channel
limits stay wrapped Discord errors.
How the Discord provider reads names¶
From comms-discord v0.26.0
(spec 0033).
A user command option carries the resolved member. Discord sends a resolved
user, and also a resolved member when the person is in the guild. The provider
uses the member first, building it the way it builds Interaction.By, so
Args.Member gives:
Name: the username;DisplayName: the server nickname, then the global name, then the username;- the member's roles.
Someone who is not in the guild comes through as a bare id, so Args.Member
answers false and Args.User gives the id. Measured on 2026-10-04: a member with
no nickname read back as their global name, and as their nickname once one was
set.
ChannelInspector.Channel asks Discord every time, with GET
/channels/{id}, and never reads the cache, because a cached name lags a rename.
It costs one request a call, and needs no channel cache on an injected client.
Measured: a channel renamed by hand read back with its new name on the next
call.
- A category reports
IsCategory, with the kindChannelUnknown. Categoryis the channel'sparent_id. For a thread, it is the parent channel's category, which costs one more request.- The allowlist is checked before the request. A channel outside it, or a thread
whose own id is not allowed, is
ErrChannelDeniedand costs nothing. - On a
PublicChannelsscope a category is never answered. - 403 is
ErrForbidden, and 404 isErrNotFound.
How the Discord provider posts¶
From comms-discord v0.27.0
(spec 0034),
Poster.Post sends one POST /channels/{id}/messages and returns the posted
message's Ref. Author.Replace and Delete accept that Ref.
Refused before any request, in this order:
| Condition | Error |
|---|---|
empty content, or a bad Notify id |
ErrEmptyContent, or ErrInvalidArgument |
| an empty or malformed channel id | ErrInvalidArgument |
| a channel outside the allowlist, or a thread whose own id is not allowed | ErrChannelDenied |
Notify on a client without Mentions.Members |
ErrInvalidArgument |
| content over Discord's 2000 characters, counted in characters, not bytes | ErrInvalidArgument |
before Connect |
ErrNotConnected |
Mentions are controlled as for every other send. allowed_mentions notifies
nobody, unless Notify names members. Then the provider writes their mentions
ahead of the content and allows exactly them.
From Discord, a 403 is ErrForbidden and a 404 ErrNotFound. A forum, a
category, or an archived or locked thread answers 400, which stays a wrapped
Discord error.
Measured on 2026-10-04:
- a post holding @everyone and a typed mention came back with no mentions;
- replacing it kept the same message;
- a post naming one member in Notify mentioned exactly that member.
How the Discord provider creates stages and gives the floor¶
From comms-discord v0.28.0
(spec 0035).
A stage needs a Community server. Discord's guild-features table says
COMMUNITY lets a guild enable stage channels. The provider reads the guild's
features, from the cache or GET /guilds/{id}, and refuses a stage with
ErrUnsupported before sending anything. Measured on 2026-10-04: a
non-Community server answers a stage create with 400, code 50024 "Cannot
execute action on this channel type", which says nothing about why. That code
on a stage create is mapped to ErrUnsupported too, as a backstop.
The audience gets View and Connect, never Speak, which means nothing on a
stage. That covers the named members and roles, and the bot's own overwrite on a
private stage. ShowChannel on a stage grants the same set.
Speaking is an invitation, because no permission makes a member a speaker. Measured on 2026-10-04:
- A member holding a channel-level Mute Members who tried to speak was refused with "You do not have permission to speak in this channel", whether or not the stage was live.
InviteToSpeakisPATCH /guilds/{id}/voice-states/{user}, with the stage andsuppress: false. Discord records it as an invitation, and the member sees "You have been invited to speak". Once they accept, they speak.- That worked for a member holding only View and Connect, on a stage with no stage instance. So the provider never starts one, and never needs a moderator's permissions.
ReturnToAudiencesendssuppress: true.
The bot needs Mute Members across the server, a power over every voice
channel. Without it, InviteToSpeak is ErrForbidden, naming Mute Members.
How the Discord provider carries a form's key¶
From comms-discord v0.29.0
(spec 0036).
A modal's custom_id is <interaction id>:<key>, or just the interaction id
when FormSpec.Key is empty. On submission the provider splits it on the
first :. Snowflakes hold no colon, so the key keeps any colons of its own.
The id half finds the form's public-answer declaration, and the key half arrives
in Interaction.ChoiceKey. A modal opened before keys existed sends a bare id,
and still works.
Discord's limits, refused before anything is sent:
| Key | Limit | Why |
|---|---|---|
| a choice's | 100 | it is the button's custom_id |
| a form field's | 100 | it is the text input's custom_id |
| a form's | 79 | the modal's custom_id also carries the id (up to 20) and the : |
A key over its limit is ErrInvalidArgument, naming the limit. A form key is
checked before the response slot is claimed, so a corrected form can still be
opened on the same token.
Discord counts Unicode code points. Measured on 2026-10-04, with buttons
whose custom_id was 100 and 101 ASCII characters, two-byte characters and
non-BMP characters: every 100 was accepted and every 101 refused. That is neither
bytes, since 100 é is 200 bytes, nor UTF-16 units, since 100 𝄞 is 200 units.
A form keyed character:named:t1:𝄞 round-tripped exactly, live.
How the Discord provider admits interactions from anywhere¶
From comms-discord v0.29.0
(spec 0037).
Every command, button press and form submission is checked against the scope's read allowlist, with a thread judged by its parent, exactly as a message would be. The scope option decides only what happens when that check fails:
- Without
WithInteractionsAnywhere, the interaction is dropped, and the person sees Discord's "The application did not respond". - With it, the interaction is delivered with
Interaction.OutsideAllowlistset to true.
Guild routing comes first and is unchanged. An interaction from another server never reaches the scope, and neither does one from a direct message, which has no server.
Nothing more is read. Messages and reactions from an unread channel are
still dropped. ThreadHistory, ChannelInspector, Join and Participants
still refuse the channel. Answer with Respond: Prompt needs the bot's own
View and Send permissions in that channel, and a person can run a command where
the bot has neither.
Measured live on 2026-10-04, with a scope reading only one channel:
- a command from #general was delivered and answered, marked outside;
- ChannelInspector and ThreadHistory on #general were refused;
- a message posted there was not read.
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/comms
Godoc for the Discord provider: pkg.go.dev/gitlab.com/phpboyscout/go/comms-discord