Discord Bots

Stream GitHub Actions Failures to Discord Voice with TypeScript

Tech Setup4 min read
TS

Tech Setup

Published October 18, 2026 · Editorial policy

Stream GitHub Actions Failures to Discord Voice with TypeScript

You are tired of checking the Actions tab after every push because a subtle type error or a bad test silently broke your pipeline. For a team of developers managing high-velocity repositories, context-switching to a browser just to find out a build failed kills your flow state. By the end of this guide, you will have a production-ready TypeScript Discord bot that joins a voice channel and reads out GitHub Actions failures the second they happen.

TL;DR: How It Works

GitHub Webhooks hit an Express server -> Express parses the workflow failure payload -> @discordjs/voice connects to your guild's voice channel -> text-to-speech-to-audio stream plays an alert.

Run the complete pipeline locally with ngrok and Docker, or deploy it to a small VPS. Here is the exact stack we are using:

  • Node.js v20+ with TypeScript
  • discord.js v14 and @discordjs/voice
  • @discordjs/opus for audio encoding
  • express to catch GitHub webhooks

Scaffolding the TypeScript Project

Initialize a clean directory and configure TypeScript for a strict, modern Node environment.

mkdir discord-gha-bot && cd discord-gha-bot
npm init -y
npm install typescript @types/node ts-node --save-dev
npx tsc --init

Open your newly created tsconfig.json and ensure these compiler flags are strictly enforced to avoid common type mismatches with Discord.js payloads:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

Install the core dependencies for Discord interactions, voice streaming, and webhook handling:

npm install discord.js @discordjs/voice @discordjs/opus sodium-native express dotenv
npm install @types/express --save-dev

Note on system dependencies: @discordjs/voice requires native audio libraries on your host machine to compile and stream audio. If you are running this on Linux (like an Ubuntu VPS), run the following before starting your app:

sudo apt-get update && sudo apt-get install -y libopus-dev ffmpeg

Configuring the Discord Bot Application

Go to the Discord Developer Portal, create a new application, and navigate to the Bot tab.

  1. Disable Public Bot so random servers can't add yours.
  2. Enable Presence Intent, Server Members Intent, and Message Content Intent.
  3. Generate an invite URL under OAuth2 > URL Generator:
    • Scopes: bot
    • Bot Permissions: Connect, Speak, View Channels
  4. Paste the URL into your browser and add the bot to your development Discord server.

Create a .env file in your root directory to store sensitive tokens securely:

DISCORD_TOKEN=your_bot_token_here
DISCORD_CLIENT_ID=your_client_id_here
DISCORD_GUILD_ID=your_server_guild_id_here
DISCORD_VOICE_CHANNEL_ID=your_voice_channel_id_here
PORT=3000

Writing the Bot Logic and Voice Streamer

Create your source directory and build the main entry point at src/index.ts. This script initializes the Discord client, connects to the specified voice channel, and spins up an Express webhook listener.

import { Client, GatewayIntentBits, VoiceBasedChannel } from 'discord.js';
import { 
  joinVoiceChannel, 
  createAudioPlayer, 
  createAudioResource, 
  AudioPlayerStatus, 
  VoiceConnectionStatus 
} from '@discordjs/voice';
import express, { Request, Response } from 'express';
import dotenv from 'dotenv';
import prism from 'prism-media';
import say from 'say'; // Optional helper, or we can use web-standard TTS APIs
import path from 'path';
import fs from 'fs';

dotenv.config();

const app = express();
app.use(express.json());

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildVoiceStates,
  ],
});

let activeVoiceConnection: any = null;
const audioPlayer = createAudioPlayer();

client.once('ready', async () => {
  console.log(`Logged in as ${client.user?.tag}!`);
  await connectToVoice();
});

async function connectToVoice() {
  try {
    const guild = await client.guilds.fetch(process.env.DISCORD_GUILD_ID!);
    const channel = await guild.channels.fetch(process.env.DISCORD_VOICE_CHANNEL_ID!) as VoiceBasedChannel;

    if (!channel || !channel.isVoiceBased()) {
      console.error('Target channel is not a valid voice channel.');
      return;
    }

    activeVoiceConnection = joinVoiceChannel({
      channelId: channel.id,
      guildId: guild.id,
      adapterCreator: guild.voiceAdapterCreator,
    });

    activeVoiceConnection.on(VoiceConnectionStatus.Ready, () => {
      console.log('Bot has successfully connected to the voice channel!');
    });

    activeVoiceConnection.subscribe(audioPlayer);
  } catch (error) {
    console.error('Failed to join voice channel:', error);
  }
}

// Generate an audio alert file using Node's native TTS or a lightweight synthesizer
async function speakMessage(text: string) {
  const filePath = path.join(__dirname, 'alert.mp3');
  
  // Using 'say' package to export text to an audio file
  say.export(text, 'Alex', 1.0, filePath, (err) => {
    if (err) {
      console.error('TTS Generation error:', err);
      return;
    }

    const resource = createAudioResource(filePath);
    audioPlayer.play(resource);
  });
}

app.post('/webhook/github', async (req: Request, res: Response) => {
  const event = req.headers['x-github-event'];
  
  if (event === 'workflow_run') {
    const payload = req.body;
    
    if (payload.action === 'completed') {
      const workflowName = payload.workflow_run.name;
      const conclusion = payload.workflow_run.conclusion;
      const repository = payload.repository.name;
      const branch = payload.workflow_run.head_branch;

      if (conclusion === 'failure') {
        const announcement = `Alert! Workflow ${workflowName} failed on repository ${repository}, branch ${branch}. Fix it now!`;
        console.log(announcement);
        await speakMessage(announcement);
      }
    }
  }

  res.status(200).send('Webhook received');
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook server listening on port ${PORT}`);
});

client.login(process.env.DISCORD_TOKEN);

You will also need to install the say package and its type definitions to handle text-to-speech audio rendering locally:

npm install say
npm install --save-dev @types/say

Exposing Your Local Server with Ngrok

To let GitHub send webhook payloads to your local machine during development, use ngrok to create an insecure or secured tunnel to port 3000.

npx ngrok http 3000

Copy the generated HTTPS forwarding URL (e.g., https://xxxx-xx-xx.ngrok-free.app).

Now, navigate to your target GitHub repository, go to Settings > Webhooks > Add webhook:

  • Payload URL: https://xxxx-xx-xx.ngrok-free.app/webhook/github
  • Content type: application/json
  • Which events would you like to trigger this webhook?: Select Let me select individual events, and check Workflow runs.
  • Ensure Active is checked, then click Add webhook.

Running and Testing the Pipeline

Compile your TypeScript code and run the application:

npx tsc
node dist/index.js

Expected terminal output:

Logged in as GHA-Voice-Bot#1337
Webhook server listening on port 3000
Bot has successfully connected to the voice channel!

To test the integration without breaking your main branch, commit a broken workflow file to a test branch in your repository:

name: Broken Test Workflow
on: [push]
jobs:
  fail:
    runs-on: ubuntu-latest
    steps:
      - name: Fail intentionally
        run: exit 1

Push this workflow to GitHub. Within seconds of the Action failing, your Discord bot will join the voice channel (if not already connected), synthesize the alert audio, and read out: "Alert! Workflow Broken Test Workflow failed on repository my-repo, branch main. Fix it now!"

Troubleshooting Common Voice and Webhook Failures

Even with correct configuration, Discord's voice API and GitHub webhooks can fail due to network constraints or native bindings. Check these common culprits if your setup fails silently:

1. The Bot Joins but No Audio Plays

  • Cause: Missing system-level Opus libraries or FFmpeg.
  • Fix: Run sudo apt-get install libopus0 ffmpeg on Linux, or brew install ffmpeg opus on macOS. Ensure @discordjs/opus or prism-media can bind to the native binaries.

2. Webhook Returns a 404 or Times Out

  • Cause: Your ngrok tunnel expired or changed URLs, or Express is missing JSON body parsing middleware.
  • Fix: Update the Payload URL in GitHub settings with your current active ngrok address. Ensure app.use(express.json()); is declared before your route handlers in src/index.ts.

3. Bot Gets Disconnected from Voice for Inactivity

  • Cause: @discordjs/voice drops connections if left idle in empty channels.
  • Fix: Modify your connection logic to handle disconnect events and automatically reconnect using entersState(connection, VoiceConnectionStatus.Ready, 30_000).

Production Deployment and Alternatives

Running this script via node dist/index.js on your laptop is fine for testing, but a production setup requires process management. Use PM2 to keep your bot alive on a dedicated VPS:

npm install -g pm2
npx tsc
pm2 start dist/index.js --name "gha-voice-bot"
pm2 save
pm2 startup

If maintaining a persistent voice connection causes stability issues or consumes too much bandwidth on your Discord server, consider an alternative architecture: instead of streaming a live TTS voice channel 24/7, write a script that triggers a WebRTC or Discord Stage Channel call only when a failure metric spikes, or integrate direct Discord webhook text messages that mention specific roles using standard markdown payloads.