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_CONTENTandGUILD_MEMBERS; 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. - 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.
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:
- Its channel is in the scope's allowlist, or it is a thread whose parent channel is.
- 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:
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.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.
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
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 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 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 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.
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 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