chore: monorepo setup (#7175)

This commit is contained in:
Noel
2022-01-07 17:18:25 +01:00
committed by GitHub
parent 780b7ed39f
commit 16390efe6e
504 changed files with 25459 additions and 22830 deletions

View File

@@ -0,0 +1,8 @@
# Examples
| Example | Description |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Basic](./basic) | A simple "Hello World" TypeScript example that plays an mp3 file. Notably, it works with discord.js v12 and so it also contains an example of creating an adapter |
| [Radio Bot](./radio-bot) | A fun JavaScript example of what you can create using @discordjs/voice. A radio bot that plays output from your speakers in a Discord voice channel |
| [Music Bot](./music-bot) | A TypeScript example of a YouTube music bot. Demonstrates how queues can be implemented and how to implement "good" disconnect/reconnection logic |
| [Recorder](./recorder) | An example of using voice receive to create a bot that can record audio from users |

View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View File

@@ -0,0 +1,91 @@
# Basic Example
This example will demonstrate how to join a voice channel and play resources, with some best practice
assistance on making sure you aren't waiting indefinitely for things to happen.
To achieve this, the example sets some fairly arbitrary time constraints for things such as joining
voice channels and audio becoming available.
## Code snippet
This code snippet doesn't include any comments for brevity. If you want to see the full source code,
check the other files in this folder!
```ts
import { Client, VoiceChannel, Intents } from 'discord.js';
import {
joinVoiceChannel,
createAudioPlayer,
createAudioResource,
entersState,
StreamType,
AudioPlayerStatus,
VoiceConnectionStatus,
} from '@discordjs/voice';
import { createDiscordJSAdapter } from './adapter';
const player = createAudioPlayer();
function playSong() {
const resource = createAudioResource('https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', {
inputType: StreamType.Arbitrary,
});
player.play(resource);
return entersState(player, AudioPlayerStatus.Playing, 5e3);
}
async function connectToChannel(channel: VoiceChannel) {
const connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: createDiscordJSAdapter(channel),
});
try {
await entersState(connection, VoiceConnectionStatus.Ready, 30e3);
return connection;
} catch (error) {
connection.destroy();
throw error;
}
}
const client = new Client({
ws: { intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES] },
});
client.login('token here');
client.on('ready', async () => {
console.log('Discord.js client is ready!');
try {
await playSong();
console.log('Song is ready to play!');
} catch (error) {
console.error(error);
}
});
client.on('message', async (message) => {
if (!message.guild) return;
if (message.content === '-join') {
const channel = message.member?.voice.channel;
if (channel) {
try {
const connection = await connectToChannel(channel);
connection.subscribe(player);
message.reply('Playing now!');
} catch (error) {
console.error(error);
}
} else {
message.reply('Join a voice channel then try again!');
}
}
});
```

View File

@@ -0,0 +1,69 @@
import { DiscordGatewayAdapterCreator, DiscordGatewayAdapterLibraryMethods } from '../../';
import { VoiceChannel, Snowflake, Client, Constants, Guild } from 'discord.js';
import { GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData } from 'discord-api-types/v9';
const adapters = new Map<Snowflake, DiscordGatewayAdapterLibraryMethods>();
const trackedClients = new Set<Client>();
/**
* Tracks a Discord.js client, listening to VOICE_SERVER_UPDATE and VOICE_STATE_UPDATE events
*
* @param client - The Discord.js Client to track
*/
function trackClient(client: Client) {
if (trackedClients.has(client)) return;
trackedClients.add(client);
client.ws.on(Constants.WSEvents.VOICE_SERVER_UPDATE, (payload: GatewayVoiceServerUpdateDispatchData) => {
adapters.get(payload.guild_id)?.onVoiceServerUpdate(payload);
});
client.ws.on(Constants.WSEvents.VOICE_STATE_UPDATE, (payload: GatewayVoiceStateUpdateDispatchData) => {
if (payload.guild_id && payload.session_id && payload.user_id === client.user?.id) {
adapters.get(payload.guild_id)?.onVoiceStateUpdate(payload);
}
});
client.on(Constants.Events.SHARD_DISCONNECT, (_, shardID) => {
const guilds = trackedShards.get(shardID);
if (guilds) {
for (const guildID of guilds.values()) {
adapters.get(guildID)?.destroy();
}
}
trackedShards.delete(shardID);
});
}
const trackedShards = new Map<number, Set<Snowflake>>();
function trackGuild(guild: Guild) {
let guilds = trackedShards.get(guild.shardID);
if (!guilds) {
guilds = new Set();
trackedShards.set(guild.shardID, guilds);
}
guilds.add(guild.id);
}
/**
* Creates an adapter for a Voice Channel.
*
* @param channel - The channel to create the adapter for
*/
export function createDiscordJSAdapter(channel: VoiceChannel): DiscordGatewayAdapterCreator {
return (methods) => {
adapters.set(channel.guild.id, methods);
trackClient(channel.client);
trackGuild(channel.guild);
return {
sendPayload(data) {
if (channel.guild.shard.status === Constants.Status.READY) {
channel.guild.shard.send(data);
return true;
}
return false;
},
destroy() {
return adapters.delete(channel.guild.id);
},
};
};
}

View File

@@ -0,0 +1,152 @@
import { Client, VoiceChannel, Intents } from 'discord.js';
import {
joinVoiceChannel,
createAudioPlayer,
createAudioResource,
entersState,
StreamType,
AudioPlayerStatus,
VoiceConnectionStatus,
} from '@discordjs/voice';
import { createDiscordJSAdapter } from './adapter';
/**
* In this example, we are creating a single audio player that plays to a number of voice channels.
* The audio player will play a single track.
*/
/**
* Create the audio player. We will use this for all of our connections.
*/
const player = createAudioPlayer();
function playSong() {
/**
* Here we are creating an audio resource using a sample song freely available online
* (see https://www.soundhelix.com/audio-examples)
*
* We specify an arbitrary inputType. This means that we aren't too sure what the format of
* the input is, and that we'd like to have this converted into a format we can use. If we
* were using an Ogg or WebM source, then we could change this value. However, for now we
* will leave this as arbitrary.
*/
const resource = createAudioResource('https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', {
inputType: StreamType.Arbitrary,
});
/**
* We will now play this to the audio player. By default, the audio player will not play until
* at least one voice connection is subscribed to it, so it is fine to attach our resource to the
* audio player this early.
*/
player.play(resource);
/**
* Here we are using a helper function. It will resolve if the player enters the Playing
* state within 5 seconds, otherwise it will reject with an error.
*/
return entersState(player, AudioPlayerStatus.Playing, 5e3);
}
async function connectToChannel(channel: VoiceChannel) {
/**
* Here, we try to establish a connection to a voice channel. If we're already connected
* to this voice channel, @discordjs/voice will just return the existing connection for us!
*/
const connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: createDiscordJSAdapter(channel),
});
/**
* If we're dealing with a connection that isn't yet Ready, we can set a reasonable
* time limit before giving up. In this example, we give the voice connection 30 seconds
* to enter the ready state before giving up.
*/
try {
/**
* Allow ourselves 30 seconds to join the voice channel. If we do not join within then,
* an error is thrown.
*/
await entersState(connection, VoiceConnectionStatus.Ready, 30e3);
/**
* At this point, the voice connection is ready within 30 seconds! This means we can
* start playing audio in the voice channel. We return the connection so it can be
* used by the caller.
*/
return connection;
} catch (error) {
/**
* At this point, the voice connection has not entered the Ready state. We should make
* sure to destroy it, and propagate the error by throwing it, so that the calling function
* is aware that we failed to connect to the channel.
*/
connection.destroy();
throw error;
}
}
/**
* Main code
* =========
* Here we will implement the helper functions that we have defined above.
*/
const client = new Client({
ws: { intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES] },
});
void client.login('token here');
client.on('ready', async () => {
console.log('Discord.js client is ready!');
/**
* Try to get our song ready to play for when the bot joins a voice channel
*/
try {
await playSong();
console.log('Song is ready to play!');
} catch (error) {
/**
* The song isn't ready to play for some reason :(
*/
console.error(error);
}
});
client.on('messageCreate', async (message) => {
if (!message.guild) return;
if (message.content === '-join') {
const channel = message.member?.voice.channel;
if (channel) {
/**
* The user is in a voice channel, try to connect.
*/
try {
const connection = await connectToChannel(channel);
/**
* We have successfully connected! Now we can subscribe our connection to
* the player. This means that the player will play audio in the user's
* voice channel.
*/
connection.subscribe(player);
await message.reply('Playing now!');
} catch (error) {
/**
* Unable to connect to the voice channel within 30 seconds :(
*/
console.error(error);
}
} else {
/**
* The user is not in a voice channel.
*/
void message.reply('Join a voice channel then try again!');
}
}
});

View File

@@ -0,0 +1,7 @@
{
"root": true,
"extends": "../../.eslintrc.json",
"parserOptions": {
"project": "./tsconfig.eslint.json"
}
}

View File

@@ -0,0 +1,3 @@
package-lock.json
auth.json
tsconfig.tsbuildinfo

View File

@@ -0,0 +1,31 @@
# Music Bot Example
This is an example of how to create a music bot using @discordjs/voice alongside [discord.js](https://github.com/discordjs/discord.js).
The focus of this example is on how to create a robust music system using this library. The example explores error recovery, reconnection logic and implementation of a queue that won't lock up.
If you're looking to make your own music bot that is fairly simple, this example is a great place to start.
## Usage
```bash
# Clone the main repository, and then run:
$ npm install
$ npm run build
# Open this example and install dependencies
$ cd examples/music-bot
$ npm install
# Set a bot token (see auth.example.json)
$ nano auth.json
# Start the bot!
$ npm start
```
## Code structure
The bot code has been separated from the code that is specific to @discordjs/voice as much as possible. Within `src/music`, you will find code that is specific to this library and you can take inspiration from this when building your own music system.
On the other hand, `src/bot.ts` is discord.js-specific code that interacts with the music system above, as well as handling user commands given on Discord. This example uses a development build of Discord.js that supports slash commands.

View File

@@ -0,0 +1,3 @@
{
"token": "Your Discord bot token here"
}

View File

@@ -0,0 +1,28 @@
{
"name": "music-bot",
"version": "0.0.1",
"description": "An example music bot written using @discordjs/voice",
"scripts": {
"start": "npm run build && node -r tsconfig-paths/register dist/bot",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"prettier": "prettier --write **/*.{ts,js,json,yml,yaml}",
"build": "tsc",
"build:check": "tsc --noEmit --incremental false"
},
"author": "Amish Shah <contact@shah.gg>",
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.5.0",
"discord-api-types": "^0.19.0",
"discord.js": "^13.0.0-dev.328501b.1626912223",
"libsodium-wrappers": "^0.7.9",
"youtube-dl-exec": "^1.2.4",
"ytdl-core": "^4.8.3"
},
"devDependencies": {
"tsconfig-paths": "^3.9.0",
"typescript": "~4.2.2"
}
}

View File

@@ -0,0 +1,188 @@
import Discord, { Interaction, GuildMember, Snowflake } from 'discord.js';
import {
AudioPlayerStatus,
AudioResource,
entersState,
joinVoiceChannel,
VoiceConnectionStatus,
} from '@discordjs/voice';
import { Track } from './music/track';
import { MusicSubscription } from './music/subscription';
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const { token } = require('../auth.json');
const client = new Discord.Client({ intents: ['GUILD_VOICE_STATES', 'GUILD_MESSAGES', 'GUILDS'] });
client.on('ready', () => console.log('Ready!'));
// This contains the setup code for creating slash commands in a guild. The owner of the bot can send "!deploy" to create them.
client.on('messageCreate', async (message) => {
if (!message.guild) return;
if (!client.application?.owner) await client.application?.fetch();
if (message.content.toLowerCase() === '!deploy' && message.author.id === client.application?.owner?.id) {
await message.guild.commands.set([
{
name: 'play',
description: 'Plays a song',
options: [
{
name: 'song',
type: 'STRING' as const,
description: 'The URL of the song to play',
required: true,
},
],
},
{
name: 'skip',
description: 'Skip to the next song in the queue',
},
{
name: 'queue',
description: 'See the music queue',
},
{
name: 'pause',
description: 'Pauses the song that is currently playing',
},
{
name: 'resume',
description: 'Resume playback of the current song',
},
{
name: 'leave',
description: 'Leave the voice channel',
},
]);
await message.reply('Deployed!');
}
});
/**
* Maps guild IDs to music subscriptions, which exist if the bot has an active VoiceConnection to the guild.
*/
const subscriptions = new Map<Snowflake, MusicSubscription>();
// Handles slash command interactions
client.on('interactionCreate', async (interaction: Interaction) => {
if (!interaction.isCommand() || !interaction.guildId) return;
let subscription = subscriptions.get(interaction.guildId);
if (interaction.commandName === 'play') {
await interaction.defer();
// Extract the video URL from the command
const url = interaction.options.get('song')!.value! as string;
// If a connection to the guild doesn't already exist and the user is in a voice channel, join that channel
// and create a subscription.
if (!subscription) {
if (interaction.member instanceof GuildMember && interaction.member.voice.channel) {
const channel = interaction.member.voice.channel;
subscription = new MusicSubscription(
joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
}),
);
subscription.voiceConnection.on('error', console.warn);
subscriptions.set(interaction.guildId, subscription);
}
}
// If there is no subscription, tell the user they need to join a channel.
if (!subscription) {
await interaction.followUp('Join a voice channel and then try that again!');
return;
}
// Make sure the connection is ready before processing the user's request
try {
await entersState(subscription.voiceConnection, VoiceConnectionStatus.Ready, 20e3);
} catch (error) {
console.warn(error);
await interaction.followUp('Failed to join voice channel within 20 seconds, please try again later!');
return;
}
try {
// Attempt to create a Track from the user's video URL
const track = await Track.from(url, {
onStart() {
interaction.followUp({ content: 'Now playing!', ephemeral: true }).catch(console.warn);
},
onFinish() {
interaction.followUp({ content: 'Now finished!', ephemeral: true }).catch(console.warn);
},
onError(error) {
console.warn(error);
interaction.followUp({ content: `Error: ${error.message}`, ephemeral: true }).catch(console.warn);
},
});
// Enqueue the track and reply a success message to the user
subscription.enqueue(track);
await interaction.followUp(`Enqueued **${track.title}**`);
} catch (error) {
console.warn(error);
await interaction.followUp('Failed to play track, please try again later!');
}
} else if (interaction.commandName === 'skip') {
if (subscription) {
// Calling .stop() on an AudioPlayer causes it to transition into the Idle state. Because of a state transition
// listener defined in music/subscription.ts, transitions into the Idle state mean the next track from the queue
// will be loaded and played.
subscription.audioPlayer.stop();
await interaction.reply('Skipped song!');
} else {
await interaction.reply('Not playing in this server!');
}
} else if (interaction.commandName === 'queue') {
// Print out the current queue, including up to the next 5 tracks to be played.
if (subscription) {
const current =
subscription.audioPlayer.state.status === AudioPlayerStatus.Idle
? `Nothing is currently playing!`
: `Playing **${(subscription.audioPlayer.state.resource as AudioResource<Track>).metadata.title}**`;
const queue = subscription.queue
.slice(0, 5)
.map((track, index) => `${index + 1}) ${track.title}`)
.join('\n');
await interaction.reply(`${current}\n\n${queue}`);
} else {
await interaction.reply('Not playing in this server!');
}
} else if (interaction.commandName === 'pause') {
if (subscription) {
subscription.audioPlayer.pause();
await interaction.reply({ content: `Paused!`, ephemeral: true });
} else {
await interaction.reply('Not playing in this server!');
}
} else if (interaction.commandName === 'resume') {
if (subscription) {
subscription.audioPlayer.unpause();
await interaction.reply({ content: `Unpaused!`, ephemeral: true });
} else {
await interaction.reply('Not playing in this server!');
}
} else if (interaction.commandName === 'leave') {
if (subscription) {
subscription.voiceConnection.destroy();
subscriptions.delete(interaction.guildId);
await interaction.reply({ content: `Left channel!`, ephemeral: true });
} else {
await interaction.reply('Not playing in this server!');
}
} else {
await interaction.reply('Unknown command');
}
});
client.on('error', console.warn);
void client.login(token);

View File

@@ -0,0 +1,156 @@
import {
AudioPlayer,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
entersState,
VoiceConnection,
VoiceConnectionDisconnectReason,
VoiceConnectionStatus,
} from '@discordjs/voice';
import type { Track } from './track';
import { promisify } from 'node:util';
const wait = promisify(setTimeout);
/**
* A MusicSubscription exists for each active VoiceConnection. Each subscription has its own audio player and queue,
* and it also attaches logic to the audio player and voice connection for error handling and reconnection logic.
*/
export class MusicSubscription {
public readonly voiceConnection: VoiceConnection;
public readonly audioPlayer: AudioPlayer;
public queue: Track[];
public queueLock = false;
public readyLock = false;
public constructor(voiceConnection: VoiceConnection) {
this.voiceConnection = voiceConnection;
this.audioPlayer = createAudioPlayer();
this.queue = [];
this.voiceConnection.on(
'stateChange',
async (_: any, newState: { status: any; reason: any; closeCode: number }) => {
if (newState.status === VoiceConnectionStatus.Disconnected) {
if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) {
/**
* If the WebSocket closed with a 4014 code, this means that we should not manually attempt to reconnect,
* but there is a chance the connection will recover itself if the reason of the disconnect was due to
* switching voice channels. This is also the same code for the bot being kicked from the voice channel,
* so we allow 5 seconds to figure out which scenario it is. If the bot has been kicked, we should destroy
* the voice connection.
*/
try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Connecting, 5_000);
// Probably moved voice channel
} catch {
this.voiceConnection.destroy();
// Probably removed from voice channel
}
} else if (this.voiceConnection.rejoinAttempts < 5) {
/**
* The disconnect in this case is recoverable, and we also have <5 repeated attempts so we will reconnect.
*/
await wait((this.voiceConnection.rejoinAttempts + 1) * 5_000);
this.voiceConnection.rejoin();
} else {
/**
* The disconnect in this case may be recoverable, but we have no more remaining attempts - destroy.
*/
this.voiceConnection.destroy();
}
} else if (newState.status === VoiceConnectionStatus.Destroyed) {
/**
* Once destroyed, stop the subscription.
*/
this.stop();
} else if (
!this.readyLock &&
(newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling)
) {
/**
* In the Signalling or Connecting states, we set a 20 second time limit for the connection to become ready
* before destroying the voice connection. This stops the voice connection permanently existing in one of these
* states.
*/
this.readyLock = true;
try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20_000);
} catch {
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) this.voiceConnection.destroy();
} finally {
this.readyLock = false;
}
}
},
);
// Configure audio player
this.audioPlayer.on(
'stateChange',
(oldState: { status: any; resource: any }, newState: { status: any; resource: any }) => {
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
// If the Idle state is entered from a non-Idle state, it means that an audio resource has finished playing.
// The queue is then processed to start playing the next track, if one is available.
(oldState.resource as AudioResource<Track>).metadata.onFinish();
void this.processQueue();
} else if (newState.status === AudioPlayerStatus.Playing) {
// If the Playing state has been entered, then a new track has started playback.
(newState.resource as AudioResource<Track>).metadata.onStart();
}
},
);
this.audioPlayer.on('error', (error: { resource: any }) =>
(error.resource as AudioResource<Track>).metadata.onError(error),
);
voiceConnection.subscribe(this.audioPlayer);
}
/**
* Adds a new Track to the queue.
*
* @param track The track to add to the queue
*/
public enqueue(track: Track) {
this.queue.push(track);
void this.processQueue();
}
/**
* Stops audio playback and empties the queue.
*/
public stop() {
this.queueLock = true;
this.queue = [];
this.audioPlayer.stop(true);
}
/**
* Attempts to play a Track from the queue.
*/
private async processQueue(): Promise<void> {
// If the queue is locked (already being processed), is empty, or the audio player is already playing something, return
if (this.queueLock || this.audioPlayer.state.status !== AudioPlayerStatus.Idle || this.queue.length === 0) {
return;
}
// Lock the queue to guarantee safe access
this.queueLock = true;
// Take the first item from the queue. This is guaranteed to exist due to the non-empty check above.
const nextTrack = this.queue.shift()!;
try {
// Attempt to convert the Track into an AudioResource (i.e. start streaming the video)
const resource = await nextTrack.createAudioResource();
this.audioPlayer.play(resource);
this.queueLock = false;
} catch (error) {
// If an error occurred, try the next item of the queue instead
nextTrack.onError(error as Error);
this.queueLock = false;
return this.processQueue();
}
}
}

View File

@@ -0,0 +1,113 @@
import { getInfo } from 'ytdl-core';
import { AudioResource, createAudioResource, demuxProbe } from '@discordjs/voice';
import { raw as ytdl } from 'youtube-dl-exec';
/**
* This is the data required to create a Track object.
*/
export interface TrackData {
url: string;
title: string;
onStart: () => void;
onFinish: () => void;
onError: (error: Error) => void;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
/**
* A Track represents information about a YouTube video (in this context) that can be added to a queue.
* It contains the title and URL of the video, as well as functions onStart, onFinish, onError, that act
* as callbacks that are triggered at certain points during the track's lifecycle.
*
* Rather than creating an AudioResource for each video immediately and then keeping those in a queue,
* we use tracks as they don't pre-emptively load the videos. Instead, once a Track is taken from the
* queue, it is converted into an AudioResource just in time for playback.
*/
export class Track implements TrackData {
public readonly url: string;
public readonly title: string;
public readonly onStart: () => void;
public readonly onFinish: () => void;
public readonly onError: (error: Error) => void;
private constructor({ url, title, onStart, onFinish, onError }: TrackData) {
this.url = url;
this.title = title;
this.onStart = onStart;
this.onFinish = onFinish;
this.onError = onError;
}
/**
* Creates an AudioResource from this Track.
*/
public createAudioResource(): Promise<AudioResource<Track>> {
return new Promise((resolve, reject) => {
const process = ytdl(
this.url,
{
o: '-',
q: '',
f: 'bestaudio[ext=webm+acodec=opus+asr=48000]/bestaudio',
r: '100K',
},
{ stdio: ['ignore', 'pipe', 'ignore'] },
);
if (!process.stdout) {
reject(new Error('No stdout'));
return;
}
const stream = process.stdout;
const onError = (error: Error) => {
if (!process.killed) process.kill();
stream.resume();
reject(error);
};
process
.once('spawn', () => {
demuxProbe(stream)
.then((probe: { stream: any; type: any }) =>
resolve(createAudioResource(probe.stream, { metadata: this, inputType: probe.type })),
)
.catch(onError);
})
.catch(onError);
});
}
/**
* Creates a Track from a video URL and lifecycle callback methods.
*
* @param url The URL of the video
* @param methods Lifecycle callbacks
*
* @returns The created Track
*/
public static async from(url: string, methods: Pick<Track, 'onStart' | 'onFinish' | 'onError'>): Promise<Track> {
const info = await getInfo(url);
// The methods are wrapped so that we can ensure that they are only called once.
const wrappedMethods = {
onStart() {
wrappedMethods.onStart = noop;
methods.onStart();
},
onFinish() {
wrappedMethods.onFinish = noop;
methods.onFinish();
},
onError(error: Error) {
wrappedMethods.onError = noop;
methods.onError(error);
},
};
return new Track({
title: info.videoDetails.title,
url,
...wrappedMethods,
});
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"rootDir": "src",
"paths": {
"@discordjs/voice": ["../../"],
"@discordjs/opus": ["./node_modules/@discordjs/opus"],
"sodium": ["./node_modules/sodium"],
"libsodium-wrappers": ["./node_modules/libsodium-wrappers"],
"tweetnacl": ["./node_modules/tweetnacl"]
}
},
"include": ["src/**/*.ts"],
"exclude": [""]
}

View File

@@ -0,0 +1,2 @@
config.json
package-lock.json

View File

@@ -0,0 +1,64 @@
# Discord Radio Bot 🎧
A proof-of-concept radio bot that uses @discordjs/voice and discord.js. Streams audio from an audio output hardware device on your computer over a Discord voice channel.
**Works on:**
- Linux (via PulseAudio `pulse`)
- Windows (via DirectShow `dshow`)
## Usage
```bash
# Clone the main @discordjs/voice repo, then install dependencies and build
$ npm install
$ npm run build
# Enter this example's directory, create a config file and start!
$ cd examples/radio-bot
$ npm install
$ nano config.json
$ npm start
# Join a voice channel in Discord, then send "-join"
```
## Configuring on Windows via `dshow`
Run `ffmpeg -list_devices true -f dshow -i dummy` and observe output containing something similar:
```
DirectShow audio devices
"Stereo Mix (Realtek(R) Audio)"
Alternative name "@device_cm_{ID1}\wave_{ID2}"
```
For example, playing the above device will mirror audio from the speaker output of your machine. Your `config.json` should then be considered like so:
```json
{
"token": "discord_bot_token",
"device": "Stereo Mix (Realtek(R) Audio)",
"type": "dshow",
"maxTransmissionGap": 5000
}
```
## Configuring on Linux via `pulse`
Run `pactl list short sources` and observe output containing something similar:
```
5 alsa_output.pci.3.analog-stereo.monitor module-alsa-card.c s16le 2ch 44100Hz IDLE
```
Then configure your `config.json` with the device you'd like to use:
```json
{
"token": "discord_bot_token",
"device": "alsa_output.pci.3.analog-stereo.monitor",
"type": "pulse",
"maxTransmissionGap": 5000
}
```

View File

@@ -0,0 +1,6 @@
{
"token": "discord_bot_token",
"device": "audio_hw_device_id",
"type": "pulse",
"maxTransmissionGap": 5000
}

View File

@@ -0,0 +1,104 @@
require('module-alias/register');
const { Client } = require('discord.js');
const prism = require('prism-media');
const config = require('./config.json');
const {
NoSubscriberBehavior,
StreamType,
createAudioPlayer,
createAudioResource,
entersState,
AudioPlayerStatus,
VoiceConnectionStatus,
joinVoiceChannel,
} = require('@discordjs/voice');
const player = createAudioPlayer({
behaviors: {
noSubscriber: NoSubscriberBehavior.Play,
maxMissedFrames: Math.round(config.maxTransmissionGap / 20),
},
});
player.on('stateChange', (oldState, newState) => {
if (oldState.status === AudioPlayerStatus.Idle && newState.status === AudioPlayerStatus.Playing) {
console.log('Playing audio output on audio player');
} else if (newState.status === AudioPlayerStatus.Idle) {
console.log('Playback has stopped. Attempting to restart.');
attachRecorder();
}
});
function attachRecorder() {
player.play(
createAudioResource(
new prism.FFmpeg({
args: [
'-analyzeduration',
'0',
'-loglevel',
'0',
'-f',
config.type,
'-i',
config.type === 'dshow' ? `audio=${config.device}` : config.device,
'-acodec',
'libopus',
'-f',
'opus',
'-ar',
'48000',
'-ac',
'2',
],
}),
{
inputType: StreamType.OggOpus,
},
),
);
console.log('Attached recorder - ready to go!');
}
async function connectToChannel(channel) {
const connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
});
try {
await entersState(connection, VoiceConnectionStatus.Ready, 30_000);
return connection;
} catch (error) {
connection.destroy();
throw error;
}
}
const client = new Client({ intents: ['GUILDS', 'GUILD_MESSAGES', 'GUILD_VOICE_STATES'] });
client.on('ready', async () => {
console.log('discord.js client is ready!');
attachRecorder();
});
client.on('messageCreate', async (message) => {
if (!message.guild) return;
if (message.content === '-join') {
const channel = message.member?.voice.channel;
if (channel) {
try {
const connection = await connectToChannel(channel);
connection.subscribe(player);
await message.reply('Playing now!');
} catch (error) {
console.error(error);
}
} else {
await message.reply('Join a voice channel then try again!');
}
}
});
void client.login(config.token);

View File

@@ -0,0 +1,32 @@
{
"name": "discord-radio-bot",
"version": "1.0.0",
"description": "A proof-of-concept radio bot for @discordjs/voice",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js"
},
"keywords": [
"discord",
"radio",
"bot",
"audio",
"speakers",
"hardware",
"dj"
],
"author": "Amish Shah <amish@shah.gg>",
"license": "MIT",
"dependencies": {
"@discordjs/voice": "file:../../",
"discord.js": "^13.0.0-dev.f7eeccba4b7015496df811f10cc2da2b0fab0630",
"libsodium-wrappers": "^0.7.9",
"module-alias": "^2.2.2",
"prism-media": "^1.3.1"
},
"_moduleAliases": {
"@root": ".",
"@discordjs/voice": "../../",
"libsodium-wrappers": "./node_modules/libsodium-wrappers"
}
}

View File

@@ -0,0 +1,7 @@
{
"root": true,
"extends": "../../.eslintrc.json",
"parserOptions": {
"project": "./tsconfig.eslint.json"
}
}

View File

@@ -0,0 +1,4 @@
package-lock.json
auth.json
tsconfig.tsbuildinfo
recordings/*.ogg

View File

@@ -0,0 +1,23 @@
# 👂 Recorder Bot
This example shows how you can use the voice receive functionality in @discordjs/voice to record users in voice channels
and save the audio to local Ogg files.
## Usage
```sh-session
# Clone the main repository, and then run:
$ npm install
$ npm run build
# Open this example and install dependencies
$ cd examples/recorder
$ npm install
# Set a bot token (see auth.example.json)
$ cp auth.example.json auth.json
$ nano auth.json
# Start the bot!
$ npm start
```

View File

@@ -0,0 +1,3 @@
{
"token": "Your Discord bot token here"
}

View File

@@ -0,0 +1,28 @@
{
"name": "receiver-bot",
"version": "0.0.1",
"description": "An example receiver bot written using @discordjs/voice",
"scripts": {
"start": "npm run build && node -r tsconfig-paths/register dist/bot",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"prettier": "prettier --write **/*.{ts,js,json,yml,yaml}",
"build": "tsc",
"build:check": "tsc --noEmit --incremental false"
},
"author": "Amish Shah <contact@shah.gg>",
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.5.3",
"discord-api-types": "^0.22.0",
"discord.js": "^13.0.1",
"libsodium-wrappers": "^0.7.9",
"node-crc": "^1.3.2",
"prism-media": "^2.0.0-alpha.0"
},
"devDependencies": {
"tsconfig-paths": "^3.10.1",
"typescript": "~4.3.5"
}
}

View File

@@ -0,0 +1,46 @@
import Discord, { Interaction } from 'discord.js';
import { getVoiceConnection } from '@discordjs/voice';
import { deploy } from './deploy';
import { interactionHandlers } from './interactions';
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const { token } = require('../auth.json');
const client = new Discord.Client({ intents: ['GUILD_VOICE_STATES', 'GUILD_MESSAGES', 'GUILDS'] });
client.on('ready', () => console.log('Ready!'));
client.on('messageCreate', async (message) => {
if (!message.guild) return;
if (!client.application?.owner) await client.application?.fetch();
if (message.content.toLowerCase() === '!deploy' && message.author.id === client.application?.owner?.id) {
await deploy(message.guild);
await message.reply('Deployed!');
}
});
/**
* The IDs of the users that can be recorded by the bot.
*/
const recordable = new Set<string>();
client.on('interactionCreate', async (interaction: Interaction) => {
if (!interaction.isCommand() || !interaction.guildId) return;
const handler = interactionHandlers.get(interaction.commandName);
try {
if (handler) {
await handler(interaction, recordable, client, getVoiceConnection(interaction.guildId));
} else {
await interaction.reply('Unknown command');
}
} catch (error) {
console.warn(error);
}
});
client.on('error', console.warn);
void client.login(token);

View File

@@ -0,0 +1,42 @@
import { EndBehaviorType, VoiceReceiver } from '@discordjs/voice';
import { User } from 'discord.js';
import { createWriteStream } from 'node:fs';
import prism from 'prism-media';
import { pipeline } from 'node:stream';
function getDisplayName(userId: string, user?: User) {
return user ? `${user.username}_${user.discriminator}` : userId;
}
export function createListeningStream(receiver: VoiceReceiver, userId: string, user?: User) {
const opusStream = receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 100,
},
});
const oggStream = new prism.opus.OggLogicalBitstream({
opusHead: new prism.opus.OpusHead({
channelCount: 2,
sampleRate: 48000,
}),
pageSizeControl: {
maxPackets: 10,
},
});
const filename = `./recordings/${Date.now()}-${getDisplayName(userId, user)}.ogg`;
const out = createWriteStream(filename);
console.log(`👂 Started recording ${filename}`);
pipeline(opusStream, oggStream, out, (err) => {
if (err) {
console.warn(`❌ Error recording file ${filename} - ${err.message}`);
} else {
console.log(`✅ Recorded ${filename}`);
}
});
}

View File

@@ -0,0 +1,26 @@
import { Guild } from 'discord.js';
export const deploy = async (guild: Guild) => {
await guild.commands.set([
{
name: 'join',
description: 'Joins the voice channel that you are in',
},
{
name: 'record',
description: 'Enables recording for a user',
options: [
{
name: 'speaker',
type: 'USER' as const,
description: 'The user to record',
required: true,
},
],
},
{
name: 'leave',
description: 'Leave the voice channel',
},
]);
};

View File

@@ -0,0 +1,92 @@
import { entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from '@discordjs/voice';
import { Client, CommandInteraction, GuildMember, Snowflake } from 'discord.js';
import { createListeningStream } from './createListeningStream';
async function join(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) {
await interaction.deferReply();
if (!connection) {
if (interaction.member instanceof GuildMember && interaction.member.voice.channel) {
const channel = interaction.member.voice.channel;
connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
selfDeaf: false,
selfMute: true,
adapterCreator: channel.guild.voiceAdapterCreator,
});
} else {
await interaction.followUp('Join a voice channel and then try that again!');
return;
}
}
try {
await entersState(connection, VoiceConnectionStatus.Ready, 20e3);
const receiver = connection.receiver;
receiver.speaking.on('start', (userId) => {
if (recordable.has(userId)) {
createListeningStream(receiver, userId, client.users.cache.get(userId));
}
});
} catch (error) {
console.warn(error);
await interaction.followUp('Failed to join voice channel within 20 seconds, please try again later!');
}
await interaction.followUp('Ready!');
}
async function record(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) {
if (connection) {
const userId = interaction.options.get('speaker')!.value! as Snowflake;
recordable.add(userId);
const receiver = connection.receiver;
if (connection.receiver.speaking.users.has(userId)) {
createListeningStream(receiver, userId, client.users.cache.get(userId));
}
await interaction.reply({ ephemeral: true, content: 'Listening!' });
} else {
await interaction.reply({ ephemeral: true, content: 'Join a voice channel and then try that again!' });
}
}
async function leave(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) {
if (connection) {
connection.destroy();
recordable.clear();
await interaction.reply({ ephemeral: true, content: 'Left the channel!' });
} else {
await interaction.reply({ ephemeral: true, content: 'Not playing in this server!' });
}
}
export const interactionHandlers = new Map<
string,
(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) => Promise<void>
>();
interactionHandlers.set('join', join);
interactionHandlers.set('record', record);
interactionHandlers.set('leave', leave);

View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"paths": {
"@discordjs/voice": ["../../"],
"libsodium-wrappers": ["./node_modules/libsodium-wrappers"]
}
},
"include": ["src/*.ts"],
"exclude": [""]
}