class Commands(commands.Cog): """Extension for custom commands. Adds the following commands: - `?command add [name] [command text]` - `?command remove [name]` """ def __init__( self, bot: Bot, commands_file: str, guild_admin_permission: bool = True, bot_admin_permission: bool = True, everyone_permission: bool = False, ) -> None: """Init Commands Args: bot (Bot): bot that loaded this cog commands_file (str): path to store database guild_admin_permission (bool): can guild admins start polls bot_admin_permission (bool): can bot admin start polls everyone_permission (bool): allow everyone to start polls """ self.bot = bot self.guild_admin_permission = guild_admin_permission self.bot_admin_permission = bot_admin_permission self.everyone_permission = everyone_permission self.db = GuildDatabase(commands_file) # Register all commands on startup tables = self.db.tables() for table in tables.values(): for name, text in table.items(): self.bot.add_command(self.make_command(name, text)) def has_permission(self, member: discord.Member) -> bool: """Does a member have permission to edit commands""" if self.everyone_permission: return True if is_admin(member) or self.bot.is_bot_admin(member): return True return False def is_custom_command(self, cmd: commands.Command) -> bool: """Check if command was created by this cog""" if cmd.cog_name == 'custom_commands': return True if 'cog_name' in cmd.__original_kwargs__: return cmd.__original_kwargs__['cog_name'] == 'custom_commands' return False def make_command(self, name: str, text: str) -> commands.Command: """Create Discord Command Args: name (str): name of command text (str): message to return when command is executed Returns: Command """ async def _command(_ctx: commands.Context): _text = self._get_command(_ctx.guild, name) if _text is None: await self.bot.message_guild( 'this command is not available in this guild', _ctx.channel) else: await self.bot.message_guild(_text, _ctx.channel) return commands.Command(_command, name=name, cog_name='custom_commands') async def add(self, ctx: commands.Context, name: str, text: str) -> None: """Add a new command to the guild Sends a message to `ctx.channel` on success or failure. Args: ctx (Context): context from command call name (str): name of command text (str): text of command """ if not self.has_permission(ctx.message.author): raise commands.MissingPermissions # Check if this is a custom command that can be overwritten if # it exists cmd = self.bot.get_command(name) if cmd is not None: if self.is_custom_command(cmd): self.bot.remove_command(name) else: await self.bot.message_guild( 'this command already exist and cannot be overwritten', ctx.channel, ) return # Save to database self._set_command(ctx.guild, name, text) # Register with bot self.bot.add_command(self.make_command(name, text)) await self.bot.message_guild( 'added command {}{}'.format(self.bot.command_prefix, name), ctx.channel, ) async def remove(self, ctx: commands.Context, name: str) -> None: """Remove a command from the guild Sends a message to `ctx.channel` on success or failure. Args: ctx (Context): context from command call name (str): name of command to remove """ if not self.has_permission(ctx.message.author): raise commands.MissingPermissions cmd = self.bot.get_command(name) if cmd is not None: # Only remove if it is a custom command if self.is_custom_command(cmd): # Remove from bot self.bot.remove_command(name) # Remove from database self._remove_command(ctx.guild, name) msg = 'removed command {}{}'.format(self.bot.command_prefix, name) await self.bot.message_guild(msg, ctx.channel) else: await self.bot.message_guild('this command cannot be removed', ctx.channel) return else: await self.bot.message_guild( 'the {} command does not exists'.format(name), ctx.channel) def _get_command(self, guild: discord.Guild, name: str) -> Optional[str]: """Get command text from database""" return self.db.value(guild, name) def _remove_command(self, guild: discord.Guild, name: str) -> None: """Remove command text from database""" return self.db.clear(guild, name) def _set_command(self, guild: discord.Guild, name: str, text: str) -> None: """Set command in dataset""" self.db.set(guild, name, text) @commands.group( name='commands', pass_context=True, brief='add/remove custom commands', description='Add and remove custom commands for this guild.', ) async def _commands(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self.bot.message_guild( 'use the `add` or `remove` subcommands. See `{}help {}` for ' 'more info'.format(self.bot.command_prefix, ctx.invoked_with), ctx.channel, ) @_commands.command( name='add', pass_context=True, brief='add a custom command', description='Add a custom command that can be invoked with <name>. ' 'The command will print all <text> after <name>. ' 'Note that quotes are not needed around the command body ' 'text.', ) async def _add(self, ctx: commands.Context, name: str, *, text: str) -> None: await self.add(ctx, name, text) @_commands.command( name='remove', pass_context=True, brief='remove a custom command', ignore_extra=False, description='Remove the custom command with <name>', ) async def _remove(self, ctx: commands.Context, name: str) -> None: await self.remove(ctx, name)
class Voice(commands.Cog): """Extension for playing sound clips in voice channels Based on https://github.com/Rapptz/discord.py/blob/master/examples/basic_voice.py Adds the following commands: - `?join`: have bot join voice channel of user - `?leave`: have bot leave voice channel of user - `?sounds`: aliases `?sounds list` - `?sounds play [name]`: play sound with name - `?sounds roll`: play a random sound - `?sounds list`: list available sounds - `?sounds add [name] [youtube_url]`: download youtube audio and saves a sound with name - `?sounds update [name] [key] [value]`: update data about a sound - `?sounds remove [name]`: remove a sound """ def __init__( self, bot: Bot, sounds_file: str, sounds_dir: str, ) -> None: """Init Voice Args: bot (Bot): bot that loaded this cog sounds_file (str): path to store sounds database sounds_dir (str): directory to store audio files in """ self.bot = bot # Note: sounds stored as ./<sounds_dir>/<guild_id>/<sound_name>.mp3 self.sounds_dir = sounds_dir self.db = GuildDatabase(sounds_file) self.rebuild_database() self._leave_on_empty.start() def rebuild_database(self): """Scan and clean up database Checks for sounds that are missing and downloads if possible, deletes otherwise. Enforces schema. Each entry keyed by `sound` has the following attributes: `url`, `image_url`, `path`, `tag`. """ logger.info('[VOICE] Scanning sounds database...') for guild_id, table in self.db.tables().items(): guild = self.bot.get_guild(guild_id) if guild is None: logger.info(f'[VOICE] Failed to find guild {guild_id}') continue for sound in list(table.keys()): data = table[sound] # Check additional metadata exists if 'image_url' not in data: data['image_url'] = None if 'url' not in data: data['url'] = None if 'tag' not in data: data['tag'] = None # If file does not exist, try to download, otherwise delete if not os.path.isfile(data['path']): if 'url' in data and data['url'] is not None: self.add( guild, sound, data['url'], image_url=data['image_url'], tag=data['tag'], ) logger.info( f'[VOICE] Restored missing {sound} in {guild.name}' ) else: self.db.clear(guild, sound) logger.info( f'[VOICE] Removed invalid {sound} from {guild.name}' ) self.db.set(guild, sound, data) logger.info('[VOICE] Finished scanning sounds database') async def join_channel(self, channel: discord.VoiceChannel) -> None: """Join voice channel Args: channel (VoiceChannel): voice channel to leave Raises: JoinVoiceChannelException """ try: if channel.guild.voice_client is not None: await channel.guild.voice_client.move_to(channel) return await channel.connect() except Exception: logger.exception( f'Caught exception when trying to join voice channel ' f'{channel}. Raising JoinVoiceChannelException instead.') raise JoinVoiceChannelException async def leave_channel(self, guild: discord.Guild) -> None: """Leave voice channel for guild Args: guild (Guild): guild to leave voice channel in """ if guild.voice_client is not None: await guild.voice_client.disconnect() async def play(self, channel: discord.VoiceChannel, sound: str) -> None: """Play the sound for `ctx.message.author` Args: channel (VoiceChannel): channel to play sound in sound (str): name of sound to play Raises: JoinVoiceChannelException: if unable to join channel SoundNotFoundException: if unable to find sound """ await self.join_channel(channel) voice_client = channel.guild.voice_client if voice_client.is_playing(): voice_client.stop() sound_data = self.db.value(channel.guild, sound) if sound_data is None: raise SoundNotFoundException(f'Unable to find sound {sound}') if 'path' not in sound_data or not os.path.isfile(sound_data['path']): # If entry is missing valid path, remove from database self.db.clear(channel.guild, sound) raise SoundNotFoundException(f'Unable to find sound {sound}') source = discord.FFmpegPCMAudio(sound_data['path']) voice_client.play(source, after=None) voice_client.source = discord.PCMVolumeTransformer( voice_client.source, 1) def sounds(self, guild: discord.Guild) -> dict: """Returns all sounds and metadata Args: guild (Guild) """ return self.db.table(guild) def add( self, guild: discord.Guild, name: str, url: str, *, image_url: Optional[str] = None, tag: Optional[str] = None, ) -> None: """Add a new sound from YouTube Args: guild (Guild): guild to add sound to name (str): name for the sound url (str): youtube url to download image_url (str): optional url of image for soundboard tag (str): optional tag for sound Raises: SoundExistsException if sound with `name` already exists SoundExceedsLimitException if sound is too long SoundDownloadException if there is an error downloading the sound """ sounds = self.sounds(guild) if name in sounds and os.path.isfile(sounds[name]['path']): raise SoundExistsException path = os.path.join(self.sounds_dir, str(guild.id)) os.makedirs(path, exist_ok=True) path = os.path.join(path, f'{name}.mp3') ydl_opts = { 'outtmpl': path, 'format': 'worst', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128', }], 'logger': logger, 'socket_timeout': 30, } try: with youtube_dl.YoutubeDL(ydl_opts) as ydl: metadata = ydl.extract_info(url, download=False, process=False) if int(metadata['duration']) > MAX_SOUND_LENGTH_SECONDS: raise SoundExceedsLimitException ydl.download([url]) except Exception: logger.exception('Caught error downloading sound') raise SoundDownloadException self.db.set( guild, name, { 'path': path, 'url': url, 'image_url': image_url, 'tag': tag }, ) def update( self, guild: discord.Guild, name: str, *, url: Optional[str] = None, image_url: Optional[str] = None, tag: Optional[str] = None, ) -> None: """Update and existing sound Args: guild (Guild): guild sound is in name (str): name of the sound url (str): update with new youtube url image_url (str): update with new image url tag (str): update with new tag Raises: SoundNotFoundException: if the sound cannot be found """ sound_data = self.db.value(guild, name) if sound_data is None: raise SoundNotFoundException(f'Unable to find sound {name}') if image_url is not None: sound_data['image_url'] = image_url if tag is not None: sound_data['tag'] = tag # URL has changed so download new file if url != sound_data['url']: self.db.clear(guild, name) try: self.add( guild, name, sound_data['url'], image_url=sound_data['image_url'], tag=sound_data['tag'], ) except Exception as e: # Restore original state if there is an error self.db.set(guild, name, sound_data) raise e def remove(self, guild: discord.Guild, member: discord.Member, name: str) -> None: """Remove a sound Requires bot admin permissions. Args: guild (Guild): guild sound is in member (Member): member issuing command name (str): name of the sound Raises: SoundNotFoundException: if the sound cannot be found MissingPermissions: if calling user lacks permissions """ if not (is_admin(member) or self.bot.is_bot_admin(member)): raise commands.MissingPermissions if name not in self.sounds(guild): raise SoundNotFoundException(f'Unable to find sound {name}') self.db.clear(guild, name) @tasks.loop(seconds=30.0) async def _leave_on_empty(self) -> None: """Task that periodically checks if connected channels are empty and leaves""" for client in self.bot.voice_clients: if len(client.channel.members) <= 1: await client.disconnect() @commands.command( name='join', pass_context=True, brief='join voice channel', ignore_extra=False, ) async def _join(self, ctx: commands.Context) -> bool: if ctx.author.voice is None or ctx.author.voice.channel is None: await self.bot.message_guild( '{}, you must be in a voice channel'.format( ctx.author.mention), ctx.channel, ) else: await self.join_channel(ctx.author.voice.channel) @commands.command( name='leave', pass_context=True, brief='leave voice channel', ignore_extra=False, ) async def _leave(self, ctx: commands.Context) -> None: await self.leave_channel(ctx.guild) @commands.group(name='sounds', pass_context=True, brief='?help sounds for more info') async def _sounds(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self._list(ctx) @_sounds.command( name='add', pass_context=True, brief='add a sound: <name> <url> [image_url] [tag]', ignore_extra=False, ) async def _add( self, ctx: commands.Context, name: str, url: str, image_url: Optional[str] = None, tag: Optional[str] = None, ) -> None: try: self.add(ctx.guild, name, url, image_url=image_url, tag=tag) except SoundExistsException: await self.bot.message_guild( f'sound with name `{name}` already exists', ctx.channel) except SoundExceedsLimitException: await self.bot.message_guild( f'{ctx.author.mention}, the clip is too long ' f'(max={MAX_SOUND_LENGTH_SECONDS}s)', ctx.channel, ) except SoundDownloadException: await self.bot.message_guild( f'error downloading video for `{name}`', ctx.channel) else: await self.bot.message_guild(f'added sound `{name}`', ctx.channel) @_sounds.command( name='update', pass_context=True, brief='update a sound: <name> <key> <value>', ignore_extra=False, ) async def _update(self, ctx: commands.Context, name: str, key: str, value: str) -> None: sounds = self.sounds(ctx.guild) if name not in sounds: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) return if key not in sounds[name]: await self.bot.message_guild( f'`{key}` is not a valid option. Options are: ' f'`{list(sounds[name].keys())}`', ctx.channel, ) return kwargs = {key: value} try: self.update(ctx.guild, name, **kwargs) except SoundNotFoundException: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) except SoundExceedsLimitException: await self.bot.message_guild( f'{ctx.author.mention}, the new clip is too long ' f'(max={MAX_SOUND_LENGTH_SECONDS}s)', ctx.channel, ) except SoundDownloadException: await self.bot.message_guild( f'error downloading video for `{name}`', ctx.channel) else: await self.bot.message_guild(f'update sound `{name}`', ctx.channel) @_sounds.command( name='get', pass_context=True, brief='get sound data: <name>', ignore_extra=False, ) async def _get(self, ctx: commands.Context, name: str) -> None: sounds = self.sounds(ctx.guild) if name in sounds: await self.bot.message_guild(f'`{name}`: `{sounds[name]}`', ctx.channel) else: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) @_sounds.command( name='remove', pass_context=True, brief='remove a sound: <name>', ignore_extra=False, ) async def _remove(self, ctx: commands.Context, name: str) -> None: try: self.remove(ctx.guild, ctx.author, name) except commands.MissingPermissions: await self.bot.message_guild( f'{ctx.author.mention}, you lack permissions', ctx.channel) except SoundNotFoundException: await self.bot.message_guild( f'sound with name `{name}` does not exists', ctx.channel) else: await self.bot.message_guild(f'removed sound `{name}`', ctx.channel) async def _list(self, ctx: commands.Context) -> None: sounds = self.sounds(ctx.guild) sounds = ' '.join(sorted(sounds.keys())) await self.bot.message_guild(f'the available sounds are: `{sounds}`', ctx.channel) @_sounds.command( name='play', pass_context=True, brief='play a sound: [name]', ignore_extra=False, ) async def _play(self, ctx: commands.Context, sound: str) -> None: if ctx.author.voice is None or ctx.author.voice.channel is None: await self.bot.message_guild( '{}, you must be in a voice channel'.format( ctx.author.mention), ctx.channel, ) try: await self.play(ctx.author.voice.channel, sound) except JoinVoiceChannelException: await self.bot.message_guild('failed to join the voice channel', ctx.channel) except SoundNotFoundException: await self.bot.message_guild(f'unable to find sound `{sound}`', ctx.channel) except Exception: logger.exception( f'Failed to play sound {sound} in guild {ctx.guild.id}') @_sounds.command( name='roll', pass_context=True, brief='play a random sound', ignore_extra=False, ) async def _roll(self, ctx: commands.Context) -> None: sounds = list(self.sounds(ctx.guild).keys()) if len(sounds) < 1: await self.bot.message_guild('there are no sounds available', ctx.channel) else: await self._play(ctx, random.choice(sounds))