fix command permission handling and push discord to v10 (#640)

Co-authored-by: Max Isom <hi@maxisom.me>
This commit is contained in:
Kevin Kendzia 2022-05-14 02:44:14 +02:00 committed by GitHub
parent 1ef05aba9d
commit eb2885b206
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1214 additions and 644 deletions

View file

@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- Migrated to the v10 API
- Command permissions are now configured differently: you can now configure permissions in Discord's UI rather than through the bot. See the [wiki page](https://github.com/codetheweb/muse/wiki/Configuring-Bot-Permissions) for details.
- 🚨 when you upgrade to this version, the role you manually set with `/config set-role` will no longer be respected. Check the above link for how to re-configure permissions.
## [1.9.0] - 2022-04-23
### Changed

View file

@ -0,0 +1,22 @@
/*
Warnings:
- You are about to drop the column `invitedByUserId` on the `Setting` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"roleId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "roleId", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "roleId", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -49,7 +49,7 @@
"prisma": "^3.11.0",
"release-it": "^14.11.8",
"type-fest": "^2.12.0",
"typescript": "^4.6.2"
"typescript": "^4.6.4"
},
"eslintConfig": {
"extends": [
@ -74,17 +74,17 @@
}
},
"dependencies": {
"@discordjs/builders": "^0.12.0",
"@discordjs/builders": "0.14.0-dev.1652443433-d522320",
"@discordjs/opus": "^0.7.0",
"@discordjs/rest": "^0.3.0",
"@discordjs/voice": "^0.8.0",
"@discordjs/rest": "0.5.0-dev.1651147752-679dcda",
"@discordjs/voice": "0.10.0-dev.1651147759-679dcda",
"@prisma/client": "^3.11.0",
"@types/libsodium-wrappers": "^0.7.9",
"array-shuffle": "^3.0.0",
"debug": "^4.3.3",
"delay": "^5.0.0",
"discord-api-types": "^0.29.0",
"discord.js": "^13.6.0",
"discord-api-types": "0.32.1",
"discord.js": "14.0.0-dev.1652443445-d522320",
"dotenv": "^16.0.0",
"esmo": "0.14.1",
"execa": "^6.1.0",
@ -108,6 +108,7 @@
"reflect-metadata": "^0.1.13",
"spotify-uri": "^2.2.0",
"spotify-web-api-node": "^5.0.2",
"sync-fetch": "^0.3.1",
"xbytes": "^1.7.0",
"youtube.ts": "^0.2.8",
"ytdl-core": "^4.11.0",

View file

@ -25,7 +25,6 @@ model KeyValueCache {
model Setting {
guildId String @id
invitedByUserId String?
playlistLimit Int @default(50)
secondsToWaitAfterQueueEmpties Int @default(30)
leaveIfNoListeners Boolean @default(true)

View file

@ -1,4 +1,4 @@
import {Client, Collection, ExcludeEnum, PresenceStatusData, User} from 'discord.js';
import {Client, Collection, PresenceStatusData, User} from 'discord.js';
import {inject, injectable} from 'inversify';
import ora from 'ora';
import {TYPES} from './types.js';
@ -7,32 +7,27 @@ import Command from './commands/index.js';
import debug from './utils/debug.js';
import handleGuildCreate from './events/guild-create.js';
import handleVoiceStateUpdate from './events/voice-state-update.js';
import handleGuildUpdate from './events/guild-update.js';
import errorMsg from './utils/error-msg.js';
import {isUserInVoice} from './utils/channels.js';
import Config from './services/config.js';
import {generateDependencyReport} from '@discordjs/voice';
import {REST} from '@discordjs/rest';
import {Routes} from 'discord-api-types/v9';
import updatePermissionsForGuild from './utils/update-permissions-for-guild.js';
import {ActivityTypes} from 'discord.js/typings/enums';
import {Routes, ActivityType} from 'discord-api-types/v10';
import registerCommandsOnGuild from './utils/register-commands-on-guild.js';
@injectable()
export default class {
private readonly client: Client;
private readonly config: Config;
private readonly token: string;
private readonly shouldRegisterCommandsOnBot: boolean;
private readonly commandsByName!: Collection<string, Command>;
private readonly commandsByButtonId!: Collection<string, Command>;
constructor(
@inject(TYPES.Client) client: Client,
@inject(TYPES.Config) config: Config,
) {
@inject(TYPES.Config) config: Config) {
this.client = client;
this.config = config;
this.token = config.DISCORD_TOKEN;
this.shouldRegisterCommandsOnBot = config.REGISTER_COMMANDS_ON_BOT;
this.commandsByName = new Collection();
this.commandsByButtonId = new Collection();
@ -61,12 +56,13 @@ export default class {
}
// Register event handlers
// eslint-disable-next-line complexity
this.client.on('interactionCreate', async interaction => {
try {
if (interaction.isCommand()) {
const command = this.commandsByName.get(interaction.commandName);
if (!command) {
if (!command || !interaction.isChatInputCommand()) {
return;
}
@ -76,7 +72,6 @@ export default class {
}
const requiresVC = command.requiresVC instanceof Function ? command.requiresVC(interaction) : command.requiresVC;
if (requiresVC && interaction.member && !isUserInVoice(interaction.guild, interaction.member.user as User)) {
await interaction.reply({content: errorMsg('gotta be in a voice channel'), ephemeral: true});
return;
@ -111,9 +106,9 @@ export default class {
// This can fail if the message was deleted, and we don't want to crash the whole bot
try {
if ((interaction.isApplicationCommand() || interaction.isButton()) && (interaction.replied || interaction.deferred)) {
if ((interaction.isCommand() || interaction.isButton()) && (interaction.replied || interaction.deferred)) {
await interaction.editReply(errorMsg(error as Error));
} else if (interaction.isApplicationCommand() || interaction.isButton()) {
} else if (interaction.isCommand() || interaction.isButton()) {
await interaction.reply({content: errorMsg(error as Error), ephemeral: true});
}
} catch {}
@ -126,11 +121,9 @@ export default class {
debug(generateDependencyReport());
// Update commands
const rest = new REST({version: '9'}).setToken(this.token);
const rest = new REST({version: '10'}).setToken(this.config.DISCORD_TOKEN);
if (this.shouldRegisterCommandsOnBot) {
spinner.text = '📡 updating commands on bot...';
await rest.put(
Routes.applicationCommands(this.client.user!.id),
{body: this.commandsByName.map(command => command.slashCommand.toJSON())},
@ -140,10 +133,12 @@ export default class {
await Promise.all([
...this.client.guilds.cache.map(async guild => {
await rest.put(
Routes.applicationGuildCommands(this.client.user!.id, guild.id),
{body: this.commandsByName.map(command => command.slashCommand.toJSON())},
);
await registerCommandsOnGuild({
rest,
guildId: guild.id,
applicationId: this.client.user!.id,
commands: this.commandsByName.map(c => c.slashCommand),
});
}),
// Remove commands registered on bot (if they exist)
rest.put(Routes.applicationCommands(this.client.user!.id), {body: []}),
@ -155,18 +150,14 @@ export default class {
activities: [
{
name: this.config.BOT_ACTIVITY,
type: this.config.BOT_ACTIVITY_TYPE as unknown as ExcludeEnum<typeof ActivityTypes, 'CUSTOM'>,
type: this.config.BOT_ACTIVITY_TYPE as unknown as Exclude<ActivityType, ActivityType.Custom>,
url: this.config.BOT_ACTIVITY_URL === '' ? undefined : this.config.BOT_ACTIVITY_URL,
},
],
status: this.config.BOT_STATUS as PresenceStatusData,
});
// Update permissions
spinner.text = '📡 updating permissions...';
await Promise.all(this.client.guilds.cache.map(async guild => updatePermissionsForGuild(guild)));
spinner.succeed(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''}&scope=bot%20applications.commands&permissions=36700288`);
spinner.succeed(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''}&scope=bot%20applications.commands&permissions=36700160`);
});
this.client.on('error', console.error);
@ -174,8 +165,6 @@ export default class {
this.client.on('guildCreate', handleGuildCreate);
this.client.on('voiceStateUpdate', handleVoiceStateUpdate);
this.client.on('guildUpdate', handleGuildUpdate);
await this.client.login(this.token);
await this.client.login();
}
}

View file

@ -1,5 +1,5 @@
import {inject, injectable} from 'inversify';
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
@ -19,7 +19,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction) {
public async execute(interaction: ChatInputCommandInteraction) {
this.playerManager.get(interaction.guild!.id).clear();
await interaction.reply('clearer than a field after a fresh harvest');

View file

@ -1,8 +1,7 @@
import {SlashCommandBuilder} from '@discordjs/builders';
import {CommandInteraction, MessageEmbed} from 'discord.js';
import {ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits} from 'discord.js';
import {injectable} from 'inversify';
import {prisma} from '../utils/db.js';
import updatePermissionsForGuild from '../utils/update-permissions-for-guild.js';
import Command from './index.js';
@injectable()
@ -10,6 +9,7 @@ export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('config')
.setDescription('configure bot settings')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild.toString() as any)
.addSubcommand(subcommand => subcommand
.setName('set-playlist-limit')
.setDescription('set the maximum number of tracks that can be added from a playlist')
@ -43,10 +43,10 @@ export default class implements Command {
.setName('get')
.setDescription('show all settings'));
async execute(interaction: CommandInteraction) {
async execute(interaction: ChatInputCommandInteraction) {
switch (interaction.options.getSubcommand()) {
case 'set-playlist-limit': {
const limit = interaction.options.getInteger('limit')!;
const limit: number = interaction.options.getInteger('limit')!;
if (limit < 1) {
throw new Error('invalid limit');
@ -66,25 +66,6 @@ export default class implements Command {
break;
}
case 'set-role': {
const role = interaction.options.getRole('role')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
roleId: role.id,
},
});
await updatePermissionsForGuild(interaction.guild!);
await interaction.reply('👍 role updated');
break;
}
case 'set-wait-after-queue-empties': {
const delay = interaction.options.getInteger('delay')!;
@ -120,7 +101,7 @@ export default class implements Command {
}
case 'get': {
const embed = new MessageEmbed().setTitle('Config');
const embed = new EmbedBuilder().setTitle('Config');
const config = await prisma.setting.findUnique({where: {guildId: interaction.guild!.id}});

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
@ -19,7 +19,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction) {
public async execute(interaction: ChatInputCommandInteraction) {
const player = this.playerManager.get(interaction.guild!.id);
if (!player.voiceConnection) {

View file

@ -1,5 +1,5 @@
import {SlashCommandBuilder} from '@discordjs/builders';
import {AutocompleteInteraction, CommandInteraction, MessageEmbed} from 'discord.js';
import {AutocompleteInteraction, ChatInputCommandInteraction, EmbedBuilder} from 'discord.js';
import {inject, injectable} from 'inversify';
import Command from '.';
import AddQueryToQueue from '../services/add-query-to-queue.js';
@ -56,9 +56,9 @@ export default class implements Command {
constructor(@inject(TYPES.Services.AddQueryToQueue) private readonly addQueryToQueue: AddQueryToQueue) {}
requiresVC = (interaction: CommandInteraction) => interaction.options.getSubcommand() === 'use';
requiresVC = (interaction: ChatInputCommandInteraction) => interaction.options.getSubcommand() === 'use';
async execute(interaction: CommandInteraction) {
async execute(interaction: ChatInputCommandInteraction) {
switch (interaction.options.getSubcommand()) {
case 'use':
await this.use(interaction);
@ -100,7 +100,7 @@ export default class implements Command {
})));
}
private async use(interaction: CommandInteraction) {
private async use(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString('name')!.trim();
const favorite = await prisma.favoriteQuery.findFirst({
@ -123,7 +123,7 @@ export default class implements Command {
});
}
private async list(interaction: CommandInteraction) {
private async list(interaction: ChatInputCommandInteraction) {
const favorites = await prisma.favoriteQuery.findMany({
where: {
guildId: interaction.guild!.id,
@ -135,7 +135,7 @@ export default class implements Command {
return;
}
const embed = new MessageEmbed().setTitle('Favorites');
const embed = new EmbedBuilder().setTitle('Favorites');
let description = '';
for (const favorite of favorites) {
@ -149,7 +149,7 @@ export default class implements Command {
});
}
private async create(interaction: CommandInteraction) {
private async create(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString('name')!.trim();
const query = interaction.options.getString('query')!.trim();
@ -174,7 +174,7 @@ export default class implements Command {
await interaction.reply('👍 favorite created');
}
private async remove(interaction: CommandInteraction) {
private async remove(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString('name')!.trim();
const favorite = await prisma.favoriteQuery.findFirst({where: {

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
@ -25,7 +25,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const currentSong = player.getCurrent();

View file

@ -1,11 +1,11 @@
import {SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder} from '@discordjs/builders';
import {AutocompleteInteraction, ButtonInteraction, CommandInteraction} from 'discord.js';
import {AutocompleteInteraction, ButtonInteraction, ChatInputCommandInteraction} from 'discord.js';
export default interface Command {
readonly slashCommand: Partial<SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
readonly handledButtonIds?: readonly string[];
readonly requiresVC?: boolean | ((interaction: CommandInteraction) => boolean);
execute: (interaction: CommandInteraction) => Promise<void>;
readonly requiresVC?: boolean | ((interaction: ChatInputCommandInteraction) => boolean);
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
handleButtonInteraction?: (interaction: ButtonInteraction) => Promise<void>;
handleAutocompleteInteraction?: (interaction: AutocompleteInteraction) => Promise<void>;
}

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
@ -26,7 +26,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const from = interaction.options.getInteger('from') ?? 1;

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
@ -18,7 +18,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
if (!player.getCurrent()) {

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
@ -20,7 +20,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction) {
public async execute(interaction: ChatInputCommandInteraction) {
const player = this.playerManager.get(interaction.guild!.id);
if (player.status !== STATUS.PLAYING) {

View file

@ -1,4 +1,4 @@
import {AutocompleteInteraction, CommandInteraction} from 'discord.js';
import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
import {URL} from 'url';
import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
@ -43,8 +43,7 @@ export default class implements Command {
this.addQueryToQueue = addQueryToQueue;
}
// eslint-disable-next-line complexity
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const query = interaction.options.getString('query')!;
await this.addQueryToQueue.addToQueue({

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
@ -22,7 +22,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction) {
public async execute(interaction: ChatInputCommandInteraction) {
const player = this.playerManager.get(interaction.guild!.id);
const embed = buildQueueEmbed(player, interaction.options.getInteger('page') ?? 1);

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
@ -26,7 +26,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const position = interaction.options.getInteger('position') ?? 1;

View file

@ -6,7 +6,7 @@ import PlayerManager from '../managers/player.js';
import {STATUS} from '../services/player.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
import {CommandInteraction, GuildMember} from 'discord.js';
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
@injectable()
export default class implements Command {
@ -22,8 +22,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
// eslint-disable-next-line complexity
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const [targetVoiceChannel] = getMemberVoiceChannel(interaction.member as GuildMember) ?? getMostPopularVoiceChannel(interaction.guild!);
if (player.status === STATUS.PLAYING) {

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
@ -26,7 +26,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const currentSong = player.getCurrent();

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
@ -19,7 +19,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
if (player.isQueueEmpty()) {

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
@ -24,7 +24,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const numToSkip = interaction.options.getInteger('number') ?? 1;
if (numToSkip < 1) {

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
@ -20,7 +20,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction) {
public async execute(interaction: ChatInputCommandInteraction) {
const player = this.playerManager.get(interaction.guild!.id);
if (!player.voiceConnection) {

View file

@ -1,4 +1,4 @@
import {CommandInteraction} from 'discord.js';
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
@ -20,7 +20,7 @@ export default class implements Command {
this.playerManager = playerManager;
}
public async execute(interaction: CommandInteraction): Promise<void> {
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
try {

View file

@ -1,58 +1,44 @@
import {Guild, Client} from 'discord.js';
import {Client, Guild} from 'discord.js';
import container from '../inversify.config.js';
import Command from '../commands';
import {TYPES} from '../types.js';
import Config from '../services/config.js';
import {prisma} from '../utils/db.js';
import {REST} from '@discordjs/rest';
import {Routes} from 'discord-api-types/v9';
import updatePermissionsForGuild from '../utils/update-permissions-for-guild.js';
import {Setting} from '@prisma/client';
import registerCommandsOnGuild from '../utils/register-commands-on-guild.js';
export default async (guild: Guild): Promise<void> => {
let invitedBy;
try {
const logs = await guild.fetchAuditLogs({type: 'BOT_ADD'});
invitedBy = logs.entries.find(entry => entry.target?.id === guild.client.user?.id)?.executor;
} catch {}
if (!invitedBy) {
console.warn(`Could not find user who invited Muse to ${guild.name} from the audit logs.`);
}
await prisma.setting.upsert({
export async function createGuildSettings(guild: Guild): Promise<Setting> {
return prisma.setting.upsert({
where: {
guildId: guild.id,
},
create: {
guildId: guild.id,
invitedByUserId: invitedBy?.id,
},
update: {
invitedByUserId: invitedBy?.id,
},
update: {},
});
}
export default async (guild: Guild): Promise<void> => {
await createGuildSettings(guild);
const config = container.get<Config>(TYPES.Config);
// Setup slash commands
if (!config.REGISTER_COMMANDS_ON_BOT) {
const token = container.get<Config>(TYPES.Config).DISCORD_TOKEN;
const client = container.get<Client>(TYPES.Client);
const rest = new REST({version: '9'}).setToken(token);
const rest = new REST({version: '10'}).setToken(config.DISCORD_TOKEN);
await rest.put(
Routes.applicationGuildCommands(client.user!.id, guild.id),
{body: container.getAll<Command>(TYPES.Command).map(command => command.slashCommand.toJSON())},
);
await registerCommandsOnGuild({
rest,
applicationId: client.user!.id,
guildId: guild.id,
commands: container.getAll<Command>(TYPES.Command).map(command => command.slashCommand),
});
}
await updatePermissionsForGuild(guild);
if (invitedBy) {
await invitedBy.send('👋 Hi! You just invited me to a server. I can\'t be used by your server members until you complete setup by running /config set-role in your server.');
} else {
const owner = await guild.fetchOwner();
await owner.send('👋 Hi! Someone (probably you) just invited me to a server you own. I can\'t be used by your server members until you complete setup by running /config set-role in your server.');
}
};

View file

@ -1,10 +0,0 @@
import {Guild} from 'discord.js';
import updatePermissionsForGuild from '../utils/update-permissions-for-guild.js';
const handleGuildUpdate = async (oldGuild: Guild, newGuild: Guild) => {
if (oldGuild.ownerId !== newGuild.ownerId) {
await updatePermissionsForGuild(newGuild);
}
};
export default handleGuildUpdate;

View file

@ -2,7 +2,7 @@ import 'reflect-metadata';
import {Container} from 'inversify';
import {TYPES} from './types.js';
import Bot from './bot.js';
import {Client, Intents} from 'discord.js';
import {Client, GatewayIntentBits} from 'discord.js';
import ConfigProvider from './services/config.js';
// Managers
@ -41,12 +41,10 @@ import KeyValueCacheProvider from './services/key-value-cache.js';
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)
const intents: GatewayIntentBits[] = [];
intents.push(GatewayIntentBits.Guilds); // To listen for guildCreate event
intents.push(GatewayIntentBits.GuildMessageReactions); // To listen for message reactions (messageReactionAdd event)
intents.push(GatewayIntentBits.GuildVoiceStates); // To listen for voice state changes (voiceStateUpdate event)
// Bot
container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope();

View file

@ -1,5 +1,5 @@
/* eslint-disable complexity */
import {CommandInteraction, GuildMember} from 'discord.js';
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
import {inject, injectable} from 'inversify';
import shuffle from 'array-shuffle';
import {TYPES} from '../types.js';
@ -12,7 +12,8 @@ import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channe
@injectable()
export default class AddQueryToQueue {
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs, @inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager) {}
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs, @inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager) {
}
public async addToQueue({
query,
@ -25,7 +26,7 @@ export default class AddQueryToQueue {
addToFrontOfQueue: boolean;
shuffleAdditions: boolean;
shouldSplitChapters: boolean;
interaction: CommandInteraction;
interaction: ChatInputCommandInteraction;
}): Promise<void> {
const guildId = interaction.guild!.id;
const player = this.playerManager.get(guildId);
@ -121,7 +122,11 @@ export default class AddQueryToQueue {
}
newSongs.forEach(song => {
player.add({...song, addedInChannelId: interaction.channel!.id, requestedBy: interaction.member!.user.id}, {immediate: addToFrontOfQueue ?? false});
player.add({
...song,
addedInChannelId: interaction.channel!.id,
requestedBy: interaction.member!.user.id,
}, {immediate: addToFrontOfQueue ?? false});
});
const firstSong = newSongs[0];

View file

@ -18,7 +18,7 @@ const CONFIG_MAP = {
CACHE_DIR: path.join(DATA_DIR, 'cache'),
CACHE_LIMIT_IN_BYTES: xbytes.parseSize(process.env.CACHE_LIMIT ?? '2GB'),
BOT_STATUS: process.env.BOT_STATUS ?? 'online',
BOT_ACTIVITY_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'LISTENING',
BOT_ACTIVITY_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'Listening',
BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '',
BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music',
} as const;

View file

@ -10,7 +10,7 @@ import {
AudioPlayerState,
AudioPlayerStatus,
createAudioPlayer,
createAudioResource,
createAudioResource, DiscordGatewayAdapterCreator,
joinVoiceChannel,
StreamType,
VoiceConnection,
@ -82,7 +82,7 @@ export default class {
this.voiceConnection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator,
});
}

View file

@ -47,7 +47,7 @@ export default class {
items.push(...tracksResponse.items.map(playlistItem => playlistItem.track));
}
const tracks = this.limitTracks(items, playlistLimit).map(this.toSpotifyTrack);
const tracks = this.limitTracks(items.filter(i => i !== null) as SpotifyApi.TrackObjectSimplified[], playlistLimit).map(this.toSpotifyTrack);
return [tracks, playlist];
}

View file

@ -8,7 +8,6 @@ export const TYPES = {
ThirdParty: Symbol('ThirdParty'),
Managers: {
Player: Symbol('PlayerManager'),
UpdatingQueueEmbed: Symbol('UpdatingQueueEmbed'),
},
Services: {
AddQueryToQueue: Symbol('AddQueryToQueue'),

View file

@ -1,5 +1,5 @@
import getYouTubeID from 'get-youtube-id';
import {MessageEmbed} from 'discord.js';
import {EmbedBuilder} from 'discord.js';
import Player, {MediaSource, QueuedSong, STATUS} from '../services/player.js';
import getProgressBar from './get-progress-bar.js';
import {prettyTime} from './time.js';
@ -50,7 +50,7 @@ const getPlayerUI = (player: Player) => {
return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉`;
};
export const buildPlayingMessageEmbed = (player: Player): MessageEmbed => {
export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
const currentlyPlaying = player.getCurrent();
if (!currentlyPlaying) {
@ -58,10 +58,9 @@ export const buildPlayingMessageEmbed = (player: Player): MessageEmbed => {
}
const {artist, thumbnailUrl, requestedBy} = currentlyPlaying;
const message = new MessageEmbed();
const message = new EmbedBuilder();
message
.setColor(player.status === STATUS.PLAYING ? 'DARK_GREEN' : 'DARK_RED')
.setColor(player.status === STATUS.PLAYING ? 'DarkGreen' : 'DarkRed')
.setTitle(player.status === STATUS.PLAYING ? 'Now Playing' : 'Paused')
.setDescription(`
**${getSongTitle(currentlyPlaying)}**
@ -77,7 +76,7 @@ export const buildPlayingMessageEmbed = (player: Player): MessageEmbed => {
return message;
};
export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => {
export const buildQueueEmbed = (player: Player, page: number): EmbedBuilder => {
const currentlyPlaying = player.getCurrent();
if (!currentlyPlaying) {
@ -108,7 +107,7 @@ export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => {
const playlistTitle = playlist ? `(${playlist.title})` : '';
const totalLength = player.getQueue().reduce((accumulator, current) => accumulator + current.length, 0);
const message = new MessageEmbed();
const message = new EmbedBuilder();
let description = `**${getSongTitle(currentlyPlaying)}**\n`;
description += `Requested by: <@${requestedBy}>\n\n`;
@ -121,11 +120,11 @@ export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => {
message
.setTitle(player.status === STATUS.PLAYING ? 'Now Playing' : 'Queued songs')
.setColor(player.status === STATUS.PLAYING ? 'DARK_GREEN' : 'NOT_QUITE_BLACK')
.setColor(player.status === STATUS.PLAYING ? 'DarkGreen' : 'NotQuiteBlack')
.setDescription(description)
.addField('In queue', getQueueInfo(player), true)
.addField('Total length', `${totalLength > 0 ? prettyTime(totalLength) : '-'}`, true)
.addField('Page', `${page} out of ${maxQueuePage}`, true)
.addFields([{name: 'In queue', value: getQueueInfo(player), inline: true}, {
name: 'Total length', value: `${totalLength > 0 ? prettyTime(totalLength) : '-'}`, inline: true,
}, {name: 'Page', value: `${page} out of ${maxQueuePage}`, inline: true}])
.setFooter({text: `Source: ${artist} ${playlistTitle}`});
if (thumbnailUrl) {
@ -134,3 +133,4 @@ export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => {
return message;
};

View file

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

View file

@ -1,4 +1,4 @@
import {ApplicationCommandOptionChoice} from 'discord.js';
import {APIApplicationCommandOptionChoice} from 'discord-api-types/v10';
import SpotifyWebApi from 'spotify-web-api-node';
import getYouTubeSuggestionsFor from './get-youtube-suggestions-for.js';
@ -14,7 +14,7 @@ const filterDuplicates = <T extends {name: string}>(items: T[]) => {
return results;
};
const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: SpotifyWebApi, limit = 10): Promise<ApplicationCommandOptionChoice[]> => {
const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: SpotifyWebApi, limit = 10): Promise<APIApplicationCommandOptionChoice[]> => {
const [youtubeSuggestions, spotifyResults] = await Promise.all([
getYouTubeSuggestionsFor(query),
spotify.search(query, ['track', 'album'], {limit: 5}),
@ -35,7 +35,7 @@ const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: Spotif
const maxYouTubeSuggestions = limit - numOfSpotifySuggestions;
const numOfYouTubeSuggestions = Math.min(maxYouTubeSuggestions, totalYouTubeResults);
const suggestions: ApplicationCommandOptionChoice[] = [];
const suggestions: APIApplicationCommandOptionChoice[] = [];
suggestions.push(
...youtubeSuggestions

View file

@ -0,0 +1,19 @@
import {REST} from '@discordjs/rest';
import {Routes} from 'discord-api-types/v10';
import Command from '../commands';
interface RegisterCommandsOnGuildOptions {
rest: REST;
applicationId: string;
guildId: string;
commands: Array<Command['slashCommand']>;
}
const registerCommandsOnGuild = async ({rest, applicationId, guildId, commands}: RegisterCommandsOnGuildOptions) => {
await rest.put(
Routes.applicationGuildCommands(applicationId, guildId),
{body: commands.map(command => command.toJSON())},
);
};
export default registerCommandsOnGuild;

View file

@ -1,53 +0,0 @@
import {ApplicationCommandPermissionData, Guild} from 'discord.js';
import {prisma} from './db.js';
const COMMANDS_TO_LIMIT_TO_GUILD_OWNER = ['config'];
const updatePermissionsForGuild = async (guild: Guild) => {
const settings = await prisma.setting.findUnique({
where: {
guildId: guild.id,
},
});
if (!settings) {
throw new Error('could not find settings for guild');
}
const permissions: ApplicationCommandPermissionData[] = [
{
id: guild.ownerId,
type: 'USER',
permission: true,
},
{
id: guild.roles.everyone.id,
type: 'ROLE',
permission: false,
},
];
if (settings.invitedByUserId) {
permissions.push({
id: settings.invitedByUserId,
type: 'USER',
permission: true,
});
}
const commands = await guild.commands.fetch();
await guild.commands.permissions.set({fullPermissions: commands.map(command => ({
id: command.id,
permissions: COMMANDS_TO_LIMIT_TO_GUILD_OWNER.includes(command.name) ? permissions : [
...permissions,
...(settings.roleId ? [{
id: settings.roleId,
type: 'ROLE' as const,
permission: true,
}] : []),
],
}))});
};
export default updatePermissionsForGuild;

View file

@ -1,10 +1,9 @@
{
"compilerOptions": {
"lib": ["ES2019", "DOM"],
"lib": ["esnext"],
"target": "es2018",
"module": "ES2020",
"moduleResolution": "node",
"declaration": true,
"strict": true,
"experimentalDecorators": true,
"esModuleInterop": true,

1451
yarn.lock

File diff suppressed because it is too large Load diff