class Rules(commands.Cog): """Extension for enforcing guild rules This cog enforces a long running meme on our Discord server that all messages have to start with '3pseat'. This cog enforces a more general version of that concept and tracks the number of infractions a user has. If they get too many, they will be kicked. Adds the following commands: - `?strikes`: aliases `?strikes list` - `?strikes list`: list strike count for members - `?strikes add @member`: add strike to member - `?strikes remove @member`: remove strike from member """ def __init__(self, bot: Bot, database_path: str, message_prefix: Optional[Union[str, List[str]]] = None, whitelist_prefix: Union[str, List[str]] = [], max_offenses: int = 3, allow_deletes: bool = True, allow_edits: bool = True, allow_wrong_commands: bool = True, booster_exception: bool = True, invite_after_kick: bool = True) -> None: """Init Rules Args: bot (Bot): bot object this cog is attached to database_path (str): path to file storing database of strikes message_prefix (str, list[str]): valid prefixes for messages. If None, no message prefix checking is done. whitelist_prefix (str, list[str]): messages starting with these prefixes will be ignored. Useful for ignoring the command prefixes of other bots. max_offenses (int): maximum strikes before being kicked allow_deletes (bool): if false, the bot will notify the channel if a message is deleted allow_edits (bool): if false, the bot will notify the channel if a message is edited allow_wrong_commands (bool): if false, members will be given a strike for trying to use invalid commands booster_exception (bool): boosters are exempt from rules invite_after_kick (bool): send invite link if member is kicked """ self.bot = bot if isinstance(message_prefix, str): message_prefix = [message_prefix] self.message_prefix = message_prefix if isinstance(whitelist_prefix, str): message_prefix = [whitelist_prefix] self.whitelist_prefix = whitelist_prefix self.max_offenses = max_offenses self.allow_deletes = allow_deletes self.allow_edits = allow_edits self.allow_wrong_commands = allow_wrong_commands self.booster_exception = booster_exception self.invite_after_kick = invite_after_kick if self.message_prefix is not None: self.bot.guild_message_prefix = self.message_prefix[0] self.db = GuildDatabase(database_path) async def list(self, ctx: commands.Context) -> None: """List strikes for members in `ctx.guild` Args: ctx (Context): context from command call """ msg = '{}, here are the strikes:'.format(ctx.message.author.mention) serverCount = 0 strikes = self.db.table(ctx.guild) if len(strikes) > 0: msg += '```' for uid in strikes: if int(uid) != ctx.guild.id: msg = msg + '\n{}: {}/{}'.format( self.bot.get_user(int(uid)).name, strikes[uid], self.max_offenses) else: serverCount = strikes[uid] msg += '```' else: msg += ' there are none!\n' msg += 'Total offenses to date: {}'.format(serverCount) await self.bot.message_guild(msg, ctx.channel) async def add(self, ctx: commands.Context, member: discord.Member) -> None: """Adds a strike to `member` Requires `ctx.message.author` to be a bot admin (not guild admin). Args: ctx (Context): context from command call member (Member): member to add strike to """ if self.bot.is_bot_admin(ctx.message.author): await self._add_strike(member, ctx.channel, ctx.guild) else: await self.bot.message_guild( 'you lack permission, {}'.format(ctx.message.author.mention), ctx.channel) async def remove(self, ctx: commands.Context, member: discord.Member) -> None: """Removes a strike from `member` Requires `ctx.message.author` to be a bot admin (not guild admin). Args: ctx (Context): context from command call member (Member): member to remove strike from """ if self.bot.is_bot_admin(ctx.message.author): self.remove_strike(member) msg = 'removed strike for {}. New strike count is {}.'.format( member.mention, self.get_strikes(member)) await self.bot.message_guild(msg, ctx.message.channel) else: await self.bot.message_guild( 'you lack permission, {}'.format(ctx.message.author.mention), ctx.channel) def should_ignore(self, message: discord.Message) -> bool: """Returns true if the message should be ignored Many types of messages are exempt from the message prefix rules including: pin messages, member join messages, guild boosters if `booster_exception`, messages that start with `whitelist_prefix`, and bot commands. Args: message (Message): message to parse Returns: `bool` """ if message.type is discord.MessageType.pins_add: return True if message.type is discord.MessageType.new_member: return True if self.booster_exception and is_booster(message.author): return True text = message.content.strip().lower() for keyword in self.whitelist_prefix: if text.startswith(keyword.lower()): return True if text.startswith(self.bot.command_prefix): return True return False def is_verified(self, message: discord.Message) -> bool: """Verifies a message passes the rules A message is verified if it: start with `message_prefix`, is just emojis, is a single attachment with no text, is a single url with no text, is quoted, or is code. Args: message (Message): message to parse Returns: `bool` """ text = message.content.strip().lower() # Check if starts with 3pseat, 3pfeet, etc if self.message_prefix is not None: for keyword in self.message_prefix: if text.startswith(keyword): return True # Check if just emoji if is_emoji(text): return True # Check if just images if text == '' and message.attachments: return True # Check if single link if is_url(text): return True # Check if quoted message if text.startswith('>'): return True # Check if code if text.startswith('```'): return True return False async def check_strikes(self, member: discord.Member, channel: discord.TextChannel, guild: discord.Guild) -> None: """Handles what to do when user recieves a strike If the user has fewer that `self.max_offenses` strikes, a warning is sent. Otherwise, the bot attempts to kick the user and send them a invite link if `self.invite_after_kick`. Warning: In general, this function should only be called by `add_strike()`. Args: member (Member): member to check strikes of channel (Channel): channel to send message in guild (Guild): guild to kick user from if needed """ count = self.get_strikes(member) if count < self.max_offenses: msg = '{}! You\'ve disturbed the spirits ({}/{})'.format( member.mention, count, self.max_offenses) await self.bot.message_guild(msg, channel) else: self.clear_strikes(member) msg = ('That\'s {} strikes, {}. I\'m sorry but your time as come. ' 'RIP.\n'.format(self.max_offenses, member.mention)) success = await self.kick(member, channel, guild, msg) if self.invite_after_kick and success: msg = ('Sorry we had to kick you. Here is a link to rejoin: ' '{link}') await self.invite(member, channel, msg) async def invite(self, user: Union[discord.User, discord.Member], channel: discord.TextChannel, message: str) -> None: """Send guild invite link to user Args: user (Member, User): user to direct message link channel (Channel): channel to create invite for message (str): optional message to include with invite link """ try: link = await channel.create_invite(max_uses=1) msg = message.format(link=link) await self.bot.message_user(msg, user) except Exception as e: logger.warning('Failed to send rejoin message to {}.' 'Caught exception: {}'.format(user, e)) async def kick(self, member: discord.Member, channel: discord.TextChannel, guild: discord.Guild, message: str = None) -> bool: """Kick member from guild Args: member (Member): member to kick channel (Channel): channel to send notification messages to guild (Guild): guild member belongs to to be kicked from message (str): optional message to sent to channel when the member is kicked Returns: `True` if kick was successful """ if is_admin(member): if message is None: message = '' message += ('Failed to kick {}. Your cognizance is highly ' 'acknowledged.'.format(member.mention)) await self.bot.message_guild(message, channel) return False try: await guild.kick(member) except Exception as e: logger.warning('Failed to kick {}. Caught exception: ' '{}'.format(member, e)) return False if message is not None: message += 'Press F to pay respects.' await self.bot.message_guild(message, channel, react=F_EMOTE) return True async def add_strike(self, member: discord.Member, channel: discord.TextChannel) -> None: """Add a strike to the `member` of `guild` in the database Calls `check_strikes()` Args: member (Member): member channel (Channel): channel to send message in """ count = self.get_strikes(member) self.db.set(member.guild, str(member.id), count + 1) server_count = self.get_global_strikes(member.guild) self.db.set(member.guild, str(member.guild.id), server_count + 1) await self.check_strikes(member, channel, member.guild) def clear_strikes(self, member: discord.Member) -> None: """Reset strikes for the `member` in the database""" self.db.set(member.guild, str(member.id), 0) def get_strikes(self, member: discord.Member) -> int: """Get strike count for `member`""" value = self.db.value(member.guild, str(member.id)) if value is None: return 0 return value def get_global_strikes(self, guild: discord.Guild) -> int: """Get global strike count for guild""" value = self.db.value(guild, str(guild.id)) if value is None: return 0 return value def remove_strike(self, member: discord.Member) -> None: """Remove a strike from the `member` in the database""" count = self.get_strikes(member) if count > 0: self.db.set(member.guild, str(member.id), count - 1) server_count = self.get_global_strikes(member.guild) if server_count > 0: self.db.set(member.guild, str(member.guild.id), server_count + 1) @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Called when message is created and sent""" if message.author.bot: return if message.guild is None: await self.bot.message_user( 'Hi there, I cannot reply to direct messages.', message.author) return if self.should_ignore(message): return if not self.is_verified(message): await self.add_strike(message.author, message.channel) @commands.Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """Called when message is deleted""" if self.allow_deletes or message.author.bot or self.should_ignore( message): return msg = '{}, where did your message go? It was: \"{}\"'.format( message.author.mention, message.clean_content) await self.bot.message_guild(msg, message.channel) @commands.Cog.listener() async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None: """Called when message is edited""" if self.allow_edits or before.author.bot or self.should_ignore(after): return # ignore message with embeds because it counts as editing a message if after.embeds: return # ignore message being edited because it was pinned if not before.pinned and after.pinned: return msg = '{}, what did you do to your message? It was: \"{}\"'.format( before.author.mention, before.clean_content) await self.bot.message_guild(msg, before.channel) # confirm new message still passes rules if not self.is_verified(after): await self.add_strike(after.author, after.channel) @commands.Cog.listener() async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: """Called when a command is invalid""" if (isinstance(error, commands.CommandNotFound) and not self.allow_wrong_commands): for prefix in self.whitelist_prefix: if ctx.message.content.startswith(prefix): return await self.add_strike(ctx.message.author, ctx.channel) @commands.group(name='strikes', pass_context=True, brief='?help strikes for more info') async def _strikes(self, ctx: commands.Context) -> None: if ctx.invoked_subcommand is None: await self.list(ctx) @_strikes.command(name='add', pass_context=True, brief='add strike to user', ignore_extra=False) async def _add(self, ctx: commands.Context, member: discord.Member) -> None: await self.add(ctx, member) @_strikes.command(name='list', pass_context=True, brief='add strike to user', ignore_extra=False) async def _list(self, ctx: commands.Context) -> None: await self.list(ctx) @_strikes.command(name='remove', pass_context=True, brief='remove strike from user', ignore_extra=False) async def _remove(self, ctx: commands.Context, member: discord.Member) -> None: await self.remove(ctx, member)
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))