<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex</title>
    <description>The latest articles on DEV Community by Alex (@sealbro).</description>
    <link>https://dev.to/sealbro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3848363%2F7d4e4900-549b-49cb-8acf-e42d5879f8dc.jpeg</url>
      <title>DEV Community: Alex</title>
      <link>https://dev.to/sealbro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sealbro"/>
    <language>en</language>
    <item>
      <title>Building a Discord Caller (Voice Relay) Bot in Go</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Sun, 05 Apr 2026 14:54:30 +0000</pubDate>
      <link>https://dev.to/sealbro/building-a-discord-caller-voice-relay-bot-in-go-5b9h</link>
      <guid>https://dev.to/sealbro/building-a-discord-caller-voice-relay-bot-in-go-5b9h</guid>
      <description>&lt;h2&gt;
  
  
  Intro
&lt;/h2&gt;

&lt;p&gt;If you've ever led a raid in an MMORPG, you know the drill: the boss pulls, everything goes sideways, and you have maybe two seconds to bark precise commands at five different party groups — each with their own role, their own channel, their own job to do. Tanks here, healers there, DPS rotate — all at once, all clearly, all &lt;em&gt;right now&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Our guild lived in Discord. Every strategy call, every pre-pull reminder, every panicked "STACK STACK STACK" — all of it happened there. But the moment a big fight started, we had to context-switch to an external app just to broadcast voice across multiple channels at once. One tool for coordination, another for execution. Every raid.&lt;/p&gt;

&lt;p&gt;That friction is what built &lt;a href="https://github.com/sealbro/go-discord-caller" rel="noopener noreferrer"&gt;go-discord-caller&lt;/a&gt;. One bot listens to the raid leader. A pool of speaker bots instantly re-broadcast that voice into every party channel simultaneously — no external app, no tab switching, no lag between the call and the action. Just one voice reaching everyone who needs to hear it, right inside Discord, the moment it matters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fux9yk7luupd85xedh8mg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fux9yk7luupd85xedh8mg.png" alt="intro" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How Discord Voice Works
&lt;/h2&gt;

&lt;p&gt;Let's start with the short basics of how Discord voice works and related libraries, since that shapes the entire architecture of the bot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discord gateways and voice flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discord uses two separate connections: a WebSocket &lt;strong&gt;gateway&lt;/strong&gt; for events and control (presence, voice state updates, slash commands) and a separate UDP socket for actual voice data&lt;/li&gt;
&lt;li&gt;To join a voice channel, the client sends a &lt;code&gt;Voice State Update&lt;/code&gt; over the gateway; Discord responds with a &lt;code&gt;Voice Server Update&lt;/code&gt; containing the endpoint and session token for the voice UDP connection&lt;/li&gt;
&lt;li&gt;Audio is encoded with the &lt;strong&gt;Opus codec&lt;/strong&gt; and sent as raw Opus frames over UDP — low latency, compact, designed for real-time speech&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;DAVE — Discord's Audio and Video Encryption:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Before DAVE, Discord voice was encrypted in transit (TLS/SRTP) but Discord's servers could theoretically decrypt it — no true end-to-end encryption&lt;/li&gt;
&lt;li&gt;DAVE (Discord's Audio and Video Encryption) brings real E2EE to voice and video using &lt;strong&gt;MLS (Messaging Layer Security)&lt;/strong&gt;, an IETF standard built for group communication, which provides forward secrecy and post-compromise security&lt;/li&gt;
&lt;li&gt;For a relay bot this matters directly: audio frames arrive already DAVE-encrypted, so the bot must participate in the MLS session to receive and re-transmit them — it can't just forward raw bytes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;disgo&lt;/code&gt; — the Go Discord library:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/disgoorg/disgo" rel="noopener noreferrer"&gt;disgo&lt;/a&gt; is the Discord library powering this project, and it's one I've been using for a few years now in various projects. It's a full-featured, actively maintained library with good documentation and a clean API design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;godave&lt;/code&gt; — CGO bridge to libdave:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discord's reference DAVE implementation is a native C++ library (&lt;code&gt;libdave&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/disgoorg/godave" rel="noopener noreferrer"&gt;godave&lt;/a&gt; is a CGO wrapper that exposes &lt;code&gt;libdave&lt;/code&gt; to Go — it's interop to the original, not a reimplementation, which means protocol correctness is guaranteed by Discord's own code&lt;/li&gt;
&lt;li&gt;The trade-off: CGO adds a C toolchain requirement and complicates static builds and containerization — this shapes the entire build and deployment strategy covered later&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The main idea is to support voice pipelines like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User (with caller role) speaks
        ↓
Owner bot VoiceReceiver (role-filtered Opus frames)
        ↓
Go channel ([]byte, buffered)
        ↓ fan-out
Speaker bot 1 VoiceProvider → Voice channel A
Speaker bot 2 VoiceProvider → Voice channel B
Speaker bot N VoiceProvider → Voice channel N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To achieve this, the key design principles needed to be implemented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One caller bot receives audio and re-transmits it to N speaker bots&lt;/li&gt;
&lt;li&gt;Per-guild isolation: each guild has its own state, speaker pool, and session&lt;/li&gt;
&lt;li&gt;Simple persisted state to survive restarts&lt;/li&gt;
&lt;li&gt;Setting up UI to set up channel and role bindings without any external web dashboard&lt;/li&gt;
&lt;li&gt;Minimal slash command surface — just enough to start, stop, and configure the relay, nothing more&lt;/li&gt;
&lt;li&gt;Role-based access control to allow any user with manager role to initiate a voice session and control who can speak without granting full admin&lt;/li&gt;
&lt;li&gt;Easy configuration and delivery via containerization (Docker) with CGO dependencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ok, let's dive into a short explanation of how some of these pieces are implemented in &lt;a href="https://github.com/sealbro/go-discord-caller" rel="noopener noreferrer"&gt;go-discord-caller&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Inside the Relay — Audio Pipeline, Speaker Pool, and Orchestrator
&lt;/h2&gt;

&lt;p&gt;The relay is built on three cooperating pieces. Here's how they fit together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audio pipeline&lt;/strong&gt; (&lt;code&gt;internal/opus/&lt;/code&gt;) — two small types implement disgo's voice interfaces. &lt;code&gt;VoiceReceiver&lt;/code&gt; sits on the owner bot: it filters frames by role via an &lt;code&gt;allowUser&lt;/code&gt; closure, drops frames non-blocking if the downstream channel is full, and shuts down via a &lt;code&gt;done&lt;/code&gt; channel. &lt;code&gt;VoiceProvider&lt;/code&gt; sits on each speaker: it blocks on channel receive, which naturally drives backpressure. Go channels are the audio bus — Opus frames flow from receiver through a fanout goroutine into N provider channels with no shared memory on the hot path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;VoiceReceiver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ReceiveOpusFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userID&lt;/span&gt; &lt;span class="n"&gt;snowflake&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;packet&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Packet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;packet&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Non-blocking check: if already closed, discard silently.&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Ignore frames from our own bot to avoid re-echoing what we send.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;botID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;userID&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;botID&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Apply optional role/user filter.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowUser&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Copy the opus bytes before sending because the backing array may be reused&lt;/span&gt;
    &lt;span class="c"&gt;// by the voice library.&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Opus&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nb"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Opus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Try to forward the frame. Selecting on done prevents a send to a&lt;/span&gt;
    &lt;span class="c"&gt;// channel that the relay goroutine has already stopped draining.&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="c"&gt;// receiver was closed between the check above and here; discard safely&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dropping opus frame: channel full"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;VoiceProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ProvideOpusFrame&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"voice provider is closed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"voice provider channel closed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Speaker pool&lt;/strong&gt; (&lt;code&gt;internal/pool/service.go&lt;/code&gt;) — all speaker gateways connect concurrently on startup, so the bot is ready to relay as soon as all bots are online. When a voice raid starts, each enabled speaker joins its bound voice channel in parallel and the orchestrator waits for all of them to be connected before audio starts flowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orchestrator&lt;/strong&gt; (&lt;code&gt;internal/manager/service.go&lt;/code&gt;) — the central coordinator that owns all per-guild state and drives the session lifecycle. &lt;code&gt;StartVoiceRaid&lt;/code&gt; sequences the whole thing: resolve the guild's isolated state (each guild has its own speaker pool, bindings, and session) → snapshot enabled speakers → owner joins its channel → attach &lt;code&gt;VoiceReceiver&lt;/code&gt; with role filter → all speakers join concurrently → fanout goroutine starts routing frames → session committed with a guard against concurrent starts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// StartVoiceRaid initiates the voice relay for a guild.&lt;/span&gt;

&lt;span class="n"&gt;resultCh&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="n"&gt;joinResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wg&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitGroup&lt;/span&gt;
&lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Speaker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channelID&lt;/span&gt; &lt;span class="n"&gt;snowflake&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;speakerID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JoinChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speakerID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;guildID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channelID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"speaker failed to join channel on raid start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"speakerID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speakerID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
                &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;chOut&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Consume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speakerID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;guildID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chOut&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to consume voice data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"speakerID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speakerID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LeaveChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;guildID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speakerID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;resultCh&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;joinResult&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chOut&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channelID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nb"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resultCh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The session object tracks everything needed to tear down cleanly: the owner's voice connection, each speaker's connection, and the audio channels linking them. When &lt;code&gt;StopVoiceRaid&lt;/code&gt; is called, the session closes the fan-out channel, which signals all &lt;code&gt;VoiceProvider&lt;/code&gt;s to stop, which causes each speaker to leave its voice channel in order. If a speaker disconnects mid-raid, the remaining speakers keep running — the broken connection is isolated to that one bot and doesn't affect the others or the owner.&lt;/p&gt;




&lt;h2&gt;
  
  
  Persistence — YAML Store
&lt;/h2&gt;

&lt;p&gt;Configuration shouldn't disappear every time the bot restarts. The &lt;code&gt;store.Store&lt;/code&gt; interface (&lt;code&gt;internal/store/&lt;/code&gt;) handles that with a deliberately simple design.&lt;/p&gt;

&lt;p&gt;The YAML-backed implementation persists channel bindings per guild and user, plus role bindings for the capture and manager roles. It loads from a single file at startup and writes on every change — no database, no migrations. An in-memory implementation exists for testing, but in production one file is all you need.&lt;/p&gt;

&lt;p&gt;The intentional simplicity here is the point: bindings rarely change, the data is small, and a YAML file is easy to inspect, back up, or edit directly if something goes wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;guilds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;guild_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000000000000001&lt;/span&gt;
      &lt;span class="na"&gt;channels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000000000000002&lt;/span&gt;
          &lt;span class="na"&gt;channel_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000000000000003&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000000000000004&lt;/span&gt;
          &lt;span class="na"&gt;channel_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000000000000005&lt;/span&gt;
      &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caller&lt;/span&gt;
          &lt;span class="na"&gt;role_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000000000000006&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manager&lt;/span&gt;
          &lt;span class="na"&gt;role_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000000000000007&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Discord Slash Commands and RBAC
&lt;/h2&gt;

&lt;p&gt;The bot is controlled entirely through slash commands — no external dashboard, no config file edits after initial setup. Four commands cover everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/status&lt;/code&gt; — public, shows current bindings and session state for the guild&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/start&lt;/code&gt; — requires manager role, calls &lt;code&gt;StartVoiceRaid&lt;/code&gt; to bring all speakers online and begin relaying&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/stop&lt;/code&gt; — requires manager role, calls &lt;code&gt;StopVoiceRaid&lt;/code&gt; to tear down the session and disconnect all speakers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/setup&lt;/code&gt; — requires Discord admin, opens the interactive setup panel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw5vtmhwtrc413gjmhs1j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw5vtmhwtrc413gjmhs1j.png" alt="Bot commands" width="765" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup UI&lt;/strong&gt; — setup happens entirely inside Discord via ephemeral messages that act as a multi-page UI. The main menu branches into a Bind Roles page (role selectors for capture and manager roles) or a paginated Bind Speakers page where each speaker can be toggled and assigned a channel. Adding a new speaker opens a sub-page with an OAuth invite link. All navigation uses Discord component interactions — buttons and selects — so there's nothing to host or maintain externally.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvr3eu9kiio31d4r63q51.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvr3eu9kiio31d4r63q51.png" alt="Setup main menu" width="800" height="1052"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdbg9idkgjpb17g4do2yf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdbg9idkgjpb17g4do2yf.png" alt="Bind role" width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo00tsexwikoxqc2uer11.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo00tsexwikoxqc2uer11.png" alt="Bind channel" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker Build with CGO and Distroless
&lt;/h2&gt;

&lt;p&gt;CGO makes containerization harder. You can't just &lt;code&gt;COPY&lt;/code&gt; a statically linked binary — the build needs a C toolchain, &lt;code&gt;libdave&lt;/code&gt; installed, and the resulting binary carries shared library dependencies at runtime.&lt;/p&gt;

&lt;p&gt;The Dockerfile solves this with a three-stage build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build stage&lt;/strong&gt; — &lt;code&gt;CGO_ENABLED=1&lt;/code&gt;, installs &lt;code&gt;libdave&lt;/code&gt;, compiles the binary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deps stage&lt;/strong&gt; — runs &lt;code&gt;ldd&lt;/code&gt; against the compiled binary and extracts all shared libraries it needs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime stage&lt;/strong&gt; — starts from a distroless base image, copies in the binary and only the libraries &lt;code&gt;ldd&lt;/code&gt; identified&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The distroless base gives a minimal attack surface: no shell, no package manager, nothing that isn't needed to run the process. The &lt;code&gt;ldd&lt;/code&gt; extraction is the key trick — it avoids having to know upfront which libraries &lt;code&gt;libdave&lt;/code&gt; pulls in, and it keeps the runtime image lean regardless of what changes in the dependency tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; GO_VERSION=latest&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; LIBDAVE_VERSION=v1.1.1&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:${GO_VERSION}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; LIBDAVE_VERSION&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src/cmd/bot&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; clang git ca-certificates bash pkg-config build-essential libusb-1.0-0-dev unzip cmake nasm zip &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git clone https://github.com/disgoorg/godave /tmp/godave &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod&lt;/span&gt; +x /tmp/godave/scripts/libdave_install.sh &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; /bin/bash /tmp/godave/scripts/libdave_install.sh &lt;span class="nv"&gt;$LIBDAVE_VERSION&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PKG_CONFIG_PATH="/root/.local/lib/pkgconfig"&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 go build &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-o&lt;/span&gt; /bin/runner

&lt;span class="c"&gt;# Collect all shared library dependencies of the binary&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /runtime-libs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    ldd /bin/runner &lt;span class="se"&gt;\
&lt;/span&gt;        | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"=&amp;gt; /"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;--dereference&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; /runtime-libs/

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;gcr.io/distroless/base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /bin/runner /&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /runtime-libs/ /usr/local/lib/&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; LD_LIBRARY_PATH=/usr/local/lib&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/runner"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Running It
&lt;/h2&gt;

&lt;p&gt;Getting the bot running is straightforward. The only upfront work is creating the Discord bots and dropping all their tokens into a &lt;code&gt;.env&lt;/code&gt; file — one owner token and as many speaker tokens as needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DISCORD_OWNER_BOT_TOKEN=...
DISCORD_SPEAKER_BOT_TOKEN_1=...
DISCORD_SPEAKER_BOT_TOKEN_2=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then pull and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;STORE_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/data/store.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/data:/data &lt;span class="se"&gt;\&lt;/span&gt;
  sealbro/go-discord-caller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else is configured from inside Discord via &lt;code&gt;/setup&lt;/code&gt; — no need to touch the config again. On startup the bot prints invite URLs for all bots to the log, so there's no hunting through the Discord developer portal; just copy the link, invite the bot to the server, and it's ready to bind. From the &lt;code&gt;/setup&lt;/code&gt; menu: assign the capture role, the manager role, bind the owner to a voice channel, add each speaker bot and assign it a channel. Then &lt;code&gt;/start&lt;/code&gt; — and from that point on, bindings survive restarts automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhizouq1j02g9jxu7rev7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhizouq1j02g9jxu7rev7.png" alt="Bots joining voice channels on start" width="700" height="1032"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full step-by-step setup instructions — including how to create the Discord application, configure bot permissions, and invite speaker bots — are available in the &lt;a href="https://github.com/sealbro/go-discord-caller" rel="noopener noreferrer"&gt;repository README&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;go-discord-caller&lt;/code&gt; is a self-hosted solution — no subscription, no third-party service, no paying for a feature that Discord should arguably have built in. The whole thing lives on whatever cheap VPS or home server you already have.&lt;/p&gt;

&lt;p&gt;What's next: two features are on the roadmap. &lt;strong&gt;Inter-server communication&lt;/strong&gt; — relaying audio across guilds, not just across channels within one. And a &lt;strong&gt;caller/speaker audio mixer&lt;/strong&gt; — letting audio flow in both directions, so speakers can respond to the raid leader without switching channels. If either of those sounds useful to you, star the &lt;a href="https://github.com/sealbro/go-discord-caller" rel="noopener noreferrer"&gt;go-discord-caller&lt;/a&gt; repository and leave a thumbs-up on the relevant issue — that's the clearest signal for what gets built next.&lt;/p&gt;

&lt;p&gt;Finally, if you want to try this for your guild raids without running your own instance, you can request access to an already-deployed bot — free, but spots are limited. Drop a message in the repository discussions. Keep in mind it runs the latest version, so it may occasionally be unstable — but you'll always get new features first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/sealbro/go-discord-caller" rel="noopener noreferrer"&gt;go-discord-caller&lt;/a&gt; — the bot built in this article&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/disgoorg/disgo" rel="noopener noreferrer"&gt;disgo&lt;/a&gt; — Discord API &amp;amp; gateway library for Go&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/disgoorg/godave" rel="noopener noreferrer"&gt;godave&lt;/a&gt; — CGO wrapper around libdave&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/discord/libdave" rel="noopener noreferrer"&gt;libdave&lt;/a&gt; — Discord's reference DAVE E2EE implementation&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>discord</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
