Merge branch 'master' into bugfix/only-add-listeners-once

This commit is contained in:
Max Isom 2021-11-17 16:16:00 -05:00
commit dd81fc47fd
No known key found for this signature in database
GPG key ID: 25C9B1A7F6798880
14 changed files with 763 additions and 581 deletions

View file

@ -8,6 +8,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-node@v2
with:
node-version: '16'
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v1 uses: actions/cache@v1
with: with:

View file

@ -1,7 +1,7 @@
FROM node:14.15.4-alpine AS base FROM node:16.13.0-alpine AS base
# Install ffmpeg and build dependencies # Install ffmpeg and build dependencies
RUN apk add --no-cache ffmpeg python make g++ RUN apk add --no-cache ffmpeg python2 make g++
WORKDIR /usr/app WORKDIR /usr/app

View file

@ -10,7 +10,7 @@
"types": "dts/types", "types": "dts/types",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": ">=16.0.0"
}, },
"files": [ "files": [
"dist", "dist",
@ -33,11 +33,11 @@
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/fluent-ffmpeg": "^2.1.17", "@types/fluent-ffmpeg": "^2.1.17",
"@types/fs-capacitor": "^2.0.0", "@types/fs-capacitor": "^2.0.0",
"@types/node": "^14.14.41", "@types/node": "^16.11.6",
"@types/node-emoji": "^1.8.1", "@types/node-emoji": "^1.8.1",
"@types/spotify-web-api-node": "^5.0.2", "@types/spotify-web-api-node": "^5.0.2",
"@types/validator": "^13.1.4", "@types/validator": "^13.1.4",
"@types/ws": "^7.4.4", "@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^4.31.1", "@typescript-eslint/eslint-plugin": "^4.31.1",
"@typescript-eslint/parser": "^4.31.1", "@typescript-eslint/parser": "^4.31.1",
"eslint": "^7.32.0", "eslint": "^7.32.0",
@ -45,7 +45,7 @@
"eslint-config-xo-typescript": "^0.44.0", "eslint-config-xo-typescript": "^0.44.0",
"husky": "^4.3.8", "husky": "^4.3.8",
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"ts-node": "^9.1.1", "ts-node": "^10.4.0",
"typescript": "^4.4.3" "typescript": "^4.4.3"
}, },
"eslintConfig": { "eslintConfig": {
@ -70,11 +70,13 @@
} }
}, },
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.5.3", "@discordjs/opus": "^0.7.0",
"@discordjs/voice": "^0.7.5",
"@types/libsodium-wrappers": "^0.7.9",
"array-shuffle": "^2.0.0", "array-shuffle": "^2.0.0",
"debug": "^4.3.1", "debug": "^4.3.1",
"delay": "^5.0.0", "delay": "^5.0.0",
"discord.js": "^12.5.3", "discord.js": "^13.3.0",
"dotenv": "^8.5.1", "dotenv": "^8.5.1",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-capacitor": "^6.2.0", "fs-capacitor": "^6.2.0",
@ -83,6 +85,7 @@
"hasha": "^5.2.2", "hasha": "^5.2.2",
"inversify": "^5.1.1", "inversify": "^5.1.1",
"iso8601-duration": "^1.3.0", "iso8601-duration": "^1.3.0",
"libsodium-wrappers": "^0.7.9",
"make-dir": "^3.1.0", "make-dir": "^3.1.0",
"node-emoji": "^1.10.0", "node-emoji": "^1.10.0",
"p-event": "^4.2.0", "p-event": "^4.2.0",

View file

@ -11,6 +11,7 @@ import handleVoiceStateUpdate from './events/voice-state-update.js';
import errorMsg from './utils/error-msg.js'; import errorMsg from './utils/error-msg.js';
import {isUserInVoice} from './utils/channels.js'; import {isUserInVoice} from './utils/channels.js';
import Config from './services/config.js'; import Config from './services/config.js';
import {generateDependencyReport} from '@discordjs/voice';
@injectable() @injectable()
export default class { export default class {
@ -34,7 +35,7 @@ export default class {
commandNames.forEach(commandName => this.commands.set(commandName, command)); commandNames.forEach(commandName => this.commands.set(commandName, command));
}); });
this.client.on('message', async (msg: Message) => { this.client.on('messageCreate', async (msg: Message) => {
// Get guild settings // Get guild settings
if (!msg.guild) { if (!msg.guild) {
return; return;
@ -44,7 +45,8 @@ export default class {
if (!settings) { if (!settings) {
// Got into a bad state, send owner welcome message // Got into a bad state, send owner welcome message
return this.client.emit('guildCreate', msg.guild); this.client.emit('guildCreate', msg.guild);
return;
} }
const {prefix, channel} = settings; const {prefix, channel} = settings;
@ -95,6 +97,7 @@ export default class {
}); });
this.client.on('ready', async () => { this.client.on('ready', async () => {
debug(generateDependencyReport());
console.log(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''}&scope=bot&permissions=36752448`); console.log(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''}&scope=bot&permissions=36752448`);
}); });

View file

@ -1,4 +1,4 @@
import {TextChannel, Message, GuildChannel} from 'discord.js'; import {TextChannel, Message, GuildChannel, ThreadChannel} from 'discord.js';
import {injectable} from 'inversify'; import {injectable} from 'inversify';
import {Settings} from '../models/index.js'; import {Settings} from '../models/index.js';
import errorMsg from '../utils/error-msg.js'; import errorMsg from '../utils/error-msg.js';
@ -20,6 +20,7 @@ export default class implements Command {
if (settings) { if (settings) {
let response = `prefix: \`${settings.prefix}\`\n`; let response = `prefix: \`${settings.prefix}\`\n`;
// eslint-disable-next-line @typescript-eslint/no-base-to-string
response += `channel: ${msg.guild!.channels.cache.get(settings.channel)!.toString()}`; response += `channel: ${msg.guild!.channels.cache.get(settings.channel)!.toString()}`;
await msg.channel.send(response); await msg.channel.send(response);
@ -35,7 +36,7 @@ export default class implements Command {
return; return;
} }
if (msg.author.id !== msg.guild!.owner!.id) { if (msg.author.id !== msg.guild!.ownerId) {
await msg.channel.send(errorMsg('not authorized')); await msg.channel.send(errorMsg('not authorized'));
return; return;
} }
@ -51,7 +52,7 @@ export default class implements Command {
} }
case 'channel': { case 'channel': {
let channel: GuildChannel | undefined; let channel: GuildChannel | ThreadChannel | undefined;
if (args[1].includes('<#') && args[1].includes('>')) { if (args[1].includes('<#') && args[1].includes('>')) {
channel = msg.guild!.channels.cache.find(c => c.id === args[1].slice(2, args[1].indexOf('>'))); channel = msg.guild!.channels.cache.find(c => c.id === args[1].slice(2, args[1].indexOf('>')));
@ -59,7 +60,7 @@ export default class implements Command {
channel = msg.guild!.channels.cache.find(c => c.name === args[1]); channel = msg.guild!.channels.cache.find(c => c.name === args[1]);
} }
if (channel && channel.type === 'text') { if (channel && channel.type === 'GUILD_TEXT') {
await Settings.update({channel: channel.id}, {where: {guildId: msg.guild!.id}}); await Settings.update({channel: channel.id}, {where: {guildId: msg.guild!.id}});
await Promise.all([ await Promise.all([

View file

@ -1,4 +1,4 @@
import {Message} from 'discord.js'; import {Message, Util} from 'discord.js';
import {injectable} from 'inversify'; import {injectable} from 'inversify';
import Command from '.'; import Command from '.';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
@ -29,7 +29,7 @@ export default class implements Command {
const {prefix} = settings; const {prefix} = settings;
const res = this.commands.sort((a, b) => a.name.localeCompare(b.name)).reduce((content, command) => { const res = Util.splitMessage(this.commands.sort((a, b) => a.name.localeCompare(b.name)).reduce((content, command) => {
const aliases = command.aliases.reduce((str, alias, i) => { const aliases = command.aliases.reduce((str, alias, i) => {
str += alias; str += alias;
@ -53,9 +53,13 @@ export default class implements Command {
content += '\n'; content += '\n';
return content; return content;
}, ''); }, ''));
for (const r of res) {
// eslint-disable-next-line no-await-in-loop
await msg.author.send(r);
}
await msg.author.send(res, {split: true});
await msg.react('🇩'); await msg.react('🇩');
await msg.react('🇲'); await msg.react('🇲');
} }

View file

@ -74,7 +74,7 @@ export default class implements Command {
embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false); embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false);
await msg.channel.send(embed); await msg.channel.send({embeds: [embed]});
} else { } else {
await msg.channel.send('queue empty'); await msg.channel.send('queue empty');
} }

View file

@ -55,7 +55,7 @@ export default class implements Command {
const newShortcut = {shortcut: shortcutName, command, guildId: msg.guild!.id, authorId: msg.author.id}; const newShortcut = {shortcut: shortcutName, command, guildId: msg.guild!.id, authorId: msg.author.id};
if (shortcut) { if (shortcut) {
if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.owner!.id) { if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.ownerId) {
await msg.channel.send(errorMsg('you do\'nt have permission to do that')); await msg.channel.send(errorMsg('you do\'nt have permission to do that'));
return; return;
} }
@ -80,7 +80,7 @@ export default class implements Command {
} }
// Check permissions // Check permissions
if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.owner!.id) { if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.ownerId) {
await msg.channel.send(errorMsg('you don\'t have permission to do that')); await msg.channel.send(errorMsg('you don\'t have permission to do that'));
return; return;
} }

View file

@ -9,7 +9,7 @@ const DEFAULT_PREFIX = '!';
export default async (guild: Guild): Promise<void> => { export default async (guild: Guild): Promise<void> => {
await Settings.upsert({guildId: guild.id, prefix: DEFAULT_PREFIX}); await Settings.upsert({guildId: guild.id, prefix: DEFAULT_PREFIX});
const owner = await guild.client.users.fetch(guild.ownerID); const owner = await guild.client.users.fetch(guild.ownerId);
let firstStep = '👋 Hi!\n'; let firstStep = '👋 Hi!\n';
firstStep += 'I just need to ask a few questions before you start listening to music.\n\n'; firstStep += 'I just need to ask a few questions before you start listening to music.\n\n';
@ -27,7 +27,7 @@ export default async (guild: Guild): Promise<void> => {
const emojiChannels: EmojiChannel[] = []; const emojiChannels: EmojiChannel[] = [];
for (const [channelId, channel] of guild.channels.cache) { for (const [channelId, channel] of guild.channels.cache) {
if (channel.type === 'text') { if (channel.type === 'GUILD_TEXT') {
emojiChannels.push({ emojiChannels.push({
name: channel.name, name: channel.name,
id: channelId, id: channelId,
@ -65,7 +65,7 @@ export default async (guild: Guild): Promise<void> => {
await owner.send(secondStep); await owner.send(secondStep);
const prefixResponses = await firstStepMsg.channel.awaitMessages((r: Message) => r.content.length === 1, {max: 1}); const prefixResponses = await firstStepMsg.channel.awaitMessages({filter: (r: Message) => r.content.length === 1, max: 1});
const prefixCharacter = prefixResponses.first()!.content; const prefixCharacter = prefixResponses.first()!.content;

View file

@ -1,4 +1,4 @@
import {VoiceState} from 'discord.js'; import {VoiceChannel, VoiceState} from 'discord.js';
import container from '../inversify.config.js'; import container from '../inversify.config.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
@ -10,7 +10,8 @@ export default (oldState: VoiceState, _: VoiceState): void => {
const player = playerManager.get(oldState.guild.id); const player = playerManager.get(oldState.guild.id);
if (player.voiceConnection) { if (player.voiceConnection) {
if (getSizeWithoutBots(player.voiceConnection.channel) === 0) { const voiceChannel: VoiceChannel = oldState.guild.channels.cache.get(player.voiceConnection.joinConfig.channelId!) as VoiceChannel;
if (!voiceChannel || getSizeWithoutBots(voiceChannel) === 0) {
player.disconnect(); player.disconnect();
} }
} }

View file

@ -2,7 +2,7 @@ import 'reflect-metadata';
import {Container} from 'inversify'; import {Container} from 'inversify';
import {TYPES} from './types.js'; import {TYPES} from './types.js';
import Bot from './bot.js'; import Bot from './bot.js';
import {Client} from 'discord.js'; import {Client, Intents} from 'discord.js';
import ConfigProvider from './services/config.js'; import ConfigProvider from './services/config.js';
// Managers // Managers
@ -32,9 +32,17 @@ import CacheProvider from './services/cache.js';
const container = new Container(); const container = new Container();
// Intents
const intents = new Intents();
intents.add(Intents.FLAGS.GUILDS); // To listen for guildCreate event
intents.add(Intents.FLAGS.GUILD_MESSAGES); // To listen for messages (messageCreate event)
intents.add(Intents.FLAGS.DIRECT_MESSAGE_REACTIONS); // To listen for message reactions (messageReactionAdd event)
intents.add(Intents.FLAGS.DIRECT_MESSAGES); // To receive the prefix message
intents.add(Intents.FLAGS.GUILD_VOICE_STATES); // To listen for voice state changes (voiceStateUpdate event)
// Bot // Bot
container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope(); container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope();
container.bind<Client>(TYPES.Client).toConstantValue(new Client()); container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers // Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();

View file

@ -1,4 +1,4 @@
import {VoiceConnection, VoiceChannel, StreamDispatcher, Snowflake, Client, TextChannel} from 'discord.js'; import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js';
import {promises as fs, createWriteStream} from 'fs'; import {promises as fs, createWriteStream} from 'fs';
import {Readable, PassThrough} from 'stream'; import {Readable, PassThrough} from 'stream';
import path from 'path'; import path from 'path';
@ -8,6 +8,7 @@ import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle'; import shuffle from 'array-shuffle';
import errorMsg from '../utils/error-msg.js'; import errorMsg from '../utils/error-msg.js';
import {AudioPlayer, AudioPlayerStatus, createAudioPlayer, createAudioResource, joinVoiceChannel, StreamType, VoiceConnection, VoiceConnectionStatus} from '@discordjs/voice';
export interface QueuedPlaylist { export interface QueuedPlaylist {
title: string; title: string;
@ -35,7 +36,7 @@ export default class {
private queue: QueuedSong[] = []; private queue: QueuedSong[] = [];
private queuePosition = 0; private queuePosition = 0;
private readonly cacheDir: string; private readonly cacheDir: string;
private dispatcher: StreamDispatcher | null = null; private audioPlayer: AudioPlayer | null = null;
private nowPlaying: QueuedSong | null = null; private nowPlaying: QueuedSong | null = null;
private playPositionInterval: NodeJS.Timeout | undefined; private playPositionInterval: NodeJS.Timeout | undefined;
private lastSongURL = ''; private lastSongURL = '';
@ -50,23 +51,26 @@ export default class {
} }
async connect(channel: VoiceChannel): Promise<void> { async connect(channel: VoiceChannel): Promise<void> {
const conn = await channel.join(); const conn = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
});
this.voiceConnection = conn; this.voiceConnection = conn;
} }
disconnect(breakConnection = true): void { disconnect(): void {
if (this.voiceConnection) { if (this.voiceConnection) {
if (this.status === STATUS.PLAYING) { if (this.status === STATUS.PLAYING) {
this.pause(); this.pause();
} }
if (breakConnection) { this.voiceConnection.destroy();
this.voiceConnection.disconnect(); this.audioPlayer?.stop();
}
this.voiceConnection = null; this.voiceConnection = null;
this.dispatcher = null; this.audioPlayer = null;
} }
} }
@ -88,8 +92,11 @@ export default class {
} }
const stream = await this.getStream(currentSong.url, {seek: positionSeconds}); const stream = await this.getStream(currentSong.url, {seek: positionSeconds});
this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus', bitrate: 'auto'}); this.audioPlayer = createAudioPlayer();
this.voiceConnection.subscribe(this.audioPlayer);
this.audioPlayer.play(createAudioResource(stream, {
inputType: StreamType.WebmOpus,
}));
this.attachListeners(); this.attachListeners();
this.startTrackingPosition(positionSeconds); this.startTrackingPosition(positionSeconds);
@ -117,8 +124,8 @@ export default class {
// Resume from paused state // Resume from paused state
if (this.status === STATUS.PAUSED && currentSong.url === this.nowPlaying?.url) { if (this.status === STATUS.PAUSED && currentSong.url === this.nowPlaying?.url) {
if (this.dispatcher) { if (this.audioPlayer) {
this.dispatcher.resume(); this.audioPlayer.unpause();
this.status = STATUS.PLAYING; this.status = STATUS.PLAYING;
this.startTrackingPosition(); this.startTrackingPosition();
return; return;
@ -132,7 +139,11 @@ export default class {
try { try {
const stream = await this.getStream(currentSong.url); const stream = await this.getStream(currentSong.url);
this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus'}); this.audioPlayer = createAudioPlayer();
this.voiceConnection.subscribe(this.audioPlayer);
this.audioPlayer.play(createAudioResource(stream, {
inputType: StreamType.WebmOpus,
}));
this.attachListeners(); this.attachListeners();
@ -170,8 +181,8 @@ export default class {
this.status = STATUS.PAUSED; this.status = STATUS.PAUSED;
if (this.dispatcher) { if (this.audioPlayer) {
this.dispatcher.pause(); this.audioPlayer.pause();
} }
this.stopTrackingPosition(); this.stopTrackingPosition();
@ -369,6 +380,7 @@ export default class {
'1', '1',
'-reconnect_delay_max', '-reconnect_delay_max',
'5', '5',
'-re',
]); ]);
if (options.seek) { if (options.seek) {
@ -440,26 +452,26 @@ export default class {
return; return;
} }
if (this.voiceConnection.listeners('disconnect').length === 0) { if (this.voiceConnection.listeners(VoiceConnectionStatus.Disconnected).length === 0) {
this.voiceConnection.on('disconnect', this.onVoiceConnectionDisconnect.bind(this)); this.voiceConnection.on(VoiceConnectionStatus.Disconnected, this.onVoiceConnectionDisconnect.bind(this));
} }
if (!this.dispatcher) { if (!this.audioPlayer) {
return; return;
} }
if (this.dispatcher.listeners('speaking').length === 0) { if (this.audioPlayer.listeners('stateChange').length === 0) {
this.dispatcher.on('speaking', this.onVoiceConnectionSpeaking.bind(this)); this.audioPlayer.on('stateChange', this.onAudioPlayerStateChange.bind(this));
} }
} }
private onVoiceConnectionDisconnect(): void { private onVoiceConnectionDisconnect(): void {
this.disconnect(false); this.disconnect();
} }
private async onVoiceConnectionSpeaking(isSpeaking: boolean): Promise<void> { private async onAudioPlayerStateChange(_oldState: {status: AudioPlayerStatus}, newState: {status: AudioPlayerStatus}): Promise<void> {
// Automatically advance queued song at end // Automatically advance queued song at end
if (!isSpeaking && this.status === STATUS.PLAYING) { if (newState.status === AudioPlayerStatus.Idle && this.status === STATUS.PLAYING) {
await this.forward(1); await this.forward(1);
} }
} }

View file

@ -3,8 +3,8 @@ import {Guild, VoiceChannel, User, GuildMember} from 'discord.js';
export const isUserInVoice = (guild: Guild, user: User): boolean => { export const isUserInVoice = (guild: Guild, user: User): boolean => {
let inVoice = false; let inVoice = false;
guild.channels.cache.filter(channel => channel.type === 'voice').forEach(channel => { guild.channels.cache.filter(channel => channel.type === 'GUILD_VOICE').forEach(channel => {
if (channel.members.array().find(member => member.id === user.id)) { if ((channel as VoiceChannel).members.find(member => member.id === user.id)) {
inVoice = true; inVoice = true;
} }
}); });
@ -12,7 +12,7 @@ export const isUserInVoice = (guild: Guild, user: User): boolean => {
return inVoice; return inVoice;
}; };
export const getSizeWithoutBots = (channel: VoiceChannel): number => channel.members.array().reduce((s, member) => { export const getSizeWithoutBots = (channel: VoiceChannel): number => channel.members.reduce((s, member) => {
if (!member.user.bot) { if (!member.user.bot) {
s++; s++;
} }
@ -22,7 +22,7 @@ export const getSizeWithoutBots = (channel: VoiceChannel): number => channel.mem
export const getMemberVoiceChannel = (member?: GuildMember): [VoiceChannel, number] | null => { export const getMemberVoiceChannel = (member?: GuildMember): [VoiceChannel, number] | null => {
const channel = member?.voice?.channel; const channel = member?.voice?.channel;
if (channel && channel.type === 'voice') { if (channel && channel.type === 'GUILD_VOICE') {
return [ return [
channel, channel,
getSizeWithoutBots(channel), getSizeWithoutBots(channel),
@ -41,7 +41,7 @@ export const getMostPopularVoiceChannel = (guild: Guild): [VoiceChannel, number]
const voiceChannels: PopularResult[] = []; const voiceChannels: PopularResult[] = [];
for (const [_, channel] of guild.channels.cache) { for (const [_, channel] of guild.channels.cache) {
if (channel.type === 'voice') { if (channel.type === 'GUILD_VOICE') {
const size = getSizeWithoutBots(channel as VoiceChannel); const size = getSizeWithoutBots(channel as VoiceChannel);
voiceChannels.push({ voiceChannels.push({

1197
yarn.lock

File diff suppressed because it is too large Load diff