mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-12 09:33:32 +01:00
chore: monorepo setup (#7175)
This commit is contained in:
8
packages/voice/examples/README.md
Normal file
8
packages/voice/examples/README.md
Normal 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 |
|
||||
24
packages/voice/examples/UNLICENSE
Normal file
24
packages/voice/examples/UNLICENSE
Normal 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/>
|
||||
91
packages/voice/examples/basic/README.md
Normal file
91
packages/voice/examples/basic/README.md
Normal 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!');
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
69
packages/voice/examples/basic/adapter.ts
Normal file
69
packages/voice/examples/basic/adapter.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
152
packages/voice/examples/basic/basic-example.ts
Normal file
152
packages/voice/examples/basic/basic-example.ts
Normal 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!');
|
||||
}
|
||||
}
|
||||
});
|
||||
7
packages/voice/examples/music-bot/.eslintrc.json
Normal file
7
packages/voice/examples/music-bot/.eslintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": "../../.eslintrc.json",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.eslint.json"
|
||||
}
|
||||
}
|
||||
3
packages/voice/examples/music-bot/.gitignore
vendored
Normal file
3
packages/voice/examples/music-bot/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
package-lock.json
|
||||
auth.json
|
||||
tsconfig.tsbuildinfo
|
||||
31
packages/voice/examples/music-bot/README.md
Normal file
31
packages/voice/examples/music-bot/README.md
Normal 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.
|
||||
3
packages/voice/examples/music-bot/auth.example.json
Normal file
3
packages/voice/examples/music-bot/auth.example.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"token": "Your Discord bot token here"
|
||||
}
|
||||
28
packages/voice/examples/music-bot/package.json
Normal file
28
packages/voice/examples/music-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
188
packages/voice/examples/music-bot/src/bot.ts
Normal file
188
packages/voice/examples/music-bot/src/bot.ts
Normal 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);
|
||||
156
packages/voice/examples/music-bot/src/music/subscription.ts
Normal file
156
packages/voice/examples/music-bot/src/music/subscription.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
113
packages/voice/examples/music-bot/src/music/track.ts
Normal file
113
packages/voice/examples/music-bot/src/music/track.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
3
packages/voice/examples/music-bot/tsconfig.eslint.json
Normal file
3
packages/voice/examples/music-bot/tsconfig.eslint.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig.json"
|
||||
}
|
||||
17
packages/voice/examples/music-bot/tsconfig.json
Normal file
17
packages/voice/examples/music-bot/tsconfig.json
Normal 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": [""]
|
||||
}
|
||||
2
packages/voice/examples/radio-bot/.gitignore
vendored
Normal file
2
packages/voice/examples/radio-bot/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
config.json
|
||||
package-lock.json
|
||||
64
packages/voice/examples/radio-bot/README.md
Normal file
64
packages/voice/examples/radio-bot/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
6
packages/voice/examples/radio-bot/config.example.json
Normal file
6
packages/voice/examples/radio-bot/config.example.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"token": "discord_bot_token",
|
||||
"device": "audio_hw_device_id",
|
||||
"type": "pulse",
|
||||
"maxTransmissionGap": 5000
|
||||
}
|
||||
104
packages/voice/examples/radio-bot/index.js
Normal file
104
packages/voice/examples/radio-bot/index.js
Normal 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);
|
||||
32
packages/voice/examples/radio-bot/package.json
Normal file
32
packages/voice/examples/radio-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
packages/voice/examples/recorder/.eslintrc.json
Normal file
7
packages/voice/examples/recorder/.eslintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": "../../.eslintrc.json",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.eslint.json"
|
||||
}
|
||||
}
|
||||
4
packages/voice/examples/recorder/.gitignore
vendored
Normal file
4
packages/voice/examples/recorder/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
package-lock.json
|
||||
auth.json
|
||||
tsconfig.tsbuildinfo
|
||||
recordings/*.ogg
|
||||
23
packages/voice/examples/recorder/README.md
Normal file
23
packages/voice/examples/recorder/README.md
Normal 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
|
||||
```
|
||||
3
packages/voice/examples/recorder/auth.example.json
Normal file
3
packages/voice/examples/recorder/auth.example.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"token": "Your Discord bot token here"
|
||||
}
|
||||
28
packages/voice/examples/recorder/package.json
Normal file
28
packages/voice/examples/recorder/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
packages/voice/examples/recorder/src/bot.ts
Normal file
46
packages/voice/examples/recorder/src/bot.ts
Normal 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);
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
26
packages/voice/examples/recorder/src/deploy.ts
Normal file
26
packages/voice/examples/recorder/src/deploy.ts
Normal 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',
|
||||
},
|
||||
]);
|
||||
};
|
||||
92
packages/voice/examples/recorder/src/interactions.ts
Normal file
92
packages/voice/examples/recorder/src/interactions.ts
Normal 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);
|
||||
3
packages/voice/examples/recorder/tsconfig.eslint.json
Normal file
3
packages/voice/examples/recorder/tsconfig.eslint.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig.json"
|
||||
}
|
||||
13
packages/voice/examples/recorder/tsconfig.json
Normal file
13
packages/voice/examples/recorder/tsconfig.json
Normal 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": [""]
|
||||
}
|
||||
Reference in New Issue
Block a user