async def _unmuting( self, ctx, member: discord.Member, *, reason='-No reason specified-' ): # TODO: Allow IDs to be unmuted (in the case of not being in the guild) if len(reason) > 990: return await ctx.send( f'{config.redTick} Unmute reason is too long, reduce it by at least {len(reason) - 990} characters' ) db = mclient.bowser.puns muteRole = ctx.guild.get_role(config.mute) action = db.find_one_and_update( { 'user': member.id, 'type': 'mute', 'active': True }, {'$set': { 'active': False }}) if not action: return await ctx.send( f'{config.redTick} Cannot unmute {member} ({member.id}), they are not currently muted' ) docID = await tools.issue_pun(member.id, ctx.author.id, 'unmute', reason, context=action['_id'], active=False) await member.remove_roles( muteRole, reason='Unmute action performed by moderator') await tools.send_modlog(self.bot, self.modLogs, 'unmute', docID, reason, user=member, moderator=ctx.author, public=True) try: await member.send(tools.format_pundm('unmute', reason, ctx.author)) except (discord.Forbidden, AttributeError): if not tools.mod_cmd_invoke_delete(ctx.channel): await ctx.send( f'{config.greenTick} {member} ({member.id}) has been successfully unmuted. I was not able to DM them about this action' ) return if tools.mod_cmd_invoke_delete(ctx.channel): return await ctx.message.delete() await ctx.send( f'{config.greenTick} {member} ({member.id}) has been successfully unmuted' )
async def _kicking(self, ctx, member: discord.Member, *, reason='-No reason specified-'): if len(reason) > 990: return await ctx.send( f'{config.redTick} Kick reason is too long, reduce it by at least {len(reason) - 990} characters' ) docID = await tools.issue_pun(member.id, ctx.author.id, 'kick', reason, active=False) await tools.send_modlog(self.bot, self.modLogs, 'kick', docID, reason, user=member, moderator=ctx.author, public=True) try: await member.send(tools.format_pundm('kick', reason, ctx.author)) except (discord.Forbidden, AttributeError): if not tools.mod_cmd_invoke_delete(ctx.channel): await ctx.send( f'{config.greenTick} {member} ({member.id}) has been successfully kicked. I was not able to DM them about this action' ) await member.kick(reason='Kick action performed by moderator') return if tools.mod_cmd_invoke_delete(ctx.channel): return await ctx.message.delete() await ctx.send( f'{config.greenTick} {member} ({member.id}) has been successfully kicked' )
async def expire_actions(self, _id, guild): db = mclient.bowser.puns doc = db.find_one({'_id': _id}) if not doc: logging.error( f'[Moderation] Expiry failed. Doc {_id} does not exist!') return # Lets do a sanity check. if not doc['active']: logging.debug( f'[Moderation] Expiry failed. Doc {_id} is not active but was scheduled to expire!' ) return twelveHr = 60 * 60 * 12 if doc['type'] == 'strike': userDB = mclient.bowser.users user = userDB.find_one({'_id': doc['user']}) try: if user['strike_check'] > time.time(): # To prevent drift we recall every 12 hours. Schedule for 12hr or expiry time, whichever is sooner retryTime = (twelveHr if user['strike_check'] - time.time() > twelveHr else user['strike_check'] - time.time()) self.taskHandles.append( self.bot.loop.call_later( retryTime, asyncio.create_task, self.expire_actions(_id, guild))) return except KeyError: # This is a rare edge case, but if a pun is manually created the user may not have the flag yet. More a dev handler than not logging.error( f'[Moderation] Expiry failed. Could not get strike_check from db.users resolving for pun {_id}, was it manually added?' ) # Start logic if doc['active_strike_count'] - 1 == 0: db.update_one({'_id': doc['_id']}, { '$set': { 'active': False }, '$inc': { 'active_strike_count': -1 } }) strikes = [ x for x in db.find({ 'user': doc['user'], 'type': 'strike', 'active': True }).sort('timestamp', 1) ] if not strikes: # Last active strike expired, no additional return self.taskHandles.append( self.bot.loop.call_later( 60 * 60 * 12, asyncio.create_task, self.expire_actions(strikes[0]['_id'], guild))) elif doc['active_strike_count'] > 0: db.update_one({'_id': doc['_id']}, {'$inc': { 'active_strike_count': -1 }}) self.taskHandles.append( self.bot.loop.call_later( 60 * 60 * 12, asyncio.create_task, self.expire_actions(doc['_id'], guild))) else: logging.warning( f'[Moderation] Expiry failed. Doc {_id} had a negative active strike count and was skipped' ) return userDB.update_one( {'_id': doc['user']}, {'$set': { 'strike_check': time.time() + 60 * 60 * 24 * 7 }}) elif doc['type'] == 'mute' and doc[ 'expiry']: # A mute that has an expiry # To prevent drift we recall every 12 hours. Schedule for 12hr or expiry time, whichever is sooner if doc['expiry'] > time.time(): retryTime = twelveHr if doc['expiry'] - time.time( ) > twelveHr else doc['expiry'] - time.time() self.taskHandles.append( self.bot.loop.call_later(retryTime, asyncio.create_task, self.expire_actions(_id, guild))) return punGuild = self.bot.get_guild(guild) try: member = await punGuild.fetch_member(doc['user']) except discord.NotFound: # User has left the server after the mute was issued. Lets just move on and let on_member_join handle on return return except discord.HTTPException: # Issue with API, lets just try again later in 30 seconds self.taskHandles.append( self.bot.loop.call_later(30, asyncio.create_task, self.expire_actions(_id, guild))) return newPun = db.find_one_and_update({'_id': doc['_id']}, {'$set': { 'active': False }}) docID = await tools.issue_pun(doc['user'], self.bot.user.id, 'unmute', 'Mute expired', active=False, context=doc['_id']) if not newPun: # There is near zero reason this would ever hit, but in case... logging.error( f'[Moderation] Expiry failed. Database failed to update user on pun expiration of {doc["_id"]}' ) await member.remove_roles(self.roles[doc['type']]) try: await member.send( tools.format_pundm('unmute', 'Mute expired', None, auto=True)) except discord.Forbidden: # User has DMs off pass await tools.send_modlog( self.bot, self.modLogs, 'unmute', docID, 'Mute expired', user=member, moderator=self.bot.user, public=True, )
async def _strike_set(self, ctx, member: discord.Member, count: StrikeRange, *, reason): punDB = mclient.bowser.puns activeStrikes = 0 puns = punDB.find({ 'user': member.id, 'type': 'strike', 'active': True }) for pun in puns: activeStrikes += pun['active_strike_count'] if activeStrikes == count: return await ctx.send( f'{config.redTick} That user already has {activeStrikes} active strikes' ) elif ( count > activeStrikes ): # This is going to be a positive diff, lets just do the math and defer work to _strike() return await self._strike(ctx, member, count - activeStrikes, reason=reason) else: # Negative diff, we will need to reduce our strikes diff = activeStrikes - count puns = punDB.find({ 'user': member.id, 'type': 'strike', 'active': True }).sort('timestamp', 1) for pun in puns: if pun['active_strike_count'] - diff >= 0: userDB = mclient.bowser.users punDB.update_one( {'_id': pun['_id']}, { '$set': { 'active_strike_count': pun['active_strike_count'] - diff, 'active': pun['active_strike_count'] - diff > 0, } }, ) userDB.update_one({'_id': member.id}, { '$set': { 'strike_check': time.time() + (60 * 60 * 24 * 7) } }) self.taskHandles.append( self.bot.loop.call_later( 60 * 60 * 12, asyncio.create_task, self.expire_actions(pun['_id'], ctx.guild.id)) ) # Check in 12 hours, prevents time drifting # Logic to calculate the remaining (diff) strikes will simplify to 0 # new_diff = diff - removed_strikes # = diff - (old_strike_amount - new_strike_amount) # = diff - (old_strike_amount - (old_strike_amount - diff)) # = diff - old_strike_amount + old_strike_amount - diff # = 0 diff = 0 break elif pun['active_strike_count'] - diff < 0: punDB.update_one( {'_id': pun['_id']}, {'$set': { 'active_strike_count': 0, 'active': False }}) diff -= pun['active_strike_count'] if diff != 0: # Something has gone horribly wrong raise ValueError('Diff != 0 after full iteration') docID = await tools.issue_pun(member.id, ctx.author.id, 'destrike', reason=reason, active=False, strike_count=activeStrikes - count) await tools.send_modlog( self.bot, self.modLogs, 'destrike', docID, reason, user=member, moderator=ctx.author, extra_author=(activeStrikes - count), public=True, ) error = "" try: await member.send( tools.format_pundm('destrike', reason, ctx.author, details=activeStrikes - count)) except discord.Forbidden: error = 'I was not able to DM them about this action' if tools.mod_cmd_invoke_delete(ctx.channel): return await ctx.message.delete() await ctx.send( f'{member} ({member.id}) has had {activeStrikes - count} strikes removed, ' f'they now have {activeStrikes} strike{"s" if activeStrikes > 1 else ""} ' f'({activeStrikes+count} - {count}) {error}')
async def _strike(self, ctx, member: discord.Member, count: typing.Optional[StrikeRange] = 1, *, reason): if count == 0: return await ctx.send( f'{config.redTick} You cannot issue less than one strike. If you need to reset this user\'s strikes to zero instead use `{ctx.prefix}strike set`' ) if len(reason) > 990: return await ctx.send( f'{config.redTick} Strike reason is too long, reduce it by at least {len(reason) - 990} characters' ) punDB = mclient.bowser.puns userDB = mclient.bowser.users activeStrikes = 0 for pun in punDB.find({ 'user': member.id, 'type': 'strike', 'active': True }): activeStrikes += pun['active_strike_count'] activeStrikes += count if activeStrikes > 16: # Max of 16 active strikes return await ctx.send( f'{config.redTick} Striking {count} time{"s" if count > 1 else ""} would exceed the maximum of 16 strikes. The amount being issued must be lowered by at least {activeStrikes - 16} or consider banning the user instead' ) docID = await tools.issue_pun(member.id, ctx.author.id, 'strike', reason, strike_count=count, public=True) userDB.update_one( {'_id': member.id}, {'$set': { 'strike_check': time.time() + (60 * 60 * 24 * 7) }}) # 7 days self.taskHandles.append( self.bot.loop.call_later(60 * 60 * 12, asyncio.create_task, self.expire_actions(docID, ctx.guild.id)) ) # Check in 12 hours, prevents time drifting await tools.send_modlog( self.bot, self.modLogs, 'strike', docID, reason, user=member, moderator=ctx.author, extra_author=count, public=True, ) content = ( f'{config.greenTick} {member} ({member.id}) has been successfully struck, ' f'they now have {activeStrikes} strike{"s" if activeStrikes > 1 else ""} ({activeStrikes-count} + {count})' ) try: await member.send( tools.format_pundm('strike', reason, ctx.author, details=count)) except discord.Forbidden: if not tools.mod_cmd_invoke_delete(ctx.channel): content += '. I was not able to DM them about this action' if activeStrikes == 16: content += '.\n:exclamation: You may want to consider a ban' await ctx.send(content) return if tools.mod_cmd_invoke_delete(ctx.channel): return await ctx.message.delete() if activeStrikes == 16: content += '.\n:exclamation: You may want to consider a ban' await ctx.send(content)
async def _muting(self, ctx, member: discord.Member, duration, *, reason='-No reason specified-'): if len(reason) > 990: return await ctx.send( f'{config.redTick} Mute reason is too long, reduce it by at least {len(reason) - 990} characters' ) db = mclient.bowser.puns if db.find_one({'user': member.id, 'type': 'mute', 'active': True}): return await ctx.send( f'{config.redTick} {member} ({member.id}) is already muted') muteRole = ctx.guild.get_role(config.mute) try: _duration = tools.resolve_duration(duration) try: if int(duration): raise TypeError except ValueError: pass except (KeyError, TypeError): return await ctx.send(f'{config.redTick} Invalid duration passed') docID = await tools.issue_pun(member.id, ctx.author.id, 'mute', reason, int(_duration.timestamp())) await member.add_roles(muteRole, reason='Mute action performed by moderator') await tools.send_modlog( self.bot, self.modLogs, 'mute', docID, reason, user=member, moderator=ctx.author, expires= f'{_duration.strftime("%B %d, %Y %H:%M:%S UTC")} ({tools.humanize_duration(_duration)})', public=True, ) error = "" try: await member.send( tools.format_pundm('mute', reason, ctx.author, tools.humanize_duration(_duration))) except (discord.Forbidden, AttributeError): error = '. I was not able to DM them about this action' if not tools.mod_cmd_invoke_delete(ctx.channel): await ctx.send( f'{config.greenTick} {member} ({member.id}) has been successfully muted{error}' ) twelveHr = 60 * 60 * 12 expireTime = time.mktime(_duration.timetuple()) logging.info(f'using {expireTime}') tryTime = twelveHr if expireTime - time.time( ) > twelveHr else expireTime - time.time() self.taskHandles.append( self.bot.loop.call_later(tryTime, asyncio.create_task, self.expire_actions(docID, ctx.guild.id))) if tools.mod_cmd_invoke_delete(ctx.channel): return await ctx.message.delete()
async def _banning(self, ctx, users: commands.Greedy[ResolveUser], *, reason='-No reason specified-'): if len(reason) > 990: return await ctx.send( f'{config.redTick} Ban reason is too long, reduce it by at least {len(reason) - 990} characters' ) if not users: return await ctx.send( f'{config.redTick} An invalid user was provided') banCount = 0 failedBans = 0 for user in users: userid = user if (type(user) is int) else user.id username = userid if (type(user) is int) else f'{str(user)}' user = (discord.Object(id=userid) if (type(user) is int) else user ) # If not a user, manually contruct a user object try: await ctx.guild.fetch_ban(user) if len(users) == 1: return await ctx.send( f'{config.redTick} {username} is already banned') else: # If a many-user ban, don't exit if a user is already banned failedBans += 1 continue except discord.NotFound: pass try: await user.send(tools.format_pundm('ban', reason, ctx.author)) except (discord.Forbidden, AttributeError): pass try: await ctx.guild.ban( user, reason=f'Ban action performed by moderator', delete_message_days=3) except discord.NotFound: # User does not exist if len(users) == 1: return await ctx.send( f'{config.redTick} User {userid} does not exist') failedBans += 1 continue docID = await tools.issue_pun(userid, ctx.author.id, 'ban', reason=reason) await tools.send_modlog( self.bot, self.modLogs, 'ban', docID, reason, username=username, userid=userid, moderator=ctx.author, public=True, ) banCount += 1 if tools.mod_cmd_invoke_delete(ctx.channel): return await ctx.message.delete() if len(users) == 1: await ctx.send( f'{config.greenTick} {users[0]} has been successfully banned') else: resp = f'{config.greenTick} **{banCount}** users have been successfully banned' if failedBans: resp += f'. Failed to ban **{failedBans}** from the provided list' return await ctx.send(resp)
async def _kicking(self, ctx, users: commands.Greedy[ResolveUser], *, reason='-No reason specified-'): if len(reason) > 990: return await ctx.send( f'{config.redTick} Kick reason is too long, reduce it by at least {len(reason) - 990} characters' ) if not users: return await ctx.send( f'{config.redTick} An invalid user was provided') kickCount = 0 failedKicks = 0 couldNotDM = False for user in users: userid = user if (type(user) is int) else user.id username = userid if (type(user) is int) else f'{str(user)}' user = (discord.Object(id=userid) if (type(user) is int) else user ) # If not a user, manually contruct a user object try: member = await ctx.guild.fetch_member(userid) except discord.HTTPException: # Member not in guild if len(users) == 1: return await ctx.send( f'{config.redTick} {username} is not the server!') else: # If a many-user kick, don't exit if a user is already gone failedKicks += 1 continue usr_role_pos = member.top_role.position if (usr_role_pos >= ctx.guild.me.top_role.position) or ( usr_role_pos >= ctx.author.top_role.position): if len(users) == 1: return await ctx.send( f'{config.redTick} Insufficent permissions to kick {username}' ) else: failedKicks += 1 continue try: await user.send(tools.format_pundm('kick', reason, ctx.author)) except (discord.Forbidden, AttributeError): couldNotDM = True pass try: await member.kick(reason='Kick action performed by moderator') except (discord.Forbidden): failedKicks += 1 continue docID = await tools.issue_pun(member.id, ctx.author.id, 'kick', reason, active=False) await tools.send_modlog(self.bot, self.modLogs, 'kick', docID, reason, user=member, moderator=ctx.author, public=True) kickCount += 1 if tools.mod_cmd_invoke_delete(ctx.channel): return await ctx.message.delete() if ctx.author.id != self.bot.user.id: # Non-command invoke, such as automod if len(users) == 1: resp = f'{config.greenTick} {users[0]} has been successfully kicked' if couldNotDM: resp += '. I was not able to DM them about this action' else: resp = f'{config.greenTick} **{kickCount}** users have been successfully kicked' if failedKicks: resp += f'. Failed to kick **{failedKicks}** from the provided list' return await ctx.send(resp)
async def _infraction_editing(self, ctx, infraction, reason, duration=None): db = mclient.bowser.puns doc = db.find_one({'_id': infraction}) if not doc: return await ctx.send( f'{config.redTick} An invalid infraction id was provided') if not doc['active'] and duration: return await ctx.send( f'{config.redTick} That infraction has already expired and the duration cannot be edited' ) if duration and doc[ 'type'] != 'mute': # TODO: Should we support strikes in the future? return ctx.send( f'{config.redTick} Setting durations is not supported for {doc["type"]}' ) user = await self.bot.fetch_user(doc['user']) if duration: try: _duration = tools.resolve_duration(duration) humanized = tools.humanize_duration(_duration) expireStr = f'{_duration.strftime("%B %d, %Y %H:%M:%S UTC")} ({humanized})' stamp = _duration.timestamp() try: if int(duration): raise TypeError except ValueError: pass except (KeyError, TypeError): return await ctx.send( f'{config.redTick} Invalid duration passed') if stamp - time.time() < 60: # Less than a minute return await ctx.send( f'{config.redTick} Cannot set the new duration to be less than one minute' ) db.update_one({'_id': infraction}, {'$set': { 'expiry': int(stamp) }}) await tools.send_modlog( self.bot, self.modLogs, 'duration-update', doc['_id'], reason, user=user, moderator=ctx.author, expires=expireStr, extra_author=doc['type'].capitalize(), ) else: db.update_one({'_id': infraction}, {'$set': {'reason': reason}}) await tools.send_modlog( self.bot, self.modLogs, 'reason-update', doc['_id'], reason, user=user, moderator=ctx.author, extra_author=doc['type'].capitalize(), updated=doc['reason'], ) try: pubChannel = self.bot.get_channel(doc['public_log_channel']) pubMessage = await pubChannel.fetch_message( doc['public_log_message']) embed = pubMessage.embeds[0] embedDict = embed.to_dict() newEmbedDict = copy.deepcopy(embedDict) listIndex = 0 for field in embedDict['fields']: # We are working with the dict because some logs can have `reason` at different indexes and we should not assume index position if duration and field['name'] == 'Expires': # This is subject to a breaking change if `name` updated, but I'll take the risk newEmbedDict['fields'][listIndex]['value'] = expireStr break elif not duration and field['name'] == 'Reason': newEmbedDict['fields'][listIndex]['value'] = reason break listIndex += 1 assert ( embedDict['fields'] != newEmbedDict['fields'] ) # Will fail if message was unchanged, this is likely because of a breaking change upstream in the pun flow newEmbed = discord.Embed.from_dict(newEmbedDict) await pubMessage.edit(embed=newEmbed) except Exception as e: logging.error(f'[Moderation] _infraction_duration: {e}') error = '' try: member = await ctx.guild.fetch_member(doc['user']) if duration: await member.send( tools.format_pundm('duration-update', reason, details=(doc['type'], expireStr))) else: await member.send( tools.format_pundm( 'reason-update', reason, details=( doc['type'], datetime.datetime.utcfromtimestamp( doc['timestamp']).strftime( "%B %d, %Y %H:%M:%S UTC"), ), )) except (discord.NotFound, discord.Forbidden, AttributeError): error = '. I was not able to DM them about this action' await ctx.send( f'{config.greenTick} The {doc["type"]} {"duration" if duration else "reason"} has been successfully updated for {user} ({user.id}){error}' )