Skip to content

A transport can carry audio only

Every Client before v0.16.0 was, in practice, a gateway: one connection carrying messages, events and voice together, authenticated with the bot's token. The contract never said so, but nothing had tested the alternative.

The alternative exists because of where audio wants to run. A support bot's gateway session belongs in one long-lived process, and the process that captures a room (the one that needs libdave, CGO and a UDP socket close to the speakers) often does not. Discord allows exactly one voice connection per guild per bot, and it is negotiated over the gateway session, so the capturing process needs that session's cooperation and none of its other powers.

So spec 0022 asked one question of the contract: is a Client that carries audio and nothing else a legal transport? It is, and it took three decisions to make that true rather than merely permitted.

It refuses NeedMessages at construction

Reader.Messages() cannot be omitted; it is on the base surface. An audio-only transport has to return something from it, and the honest something is a channel that never yields and closes with the scope.

That is honest only if nobody was promised messages. So the transport refuses NeedMessages in its constructor, with an error matching ErrUnsupported that names the need. A consumer that declared it wanted messages finds out at NewClient, where it can act on it, rather than ranging a channel that never yields and calling it a quiet server.

Every other need is accepted. NeedModeration or NeedVoiceParticipants on a node opens nothing, and the capability behind it is simply absent, which is the ordinary discovery story: AsModerator answers false, as it does on any provider that has no moderator. The line between "refused" and "absent" is whether the contract lets the capability be omitted. Messages cannot be, so it is refused; everything else can be, so it is not.

The three Actor methods answer ErrUnsupported

ReplyInThread, React and ThreadHistory are also on the base surface, and also impossible without a gateway and a REST client. They return ErrUnsupported, connected or not.

The "connected or not" is the decision. ErrNotConnected is the ordinary answer before Connect, and a caller reading it will connect and try again. An inability that is permanent takes precedence over one that is temporary, because the retry it invites cannot ever succeed.

This is the one place the contract's "omit rather than fail" rule is bent, and errors says why: the rule assumes the capability can be omitted, and these cannot.

VoiceReceiver stays on the Actor

A read-only scope has no Actor, and VoiceReceiver hangs off the Actor, so a read-only scope on an audio-only transport can do nothing at all. Spec 0022 considered moving capture to the Reader, where a read-only node would keep it, and did not.

Joining a voice channel changes what people see: the bot appears in the channel. That is the Actor's definition, and the reason both voice halves hang off it is that a deployment somebody has configured as observe-only must not put a member in a voice channel. An audio-only transport does not change that. It only makes the read-only case obviously useless, which is a fair description of a listener that is not allowed to listen.

It is not in the registry

Register gives the registry a Factory taking a ClientConfig, and a ClientConfig carries a Token. The audio-only transport's whole reason to exist is that the bot token never reaches the process it runs in. Its configuration has no token field, and a field that does not exist is a stronger refusal than a check that the field is empty.

So it is built by hand, voicenode.New(voicenode.Config{...}) on Discord, rather than found by name, and it is the first transport in the family for which that is the design. The registry is a convenience for consumers holding a platform name from configuration; a consumer building a node knows exactly which one it is building.

What it still needs from a gateway

None of this makes the gateway unnecessary. A voice join is one gateway command, Opcode 4, answered by two gateway events, and both cross the process boundary. The node takes a Control link that proxies the command and hands back the events; how that link is carried on the wire is built (voicenode/link and voicenode/broker, from chat-platform-discord v0.15.0, a worker and a gateway holder wired over a messaging.Bus) and documented in the reference and the how-to. Control stays an interface, so a test or an unusual topology can still supply its own.

What the node gives up by not holding the session is worth naming, and the shipped link closes one gap that an earlier draft of this page said it could not. The broker, which does hold the gateway, attributes the same null VOICE_STATE_UPDATE the provider's own listener does (a channel change nobody asked for) and forwards it as ErrRemovedFromVoice through Lifecycle.HandleVoiceLeft, so a node on the shipped link is told a moderator disconnect the same way the provider is, not from a liveness watch after the fact. What the node still cannot do is read a member cache, so CanSend on its session is always false, the documented fail-closed answer; and it has to be told its own user id, because a bot learns that from the token it does not have.

How the harness tests it

The conformance harness sends no token, and takes a Refuses list. A transport that refuses NeedMessages is held to the same run as one that serves it: the harness asks for a client with each refused need alone and requires a nil client and the naming error, then declares every other need and runs everything else. See declare what you can never serve.

One thing the harness found on the first run is worth passing on. A factory that returns its concrete type (*Client rather than chatplatform.Client) hands a typed nil to any adapter of the shape func(...) (chatplatform.Client, error), which is the shape every harness and registry takes, and the nil check passes on a pointer that is nil. Return the interface.