Run a voice node without a gateway¶
You have a process that holds the bot's gateway session and another that
should capture or play audio in a voice channel. The second one needs
libdave, CGO and a socket near the speakers, and it must not hold the bot
token. voicenode, in chat-platform-discord v0.14.0 and later, is the
Client for that second process, and voicenode/link and voicenode/broker,
from v0.15.0, are the control link between the two: a worker and a gateway
holder wired over a messaging.Bus, with no socket or call of your own to
write.
A transport can carry audio only is why the node is shaped as it is. This page is how to run the two processes.
Before you start¶
- CGO, and
libdaveat run time, on the worker only. Everything invoicenodebut its doc comment is behind//go:build cgo; without it the package is empty rather than failing. The broker needs neither: it never touches the voice stack. - The bot's Discord user id, on both processes. Neither learns it from the
token: the broker is told with
broker.Config.Bot, the worker withlink.Config.Bot,link.Config.Selfandvoicenode.Config.Self.Botnamespaces the bus subjects, so a wrong one on the worker is a link no broker answers and anOpenthat waits.Selfis what the link compares with the bot the broker names on every event, and a mismatch failsOpenwithErrInvalidArgumentrather than waiting. A wrongvoicenode.Config.Selfon its own is detected by nothing. - A worker name, matching
[A-Za-z0-9_-]+. It is a lease, not an identity: two processes started with the same name are one worker to the broker, and the younger takes the name once the elder's lease lapses. - A
messaging.Bus(gitlab.com/phpboyscout/go/messaging) reachable from both processes, over whatever backend you deploy:messaging-natsor the in-memory backend for a test. Both sides callmessaging.New, which can return a usable bus and a degraded-setting error; the test isbus == nil, not the error. - A
broker.Store.broker.NewMemoryStore()ships and is what the broker's own tests run against; it holds nothing across a restart. A deployment that wants claims to survive one writes its own and checks it againstbroker/storetest, the suite anyStoremust pass.
1. Build the gateway holder's process¶
store := broker.NewMemoryStore() // or your own, checked against storetest
brk, err := broker.New(store, broker.Config{Bot: botID})
if err != nil {
return err
}
// The one store call made before Run; refused with ErrInvalidArgument after.
resume, err := brk.ResumeState(ctx)
if err != nil {
return err
}
bus, err := messaging.New(backend, messaging.Settings{
Source: "voice-broker",
Subscriptions: brk.Subscriptions(),
})
if bus == nil {
return err
}
brk.Bind(bus)
client, err := discord.NewClient(
chatplatform.ClientConfig{
Token: token,
Needs: []chatplatform.Need{chatplatform.NeedVoiceParticipants},
},
discord.WithControl(brk),
discord.WithResumeState(resume.SessionID, resume.Sequence, resume.ResumeURL),
)
if err != nil {
return err
}
go func() { errs <- brk.Run(ctx) }()
if err := bus.Start(ctx); err != nil {
return err
}
return discord.ConnectControl(ctx, client)
The order matters. Run must be draining before bus.Start lets anything
arrive, or a handler enqueues onto a queue nobody is reading yet; Run itself
must start after Bind, since it is Bind that gives the broker something to
answer through. discord.WithControl takes the gateway away from this
client's own voice and requires NeedVoiceParticipants, because the broker
reads the guild voice-state intent that need requests, and declaring NeedVoiceReceive
or NeedVoiceSend beside it is refused at NewClient, because a control
client joins nothing itself. ConnectControl is what opens the gateway here:
Connect is a Reader method reached through a scope, and a broker holds no
scope, so the provider gains this one free function instead.
discord.WithResumeState takes the checkpoint by its three fields, not the
struct: a first start has an empty resume.SessionID, which sets nothing, so
the client identifies afresh.
2. Build the worker process¶
l, err := link.New(link.Config{Bot: botID, Self: botID, Worker: workerName})
if err != nil {
return err
}
bus, err := messaging.New(backend, messaging.Settings{
Source: "voice-worker-" + workerName,
Subscriptions: l.Subscriptions(),
})
if bus == nil {
return err
}
l.Bind(bus)
node, err := voicenode.New(voicenode.Config{
Control: l,
Self: botID,
Needs: []chatplatform.Need{chatplatform.NeedVoiceReceive, chatplatform.NeedVoiceSend},
})
if err != nil {
return err
}
if err := l.Attach(node); err != nil {
return err
}
if err := bus.Start(ctx); err != nil {
return err
}
Attach must come before Open, and only once: it is what gives the link the
voicenode.Events and voicenode.Lifecycle the node implements, and it
refuses a second call or a client that implements neither.
3. Join, through the ordinary contract¶
Past construction, a node is an ordinary chatplatform.Client:
p, err := node.Provider(ctx, guildID, chatplatform.WithAllowedChannels(channelID))
if err != nil {
return err
}
if err := p.Reader.Connect(ctx); err != nil { // calls link.Open
return err
}
rx, ok := chatplatform.AsVoiceReceiver(p)
if !ok {
return errors.New("no voice receiver on this scope")
}
session, err := rx.Join(ctx, channelID, sink)
Connect calls Control.Open, which blocks until the broker has given this
worker name a generation: immediately on a fresh name, or until the
predecessor's lease lapses when a replacement starts before it has stopped.
Join's gate runs in the provider's own order before anything touches the
link: an unparseable id or a nil sink with ErrInvalidArgument, a channel
outside the allowlist with ErrChannelDenied, a scope that has not connected
with ErrNotConnected, a second join with ErrVoiceBusy. Past it, Send,
Stream, Interruptions, Stats and Leave behave as the provider's do,
because the node shares the provider's join and session code.
A read-only scope has no Actor, so no voice; the explanation page says why
that is the right answer rather than a gap.
4. What is different from the in-process provider¶
| Provider | Node, over the link | |
|---|---|---|
CanSend |
reads the member cache | always false: the permission lookup needs a gateway cache the node has none of |
| a moderator disconnects the bot | ErrRemovedFromVoice, from the gateway event |
ErrRemovedFromVoice, from the broker's own gateway event, forwarded through Lifecycle.HandleVoiceLeft |
| the broker loses the claim (expiry, supersession, a reset) | n/a | ErrClaimLost, from 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 when its own Self disagrees with the bot the broker names |
The moderator row changed with the link. Before it existed, a node's only
detector for a dropped session was the liveness watch on the voice connection,
reporting ErrVoiceGatewayGone well after the fact. The broker now attributes
the same null VOICE_STATE_UPDATE the provider's own gateway listener does (a
channel change nobody asked for, on a claim that is not leaving) and the
link turns that into ErrRemovedFromVoice on Lifecycle.HandleVoiceLeft,
which ends the session directly. ErrClaimLost is the node's own addition:
it has no equivalent on the provider, because the provider has no lease for
another worker to take.
5. Test it offline¶
The conformance harness sends no token, so it runs against a node exactly as it runs against the provider:
chatplatformtest.RunProviderConformance(t, chatplatformtest.ConformanceConfig{
NewClient: func(cfg chatplatform.ClientConfig) (chatplatform.Client, error) {
return voicenode.New(voicenode.Config{Control: fakeLink, Self: self, Needs: cfg.Needs})
},
Refuses: []chatplatform.Need{chatplatform.NeedMessages},
Capabilities: chatplatformtest.Capabilities{VoiceReceiver: true, VoiceSender: true},
// Space, SecondSpace, AllowedChannel: any syntactically valid snowflakes
})
A join past the gate is observable without a network too: it calls your
Control.UpdateVoiceState with the guild and channel, then waits. A fake link
that records the call and a short context deadline is a complete test of the
proxy. broker/storetest is the same idea for a store: a suite whose only
subject is the adapter under test proves the suite and the adapter agree, not
that either is right, which is why it is run against the in-memory store too.
Operations¶
One broker per bot. A second is a deployment error the store's
compare-and-swap turns into a crash (ErrConflict from Run) rather than
into two bots fighting over a channel.
The lease numbers, all constants because a worker and a broker that
disagree about them disagree about who holds a guild: a worker says hello
every link.HelloInterval (5s), the broker's claim lease is broker.Lease
(15s), so three hellos fit inside it, and the broker sweeps every
broker.SweepInterval (5s). A worker that has heard nothing usable for
link.ReconnectingAfter (10s) reports itself reconnecting before its claims
are taken, since that precedes the lease.
What a dead worker costs. The main gateway never reports a dead worker's voice connection on its own, so a worker that dies leaves its bot in the channel until the broker's lease expires it: 15 to 20 seconds, measured (report 0023).
What a broker restart costs. A resumed broker session leaves every claim alone: nothing moves. A refused resume releases what it held, and the worker's own repeats recover a dropped request in about five seconds (report 0022).
Every voice-gateway close costs a leave and a rejoin. Discord answers a request naming the channel the bot is already in with no fresh credentials, so disgo's own reconnect after a voice-gateway close (4006, 4009) is a leave then a join, and the bot is audibly out of the channel for the gap (report 0021). This is the engine's behaviour, not the link's, so it costs the same with or without a broker.