async def on_help_claim(self, channel, member): async with self.claim_lock: self.claimed[member.id] = channel.id log.info('%s claiming %s', po(member), po(channel)) try: await channel.edit(category=self.claimed_category, topic=f'Channel claimed by {member.name}.', sync_permissions=True) except discord.HTTPException: log.warning('Failed moving %s to claimed category.', po(channel)) return if len(self.open_category.text_channels) >= 5: return try: new_channel = self.pool_category.text_channels[-1] except IndexError: log.warning('No more channels in pool.') return try: await new_channel.edit( category=self.open_category, topic='Type a message in this channel to claim it', sync_permissions=True) await new_channel.send(embed=discord.Embed( description=GUIDE_MESSAGE)) except discord.HTTPException: log.warning('Failed moving pool channel %s to ', po(new_channel))
async def close(self, ctx): ''' Un-claims a help channel, and moves it back into the pool of available help channels. ''' is_mod = await ctx.is_mod() if is_mod or self.claimed.get(ctx.author.id, None) == ctx.channel.id: if is_mod: log.info('%s manually closing %s', po(ctx.author), po(ctx.channel)) self.bot.dispatch('help_release', ctx.channel) else: raise commands.CommandError('You can\'t do that.')
async def on_welcome(self, member, channel, message): replace_table = dict(guild=member.guild.name, user=member.mention, member_count=member.guild.member_count) for key, val in replace_table.items(): message = message.replace('{' + key + '}', str(val)) log.info('Sending welcome message for %s in %s', po(member), po(member.guild)) try: await channel.send(message) except disnake.HTTPException: pass
async def unmute(self, ctx, *, member: discord.Member): '''Unmute a member. Requires Manage Roles perms.''' if await ctx.is_mod(member): raise commands.CommandError('Can\'t unmute this member.') conf = await self.config.get_entry(ctx.guild.id) mute_role = conf.mute_role if mute_role is None: raise commands.CommandError('Mute role not set or not found.') if mute_role not in member.roles: raise commands.CommandError('Member not previously muted.') pretty_author = po(ctx.author) try: await member.remove_roles( mute_role, reason='Unmuted by {0}'.format(pretty_author)) except discord.HTTPException: raise commands.CommandError('Failed removing mute role.') try: await ctx.send('{0} unmuted.'.format(str(member))) except discord.HTTPException: pass self.bot.dispatch('log', ctx.guild, member, action='UNMUTE', severity=Severity.RESOLVED, message=ctx.message, responsible=pretty_author)
async def remindme(self, ctx, *, when_and_what: ReminderConverter()): '''Create a new reminder.''' now, when, message = when_and_what if when < now: raise commands.CommandError('Specified time is in the past.') if when - now > MAX_DELTA: raise commands.CommandError('Sorry, can\'t remind in more than a year in the future.') if message is not None and len(message) > 1024: raise commands.CommandError('Sorry, keep the message below 1024 characters!') count = await self.db.fetchval('SELECT COUNT(id) FROM remind WHERE user_id=$1', ctx.author.id) if count > MAX_REMINDERS: raise commands.CommandError(f'Sorry, you can\'t have more than {MAX_REMINDERS} active reminders at once.') await self.db.execute( 'INSERT INTO remind (guild_id, channel_id, user_id, made_on, remind_on, message) VALUES ($1, $2, $3, $4, $5, $6)', ctx.guild.id, ctx.channel.id, ctx.author.id, now, when, message ) self.timer.maybe_restart(when) remind_in = when - now remind_in += timedelta(microseconds=1000000 - (remind_in.microseconds % 1000000)) await ctx.send('You will be reminded in {}.'.format(pretty_timedelta(remind_in))) log.info('%s set a reminder for %s.', po(ctx.author), pretty_datetime(when))
async def on_reminder_complete(self, record): _id = record.get('id') guild_id = record.get('guild_id') channel_id = record.get('channel_id') user_id = record.get('user_id') message_id = record.get('message_id') made_on = record.get('made_on') message = record.get('message') channel = self.bot.get_channel(channel_id) user = self.bot.get_user(user_id) desc = message or DEFAULT_REMINDER_MESSAGE if message_id is not None: jump_url = 'https://discord.com/channels/{0}/{1}/{2}'.format( guild_id, channel_id, message_id) desc += f'\n\n[Click for context!]({jump_url})' e = discord.Embed(title='Reminder', description=desc, timestamp=made_on) e.set_footer(text=f'#{channel.name}') try: if channel is not None: await channel.send(content=f'<@{user_id}>', embed=e) elif user is not None: await user.send(embed=e) except discord.HTTPException as exc: log.info('Failed sending reminder #%s for %s - %s', _id, po(user), str(exc))
async def ban_complete(self, record): guild_id = record.get('guild_id') user_id = record.get('user_id') mod_id = record.get('mod_id') duration = record.get('duration') userdata = loads(record.get('userdata')) reason = record.get('reason') guild = self.bot.get_guild(guild_id) if guild is None: return mod = guild.get_member(mod_id) pretty_mod = '(ID: {0})'.format( str(mod_id)) if mod is None else po(mod) member = FakeUser(user_id, guild, **userdata) try: await guild.unban( member, reason='Completed tempban issued by {0}'.format(pretty_mod)) except discord.HTTPException: return # rip :) self.bot.dispatch('log', guild, member, action='TEMPBAN COMPLETED', severity=Severity.RESOLVED, responsible=pretty_mod, duration=pretty_timedelta(duration), reason=reason)
async def do_action(self, message, action, reason, delete_message_days=0): '''Called when an event happens.''' member: disnake.Member = message.author guild: disnake.Guild = message.guild ctx = await self.bot.get_context(message, cls=AceContext) # ignore if member is mod if await ctx.is_mod(): self.bot.dispatch( 'log', guild, member, action='IGNORED {0} (MEMBER IS MOD)'.format(action.name), severity=Severity.LOW, message=message, reason=reason, ) return # otherwise, check against security actions and perform punishment try: if action is SecurityAction.TIMEOUT: await member.timeout(until=datetime.utcnow() + timedelta(days=28), reason=reason) elif action is SecurityAction.KICK: await member.kick(reason=reason) elif action is SecurityAction.BAN: await member.ban(delete_message_days=delete_message_days, reason=reason) except Exception as exc: # log error if something happened self.bot.dispatch('log', guild, member, action='{0} FAILED'.format(action.name), severity=Severity.HIGH, message=message, reason=reason, error=str(exc)) return # log successful security event self.bot.dispatch('log', guild, member, action=action.name, severity=Severity(action.value), message=message, reason=reason) try: await message.channel.send('{0} {1}: {2}'.format( po(member), SecurityVerb[action.name].value, reason)) except disnake.HTTPException: pass
async def on_ready(self): if not self.ready.is_set(): self.load_extensions() self.loop.create_task(self.update_dbl()) self.ready.set() log.info('Ready! %s', po(self.user))
async def on_open_message(self, message: disnake.Message): claimed_id = await self.has_channel(message.author.id) if claimed_id is not None: log.info(f'User has already claimed #{message.guild.get_channel(claimed_id)}') await self.post_error(message, f'You have already claimed <#{claimed_id}>, please ask your question there.') return now = disnake.utils.utcnow() author: disnake.Member = message.author # check whether author has claimed another channel within the last MINIMUM_CLAIM_INTERVAL time last_claim_at = self._claimed_at.get(author.id, None) if last_claim_at is not None and last_claim_at > now - MINIMUM_CLAIM_INTERVAL: # and False: log.info( f'{author} previously claimed a channel {pretty_timedelta(now - last_claim_at)} ago, ' f'should wait {pretty_timedelta(MINIMUM_CLAIM_INTERVAL - (now - last_claim_at))}' ) await self.post_error(message, f'Please wait a bit before claiming another channel.') return channel: disnake.TextChannel = message.channel log.info('%s claiming %s', po(author), po(channel)) # activate the channel self.set_state(channel, ChannelState.ACTIVATING) await self.set_claimant(channel.id, message.author.id) create_task(self.activate_channel(message)) # maybe open new channel if len(self.open_channels(forecast=True)) >= OPEN_CHANNEL_COUNT: log.info('No need to open another channel') return closed_channels = self.closed_channels(forecast=False) if not closed_channels: log.info('No closed channels available to move to open category!') return to_open = closed_channels[0] self.set_state(to_open, ChannelState.OPENING) create_task(self.open_channel(to_open))
async def close(self, ctx): controller: Controller = self.controllers.get(ctx.message.guild.id, None) if not controller: return # check that this is a channel in an active category that can actually be closed if ctx.channel.category != controller.active_category: return is_mod = await ctx.is_mod() channel_claimant_id = await controller.get_claimant(ctx.channel.id) if not is_mod and channel_claimant_id != ctx.author.id: raise commands.CommandError('You can\'t do that.') log.info('%s is closing %s', po(ctx.author), po(ctx.channel)) await controller.close_channel(ctx.channel)
async def close(self, ctx): '''Releases a help channel, and moves it back into the pool of closed help channels.''' if ctx.channel.category != self.active_category: return is_mod = await ctx.is_mod() claimed_channel_id = self.claimed_channel.get(ctx.author.id, None) claimed_at = self.claimed_at.get(ctx.author.id, None) if is_mod or claimed_channel_id == ctx.channel.id: if is_mod: log.info('%s force-closing closing %s', po(ctx.author), po(ctx.channel)) elif claimed_at is not None and claimed_at > datetime.utcnow() - MINIMUM_LEASE: raise commands.CommandError(f'Please wait at least {pretty_timedelta(MINIMUM_LEASE)} after claiming before closing a help channel.') await self.close_channel(ctx.channel) else: raise commands.CommandError('You can\'t do that.')
async def clear(self, ctx, message_count: int, user: MaybeMemberConverter = None): '''Simple purge command. Clear messages, either from user or indiscriminately.''' if message_count < 1: raise commands.CommandError( 'Please choose a positive message amount to clear.') if message_count > 100: raise commands.CommandError( 'Please choose a message count below 100.') def all_check(msg): if msg.id == RULES_MSG_ID: return False return True def user_check(msg): return msg.author.id == user.id and all_check(msg) try: await ctx.message.delete() except discord.HTTPException: pass try: deleted = await ctx.channel.purge( limit=message_count, check=all_check if user is None else user_check) except discord.HTTPException: raise commands.CommandError( 'Failed deleting messages. Does the bot have the necessary permissions?' ) count = len(deleted) log.info('%s cleared %s messages in %s', po(ctx.author), count, po(ctx.guild)) await ctx.send(f'Deleted {count} message{"s" if count > 1 else ""}.', delete_after=5)
async def helper_purge(self): guild = self.bot.get_guild(AHK_GUILD_ID) if guild is None: return role = guild.get_role(HELPERS_ROLE_ID) if role is None: return past = datetime.utcnow() - INACTIVITY_LIMIT all_helpers = list(member for member in guild.members if role in member.roles) helpers = list(member for member in all_helpers if member.joined_at < past) spare = await self.bot.db.fetch( 'SELECT user_id FROM seen WHERE guild_id=$1 AND user_id=ANY($2::bigint[]) AND seen>$3', AHK_GUILD_ID, list(member.id for member in helpers), past) spare_ids = list(record.get('user_id') for record in spare) remove = list(member for member in helpers if member.id not in spare_ids) if not remove: return log.info('About to purge %s helpers. Current list: %s', len(remove), ', '.join(list(str(member.id) for member in all_helpers))) log.info('Removing inactive helpers:\n%s', '\n'.join(list(po(member) for member in remove))) reason = 'Removed helper inactive for over {0}.'.format( pretty_timedelta(INACTIVITY_LIMIT)) for member in remove: try: await member.remove_roles(role, reason=reason) except discord.HTTPException as e: self.bot.dispatch( 'log', guild, member, action='FAILED REMOVING HELPER', reason='Failed removing role.\n\nException:\n```{}```'. format(str(e)), ) continue self.bot.dispatch('log', guild, member, action='REMOVE HELPER', reason=reason)
async def muterole(self, ctx, *, role: discord.Role = None): '''Set the mute role. Only modifiable by server administrators. Leave argument empty to clear.''' conf = await self.config.get_entry(ctx.guild.id) if role is None: await conf.update(mute_role_id=None) await ctx.send('Mute role cleared.') else: await conf.update(mute_role_id=role.id) await ctx.send('Mute role has been set to {0}'.format(po(role)))
async def logchannel(self, ctx, *, channel: discord.TextChannel = None): '''Set a channel for the bot to log moderation-related messages.''' conf = await self.config.get_entry(ctx.guild.id) if channel is None: await conf.update(log_channel_id=None) await ctx.send('Log channel cleared.') else: await conf.update(log_channel_id=channel.id) await ctx.send('Log channel has been set to {0}'.format( po(channel)))
async def close_channel(self, channel: discord.TextChannel): '''Release a claimed channel from a member, making it closed.''' log.info(f'Closing #{channel}') owner_id = None for channel_owner_id, channel_id in self.claimed_channel.items(): if channel_id == channel.id: owner_id = channel_owner_id break self.claimed_channel.pop(owner_id, None) self.claimed_messages.pop(channel.id, None) self._store_claims() log.info('Reclaiming %s from user id %s', po(channel), owner_id or 'UNKNOWN (not found in claims cache)') if self.should_open(): log.info('Moving channel to open category since it needs channels') await self.open_channel(channel) else: log.info('Moving channel to closed category') try: current_first = self.closed_category.text_channels[0] to_pos = max(current_first.position - 1, 0) except IndexError: to_pos = max(c.position for c in channel.guild.channels) + 1 opt = dict( position=to_pos, category=self.closed_category, sync_permissions=True, topic=f'<#{GET_HELP_CHAN_ID}>' ) if self.has_postfix(channel): opt['name'] = self._stripped_name(channel) # send this before moving channel in case of rate limit shi try: await channel.send(embed=discord.Embed(description=CLOSED_MESSAGE, color=discord.Color.red())) except discord.HTTPException: pass await channel.edit(**opt)
async def on_help_release(self, channel): async with self.release_lock: owner_id = None for channel_owner_id, channel_id in self.claimed.items(): if channel_id == channel.id: owner_id = channel_owner_id if owner_id is not None: self.claimed.pop(owner_id) log.info('Reclaiming %s from user id %s', po(channel), owner_id) await channel.edit(category=self.pool_category, topic='Open for claiming.', sync_permissions=True)
async def newusers(self, ctx, *, count=5): '''List newly joined members.''' count = min(max(count, 5), 25) now = datetime.now(timezone.utc) e = disnake.Embed() for idx, member in enumerate(sorted(ctx.guild.members, key=lambda m: m.joined_at, reverse=True)): if idx >= count: break value = 'Joined {0} ago\nCreated {1} ago'.format(pretty_timedelta(now - member.joined_at), pretty_timedelta(now - member.created_at)) e.add_field(name=po(member), value=value, inline=False) await ctx.send(embed=e)
async def mute_complete(self, record): conf = await self.config.get_entry(record.get('guild_id')) mute_role = conf.mute_role if mute_role is None: return guild_id = record.get('guild_id') user_id = record.get('user_id') mod_id = record.get('mod_id') duration = record.get('duration') reason = record.get('reason') guild = self.bot.get_guild(guild_id) if guild is None: return member = guild.get_member(user_id) if member is None: return mod = guild.get_member(mod_id) pretty_mod = '(ID: {0})'.format( str(mod_id)) if mod is None else po(mod) try: await member.remove_roles( mute_role, reason='Completed tempmute issued by {0}'.format(pretty_mod)) except discord.HTTPException: return self.bot.dispatch('log', guild, member, action='TEMPMUTE COMPLETED', severity=Severity.RESOLVED, responsible=pretty_mod, duration=pretty_timedelta(duration), reason=reason)
async def mute(self, ctx, member: discord.Member, *, reason: reason_converter = None): '''Mute a member. Requires Manage Roles perms.''' # TODO: should also handle people with manage roles perms if await ctx.is_mod(member): raise commands.CommandError('Can\'t mute this member.') conf = await self.config.get_entry(ctx.guild.id) mute_role = conf.mute_role if mute_role is None: raise commands.CommandError('Mute role not set or not found.') if mute_role in member.roles: raise commands.CommandError('Member already muted.') try: await member.add_roles(mute_role, reason=reason) except discord.HTTPException: raise commands.CommandError('Mute failed.') try: await ctx.send('{0} muted.'.format(str(member))) except discord.HTTPException: pass self.bot.dispatch('log', ctx.guild, member, action='MUTE', severity=Severity.LOW, message=ctx.message, responsible=po(ctx.author), reason=reason)
async def on_reminder_complete(self, record): _id = record.get('id') channel_id = record.get('channel_id') user_id = record.get('user_id') made_on = record.get('made_on') message = record.get('message') channel = self.bot.get_channel(channel_id) user = self.bot.get_user(user_id) e = discord.Embed( title='Reminder:', description=message or DEFAULT_REMINDER_MESSAGE, timestamp=made_on ) try: if channel is not None: await channel.send(content=f'<@{user_id}>', embed=e) elif user is not None: await user.send(embed=e) except discord.HTTPException as exc: log.info('Failed sending reminder #%s for %s - %s', _id, po(user), str(exc))
async def on_guild_unavailable(self, guild): log.info('Unavailable guild %s', po(guild))
async def on_guild_remove(self, guild): log.info('Left guild %s', po(guild)) await self.update_dbl()
async def on_guild_join(self, guild): log.info('Join guild %s', po(guild)) await self.update_dbl()
async def on_command(self, ctx): spl = ctx.message.content.split('\n') log.info('%s in %s: %s', po(ctx.author), po(ctx.guild), spl[0] + (' ...' if len(spl) > 1 else ''))
async def on_open_message(self, message): message: discord.Message = message author: discord.Member = message.author channel: discord.TextChannel = message.channel log.info( f'New message in open category: {message.author} - #{message.channel}' ) # check whether author already has a claimed channel claimed_id = self.claimed_channel.get(author.id, None) if claimed_id is not None: # and False: log.info( f'User has already claimed #{message.guild.get_channel(claimed_id)}' ) await self.post_error( message, f'You have already claimed <#{claimed_id}>, please ask your question there.' ) return now = datetime.utcnow() # check whether author has claimed another channel within the last MINIMUM_CLAIM_INTERVAL time last_claim_at = self.claimed_at.get(author.id, None) if last_claim_at is not None and last_claim_at > now - MINIMUM_CLAIM_INTERVAL: # and False: log.info( f'User previously claimed a channel {pretty_timedelta(now - last_claim_at)} ago' ) await self.post_error( message, f'Please wait at least {pretty_timedelta(MINIMUM_CLAIM_INTERVAL)} between each help channel claim.' ) return if self.is_claimed(channel.id): log.info('Channel is already claimed (in the claimed category?)') return log.info('%s claiming %s', po(author), po(channel)) # move channel try: opt = dict(position=self.active_info_channel.position + 1, category=self.active_category, sync_permissions=True, topic=message.jump_url) if not self.has_postfix(channel): opt['name'] = channel.name + '-' + NEW_EMOJI await channel.edit(**opt) except discord.HTTPException as exc: log.warning('Failed moving %s to claimed category: %s', po(channel), str(exc)) return # set some metadata self.claimed_channel[author.id] = channel.id self.claimed_at[author.id] = now self._store_claims() # check whether we need to move any open channels try: channel = self.closed_category.text_channels[-1] except IndexError: log.warning('No more openable channels in closed pool!') else: log.info(f'Opening new channel after claim: {channel}') await self.open_channel(channel)
def stamp(self): return 'TIME: {}\nGUILD: {}\nCHANNEL: #{}\nAUTHOR: {}\nMESSAGE ID: {}'.format( pretty_datetime(self.message.created_at), po(self.guild), po(self.channel), po(self.author), str(self.message.id))
async def do_action(self, message, action, reason): '''Called when an event happens.''' member = message.author guild = message.guild conf = await self.config.get_entry(member.guild.id) ctx = await self.bot.get_context(message, cls=AceContext) # ignore if member is mod if await ctx.is_mod(): self.bot.dispatch( 'log', guild, member, action='IGNORED {0} (MEMBER IS MOD)'.format(action.name), severity=Severity.LOW, message=message, reason=reason, ) return # otherwise, check against security actions and perform punishment try: if action is SecurityAction.MUTE: mute_role = conf.mute_role if mute_role is None: raise ValueError('No mute role set.') await member.add_roles(mute_role, reason=reason) elif action is SecurityAction.KICK: await member.kick(reason=reason) elif action is SecurityAction.BAN: await member.ban(delete_message_days=0, reason=reason) except Exception as exc: # log error if something happened self.bot.dispatch('log', guild, member, action='{0} FAILED'.format(action.name), severity=Severity.HIGH, message=message, reason=reason, error=str(exc)) return # log successful security event self.bot.dispatch('log', guild, member, action=action.name, severity=Severity(action.value), message=message, reason=reason) try: await message.channel.send('{0} {1}: {2}'.format( po(member), SecurityVerb[action.name].value, reason)) except discord.HTTPException: pass
async def purge(self, ctx, *, args: str = None): '''Advanced purge command. Do `help purge` for usage examples and argument list. Arguments are parsed as command line arguments. Examples: Delete all messages within the last 200 containing the word "spam": `purge --check 200 --contains "spam"` Delete all messages within the last 100 from two members: `purge --user @runie @dave` Delete maximum 6 messages within the last 400 starting with "ham": `purge --check 400 --max 6 --starts "ham"` List of arguments: ``` --check <int> Total amount of messages the bot will check for deletion. --max <int> Total amount of messages the bot will delete. --bot Only delete messages from bots. --user member [...] Only delete messages from these members. --after message_id Start deleting after this message id. --before message_id Delete, at most, up until this message id. --contains Delete messages containing this string(s). --starts <string> Delete messages starting with this string(s). --ends <string> Delete messages ending with this string(s).```''' parser = NoExitArgumentParser(prog='purge', add_help=False, allow_abbrev=False) parser.add_argument( '-c', '--check', type=int, metavar='message_count', help='Total amount of messages checked for deletion.') parser.add_argument( '-m', '--max', type=int, metavar='message_count', help='Total amount of messages the bot will delete.') parser.add_argument('--bot', action='store_true', help='Only delete messages from bots.') parser.add_argument('-u', '--user', nargs='+', metavar='user', help='Only delete messages from this member(s).') parser.add_argument('-a', '--after', type=int, metavar='id', help='Start deleting after this message id.') parser.add_argument('-b', '--before', type=int, metavar='id', help='Delete, at most, up until this message id.') parser.add_argument('--contains', nargs='+', metavar='text', help='Delete messages containing this string(s).') parser.add_argument( '--starts', nargs='+', metavar='text', help='Delete messages starting with this string(s).') parser.add_argument('--ends', nargs='+', metavar='text', help='Delete messages ending with this string(s).') if args is None: await ctx.send('```\n{0}\n```'.format(parser.format_help())) return try: args = parser.parse_args(shlex.split(args)) except Exception as e: raise commands.CommandError(str(e).partition('error: ')[2]) preds = [ lambda m: m.id != ctx.message.id, lambda m: m.id != RULES_MSG_ID ] if args.user: converter = MaybeMemberConverter() members = [] for id in args.user: try: member = await converter.convert(ctx, id) members.append(member) except commands.CommandError: raise commands.CommandError( 'Unknown user: "******"'.format(id)) # yes, if both objects were discord.Member I could do m.author in members, # but since member can be FakeUser I need to do an explicit id comparison preds.append( lambda m: any(m.author.id == member.id for member in members)) if args.contains: preds.append(lambda m: any( (s in m.content) for s in args.contains)) if args.bot: preds.append(lambda m: m.author.bot) if args.starts: preds.append( lambda m: any(m.content.startswith(s) for s in args.starts)) if args.ends: preds.append( lambda m: any(m.content.endswith(s) for s in args.ends)) count = args.max deleted = 0 def predicate(message): nonlocal deleted if count is not None and deleted >= count: return False if all(pred(message) for pred in preds): deleted += 1 return True # limit is 100 be default limit = 100 after = None before = None # set to 512 if after flag is set if args.after: after = discord.Object(id=args.after) limit = PURGE_LIMIT if args.before: before = discord.Object(id=args.before) # if we actually want to manually specify it doe if args.check is not None: limit = max(0, min(PURGE_LIMIT, args.check)) try: deleted_messages = await ctx.channel.purge(limit=limit, check=predicate, before=before, after=after) except discord.HTTPException: raise commands.CommandError( 'Error occurred when deleting messages.') deleted_count = len(deleted_messages) log.info('%s purged %s messages in %s', po(ctx.author), deleted_count, po(ctx.guild)) await ctx.send('{0} messages deleted.'.format(deleted_count), delete_after=10)