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.