Voice bot — join a VC and play WebM/Opus audio or MP4 video.

voice-bot.js · 246 lines

Voice bot — join a VC and play WebM/Opus audio or MP4 video.

View on GitHubnode examples/voice-bot.js
javascript
/**
 * Voice bot — join a VC and play WebM/Opus audio or MP4 video.
 *
 * WARNING: Voice is being reworked. Fluxer is updating how voice works, so
 * @fluxerjs/voice and this demo will change soon. Do not build production bots
 * on the current voice APIs.
 *
 * Commands:
 *   !play — joins your VC and plays a fixed WebM/Opus track (via youtube-dl-exec)
 *   !playvideo [url] [480p|720p|1080p|1440p|4k] — stream MP4 (default 720p)
 *   !stop — stop playback and leave
 *
 * Usage (from repo root after pnpm install && pnpm run build):
 *   FLUXER_BOT_TOKEN=your_token node examples/voice-bot.js
 *
 * Optional: VOICE_DEBUG=1, FLUXER_VIDEO_FFMPEG=1
 *
 * See the Voice guide: https://fluxerjs.blstmo.com/guides/voice/
 */

import youtubedl from 'youtube-dl-exec';
import { Client, Events, VoiceChannel, parsePrefixCommand } from '@fluxerjs/core';
import { getVoiceManager, LiveKitRtcConnection } from '@fluxerjs/voice';

console.warn(
  '[voice] WARNING: Voice is being reworked. Fluxer is updating how voice works; @fluxerjs/voice and this example will change soon.',
);

const PREFIX = '!';
const PLAY_URL = 'https://www.youtube.com/watch?v=eVTXPUF4Oz4';
const YTDLP_FORMAT = 'bestaudio[ext=webm][acodec=opus]/bestaudio[ext=webm]/bestaudio';
const DEFAULT_VIDEO_URL = 'https://www.w3schools.com/html/mov_bbb.mp4';
const YTDLP_VIDEO_FORMAT =
  'best[height<=1080][ext=mp4]/best[height<=1080]/22/18/best[ext=mp4]/best';
const YOUTUBE_LIKE = /youtube\.com|youtu\.be|yt\.be/i;
const RESOLUTIONS = ['480p', '720p', '1080p', '1440p', '4k'];

/** @type {Map<string, { channel: import('@fluxerjs/core').VoiceChannel, streamUrl: string }>} */
const playState = new Map();

async function getStreamUrl(url) {
  const result = await youtubedl(
    url,
    {
      getUrl: true,
      f: YTDLP_FORMAT,
      formatSort: 'acodec:opus',
      noWarnings: true,
      noPlaylist: true,
    },
    { timeout: 15000 },
  );
  return String(result ?? '').trim();
}

async function getVideoUrl(url) {
  const result = await youtubedl(
    url,
    {
      getUrl: true,
      f: YTDLP_VIDEO_FORMAT,
      noWarnings: true,
      noPlaylist: true,
    },
    { timeout: 20000 },
  );
  return String(result ?? '').trim() || null;
}

async function resolveVoiceChannel(message, client) {
  const guildId = message.guildId;
  if (!guildId) {
    await message.reply('This command only works in a server.');
    return null;
  }
  const voiceManager = getVoiceManager(client);
  const voiceChannelId = voiceManager.getVoiceChannelId(guildId, message.author.id);
  if (!voiceChannelId) {
    await message.reply('Join a voice channel first.');
    return null;
  }
  const channel = client.channels.get(voiceChannelId);
  if (!(channel instanceof VoiceChannel)) {
    await message.reply('Could not find that voice channel.');
    return null;
  }
  return { guildId, voiceManager, channel };
}

const token = process.env.FLUXER_BOT_TOKEN;
if (!token) {
  console.error('Error: Set FLUXER_BOT_TOKEN environment variable');
  process.exit(1);
}

const client = new Client({
  intents: 0,
  rest: process.env.FLUXER_API_URL ? { api: process.env.FLUXER_API_URL } : undefined,
});

// Create VoiceManager before login so it receives VoiceStatesSync from READY/GUILD_CREATE.
getVoiceManager(client);

client.on(Events.Ready, () => {
  console.log(`Logged in as ${client.user?.username}`);
  client.user?.setPresence({
    status: 'online',
    customStatus: { text: 'Try !play' },
  });
});

client.on(Events.MessageCreate, async (message) => {
  if (message.author.bot || !message.content) return;
  const parsed = parsePrefixCommand(message.content, PREFIX);
  if (!parsed) return;

  const { command, args } = parsed;

  try {
    if (command === 'play') {
      const ctx = await resolveVoiceChannel(message, client);
      if (!ctx) return;
      const { guildId, voiceManager, channel } = ctx;
      const streamUrl = await getStreamUrl(PLAY_URL);
      if (!streamUrl) {
        await message.reply('Could not get stream URL. Is youtube-dl-exec installed?');
        return;
      }
      playState.set(guildId, { channel, streamUrl });
      const connection = await voiceManager.join(channel);
      connection.on?.('serverLeave', async () => {
        const state = playState.get(guildId);
        if (!state) return;
        console.log('[voice] LiveKit server sent leave; auto-reconnecting...');
        try {
          const conn = await voiceManager.join(state.channel);
          await conn.play(state.streamUrl);
        } catch (e) {
          console.error('[voice] Auto-reconnect failed:', e);
          playState.delete(guildId);
        }
      });
      await connection.play(streamUrl);
      await message.reply('Playing in your voice channel.');
      return;
    }

    if (command === 'playvideo') {
      const ctx = await resolveVoiceChannel(message, client);
      if (!ctx) return;
      const { voiceManager, channel } = ctx;

      const first = args[0]?.trim();
      const second = args[1]?.trim();
      const isRes = (s) => s && RESOLUTIONS.includes(s.toLowerCase());

      let inputUrl;
      let resolution;
      if (isRes(first)) {
        inputUrl = DEFAULT_VIDEO_URL;
        resolution = first.toLowerCase();
      } else if (first && /^https?:\/\//i.test(first)) {
        inputUrl = first;
        resolution = isRes(second) ? second.toLowerCase() : '720p';
      } else {
        inputUrl = DEFAULT_VIDEO_URL;
        resolution = isRes(second) ? second.toLowerCase() : '720p';
      }

      let videoUrl = inputUrl;
      if (YOUTUBE_LIKE.test(inputUrl)) {
        await message.reply('Fetching video URL from YouTube...').catch(() => {});
        const resolved = await getVideoUrl(inputUrl);
        if (!resolved) {
          await message.reply('Could not get video URL. Is youtube-dl-exec installed?');
          return;
        }
        videoUrl = resolved;
      }

      const connection = await voiceManager.join(channel);
      if (!(connection instanceof LiveKitRtcConnection)) {
        await message.reply(
          'Video requires LiveKit voice (this server may use a different voice backend).',
        );
        return;
      }
      if (!connection.isConnected()) {
        await message.reply('Voice connection not ready. Try again in a moment.');
        return;
      }
      await connection.playVideo(videoUrl, { source: 'screenshare', resolution });
      await message.reply(
        `Streaming video at ${resolution} 30fps. Use \`${PREFIX}stop\` to leave.`,
      );
      return;
    }

    if (command === 'stop') {
      const guildId = message.guildId;
      if (!guildId) {
        await message.reply('This command only works in a server.');
        return;
      }
      const voiceManager = getVoiceManager(client);
      const connection = voiceManager.getConnection(guildId);
      if (connection) {
        playState.delete(guildId);
        connection.stop();
        voiceManager.leave(guildId);
        await message.reply('Stopped and left the voice channel.');
      } else {
        await message.reply('Not in a voice channel in this server.');
      }
    }
  } catch (err) {
    console.error('Voice command error:', err);
    await message.reply('Failed to run that voice command.').catch(() => {});
  }
});

client.on(Events.Error, (err) => console.error('Client error:', err));

if (process.env.VOICE_DEBUG === '1' || process.env.VOICE_DEBUG === 'true') {
  client.on(Events.VoiceStateUpdate, (d) =>
    console.log('[voice] VoiceStateUpdate', {
      guild_id: d.guild_id,
      user_id: d.user_id,
      channel_id: d.channel_id,
    }),
  );
  client.on(Events.VoiceServerUpdate, (d) =>
    console.log('[voice] VoiceServerUpdate', {
      guild_id: d.guild_id,
      endpoint: d.endpoint ? 'present' : 'null',
    }),
  );
}

try {
  await client.login(token);
} catch (err) {
  console.error('Login failed:', err);
  process.exit(1);
}