diff --git a/bot/LionModule.py b/bot/LionModule.py index d56ac38b..d6a1a557 100644 --- a/bot/LionModule.py +++ b/bot/LionModule.py @@ -1,4 +1,5 @@ from cmdClient import Command, Module +from cmdClient.lib import SafeCancellation from meta import log @@ -66,5 +67,15 @@ class LionModule(Module): """ Lion pre-command hook. """ - # TODO: Add blacklist and auto-fetch of lion here. - ... + # Check global guild blacklist + if ctx.guild.id in ctx.client.objects['blacklisted_guilds']: + raise SafeCancellation + + # Check global user blacklist + if ctx.author.id in ctx.client.objects['blacklisted_users']: + raise SafeCancellation + + if ctx.guild: + # Check guild's own member blacklist + if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]: + raise SafeCancellation diff --git a/bot/constants.py b/bot/constants.py index 683e6c8f..ed3a3b3b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 2 +DATA_VERSION = 3 diff --git a/bot/core/__init__.py b/bot/core/__init__.py index 651b6553..9be4f2bd 100644 --- a/bot/core/__init__.py +++ b/bot/core/__init__.py @@ -1,4 +1,5 @@ from . import data # noqa from .module import module -from .lion import Lion # noqa +from .lion import Lion +from . import blacklists diff --git a/bot/core/data.py b/bot/core/data.py index 29e54b67..6113ab4e 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -81,3 +81,8 @@ lions = RowTable( ) lion_ranks = Table('member_ranks', attach_as='lion_ranks') + + +global_guild_blacklist = Table('global_guild_blacklist') +global_user_blacklist = Table('global_user_blacklist') +ignored_members = Table('ignored_members') diff --git a/bot/modules/economy/cointop_cmd.py b/bot/modules/economy/cointop_cmd.py index 668ba80f..cd90b537 100644 --- a/bot/modules/economy/cointop_cmd.py +++ b/bot/modules/economy/cointop_cmd.py @@ -42,11 +42,14 @@ async def cmd_topcoin(ctx): Lion.sync() # Fetch the leaderboard - exclude = [m.id for m in ctx.guild_settings.unranked_roles.members] + exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) + exclude.update(ctx.client.objects['blacklisted_users']) + exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) + if exclude: user_data = tables.lions.select_where( guildid=ctx.guild.id, - userid=data.NOT(exclude), + userid=data.NOT(list(exclude)), select_columns=('userid', 'coins'), _extra="AND coins > 0 ORDER BY coins DESC " + ("LIMIT 100" if top100 else "") ) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index 400c5d27..6600dc1d 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -1,4 +1,5 @@ import discord +from cmdClient.checks import is_owner from utils.lib import prop_tabulate from utils import interactive, ctx_addons # noqa @@ -31,6 +32,11 @@ admin_group_order = ( ('Productivity', 'Statistics', 'Economy', 'Personal Settings') ) +bot_admin_group_order = ( + ('Bot Admin', 'Guild Configuration', 'Moderation', 'Meta'), + ('Productivity', 'Statistics', 'Economy', 'Personal Settings') +) + # Help embed format # TODO: Add config fields for this title = "LionBot Command List" @@ -153,10 +159,17 @@ async def cmd_help(ctx): stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group)) # Now put everything into a bunch of embeds - if ctx.guild and is_guild_admin(ctx.author): - group_order = admin_group_order + if await is_owner.run(ctx): + group_order = bot_admin_group_order + elif ctx.guild: + if is_guild_admin(ctx.author): + group_order = admin_group_order + elif ctx.guild_settings.mod_role.value in ctx.author.roles: + group_order = mod_group_order + else: + group_order = standard_group_order else: - group_order = standard_group_order + group_order = admin_group_order help_embeds = [] for page_groups in group_order: diff --git a/bot/modules/reminders/reminder.py b/bot/modules/reminders/reminder.py index 080f5c70..d3e4f764 100644 --- a/bot/modules/reminders/reminder.py +++ b/bot/modules/reminders/reminder.py @@ -134,6 +134,10 @@ class Reminder: """ Execute the reminder. """ + if self.data.userid in client.objects['blacklisted_users']: + self.delete(self.reminderid) + return + # Build the message embed embed = discord.Embed( title="You asked me to remind you!", diff --git a/bot/modules/study/time_tracker.py b/bot/modules/study/time_tracker.py index d48be364..1bf82b42 100644 --- a/bot/modules/study/time_tracker.py +++ b/bot/modules/study/time_tracker.py @@ -47,9 +47,14 @@ def _scan(guild): members = itertools.chain(*channel_members) # TODO filter out blacklisted users + blacklist = client.objects['blacklisted_users'] + guild_blacklist = client.objects['ignored_members'][guild.id] + for member in members: if member.bot: continue + if member.id in blacklist or member.id in guild_blacklist: + continue lion = Lion.fetch(guild.id, member.id) # Add time diff --git a/bot/modules/study/top_cmd.py b/bot/modules/study/top_cmd.py index 63bdf1b2..774f0409 100644 --- a/bot/modules/study/top_cmd.py +++ b/bot/modules/study/top_cmd.py @@ -42,11 +42,14 @@ async def cmd_top(ctx): Lion.sync() # Fetch the leaderboard - exclude = [m.id for m in ctx.guild_settings.unranked_roles.members] + exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) + exclude.update(ctx.client.objects['blacklisted_users']) + exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) + if exclude: user_data = tables.lions.select_where( guildid=ctx.guild.id, - userid=data.NOT(exclude), + userid=data.NOT(list(exclude)), select_columns=('userid', 'tracked_time'), _extra="AND tracked_time > 0 ORDER BY tracked_time DESC " + ("LIMIT 100" if top100 else "") ) diff --git a/bot/modules/sysadmin/__init__.py b/bot/modules/sysadmin/__init__.py index bd276c1c..8b2cee5a 100644 --- a/bot/modules/sysadmin/__init__.py +++ b/bot/modules/sysadmin/__init__.py @@ -3,3 +3,4 @@ from .module import module from . import exec_cmds from . import guild_log from . import status +from . import blacklist diff --git a/bot/modules/sysadmin/blacklist.py b/bot/modules/sysadmin/blacklist.py new file mode 100644 index 00000000..12a2ed9b --- /dev/null +++ b/bot/modules/sysadmin/blacklist.py @@ -0,0 +1,310 @@ +""" +System admin submodule providing an interface for managing the globally blacklisted guilds and users. + +NOTE: Not shard-safe, and will not update across shards. +""" +import discord +from cmdClient.checks import is_owner +from cmdClient.lib import ResponseTimedOut + +from .module import module + + +@module.cmd( + "guildblacklist", + desc="View/add/remove blacklisted guilds.", + group="Bot Admin", + flags=('remove',) +) +@is_owner() +async def cmd_guildblacklist(ctx, flags): + """ + Usage``: + {prefix}guildblacklist + {prefix}guildblacklist guildid, guildid, guildid + {prefix}guildblacklist --remove guildid, guildid, guildid + Description: + View, add, or remove guilds from the blacklist. + """ + blacklist = ctx.client.objects['blacklisted_guilds'] + + if ctx.args: + # guildid parsing + items = [item.strip() for item in ctx.args.split(',')] + if any(not item.isdigit() for item in items): + return await ctx.error_reply( + "Please provide guilds as comma seprated guild ids." + ) + + guildids = set(int(item) for item in items) + + if flags['remove']: + # Handle removing from the blacklist + # First make sure that all the guildids are in the blacklist + difference = [guildid for guildid in guildids if guildid not in blacklist] + if difference: + return await ctx.error_reply( + "The following guildids are not in the blacklist! No guilds were removed.\n`{}`".format( + '`, `'.join(str(guildid) for guildid in difference) + ) + ) + + # Remove the guilds from the data blacklist + ctx.client.data.global_guild_blacklist.delete_where( + guildid=list(guildids) + ) + + # Ack removal + await ctx.embed_reply( + "You have removed the following guilds from the guild blacklist.\n`{}`".format( + "`, `".join(str(guildid) for guildid in guildids) + ) + ) + else: + # Handle adding to the blacklist + to_add = [guildid for guildid in guildids if guildid not in blacklist] + if not to_add: + return await ctx.error_reply( + "All of the provided guilds are already blacklisted!" + ) + + # Prompt for reason + try: + reason = await ctx.input("Please enter the reasons these guild(s) are being blacklisted:") + except ResponseTimedOut: + raise ResponseTimedOut("Reason prompt timed out, no guilds were blacklisted.") + + # Add to the blacklist + ctx.client.data.global_guild_blacklist.insert_many( + *((guildid, ctx.author.id, reason) for guildid in to_add), + insert_keys=('guildid', 'ownerid', 'reason') + ) + + # Check if we are in any of these guilds + to_leave = (ctx.client.get_guild(guildid) for guildid in to_add) + to_leave = [guild for guild in to_leave if guild is not None] + for guild in to_leave: + await guild.leave() + + if to_leave: + left_str = "\nConsequently left the following guild(s):\n**{}**".format( + '**\n**'.join(guild.name for guild in to_leave) + ) + else: + left_str = "" + + # Ack the addition + await ctx.embed_reply( + "Added the following guild(s) to the blacklist:\n`{}`\n{}".format( + '`, `'.join(str(guildid) for guildid in to_add), + left_str + ) + ) + + # Refresh the cached blacklist after modification + ctx.client.objects['blacklisted_guilds'] = set( + row['guildid'] for row in ctx.client.data.global_guild_blacklist.select_where() + ) + else: + # Display the current blacklist + # First fetch the full blacklist data + rows = ctx.client.data.global_guild_blacklist.select_where() + if not rows: + await ctx.reply("There are no blacklisted guilds!") + else: + # Text blocks for each blacklisted guild + lines = [ + "`{}` blacklisted by <@{}> at \n**Reason:** {}".format( + row['guildid'], + row['ownerid'], + row['created_at'].timestamp(), + row['reason'] + ) for row in sorted(rows, key=lambda row: row['created_at'].timestamp(), reverse=True) + ] + + # Split lines across pages + blocks = [] + block_len = 0 + block_lines = [] + i = 0 + while i < len(lines): + line = lines[i] + line_len = len(line) + + if block_len + line_len > 2000: + if block_lines: + # Flush block, run line again on next page + blocks.append('\n'.join(block_lines)) + block_lines = [] + block_len = 0 + else: + # Too long for the block, but empty block! + # Truncate + blocks.append(line[:2000]) + i += 1 + else: + block_lines.append(line) + i += 1 + + if block_lines: + # Flush block + blocks.append('\n'.join(block_lines)) + + # Build embed pages + pages = [ + discord.Embed( + title="Blacklisted Guilds", + description=block, + colour=discord.Colour.orange() + ) for block in blocks + ] + page_count = len(blocks) + if page_count > 1: + for i, page in enumerate(pages): + page.set_footer(text="Page {}/{}".format(i + 1, page_count)) + + # Finally, post + await ctx.pager(pages) + + +@module.cmd( + "userblacklist", + desc="View/add/remove blacklisted users.", + group="Bot Admin", + flags=('remove',) +) +@is_owner() +async def cmd_userblacklist(ctx, flags): + """ + Usage``: + {prefix}userblacklist + {prefix}userblacklist userid, userid, userid + {prefix}userblacklist --remove userid, userid, userid + Description: + View, add, or remove users from the blacklist. + """ + blacklist = ctx.client.objects['blacklisted_users'] + + if ctx.args: + # userid parsing + items = [item.strip('<@!&> ') for item in ctx.args.split(',')] + if any(not item.isdigit() for item in items): + return await ctx.error_reply( + "Please provide users as comma seprated user ids or mentions." + ) + + userids = set(int(item) for item in items) + + if flags['remove']: + # Handle removing from the blacklist + # First make sure that all the userids are in the blacklist + difference = [userid for userid in userids if userid not in blacklist] + if difference: + return await ctx.error_reply( + "The following userids are not in the blacklist! No users were removed.\n`{}`".format( + '`, `'.join(str(userid) for userid in difference) + ) + ) + + # Remove the users from the data blacklist + ctx.client.data.global_user_blacklist.delete_where( + userid=list(userids) + ) + + # Ack removal + await ctx.embed_reply( + "You have removed the following users from the user blacklist.\n{}".format( + ", ".join('<@{}>'.format(userid) for userid in userids) + ) + ) + else: + # Handle adding to the blacklist + to_add = [userid for userid in userids if userid not in blacklist] + if not to_add: + return await ctx.error_reply( + "All of the provided users are already blacklisted!" + ) + + # Prompt for reason + try: + reason = await ctx.input("Please enter the reasons these user(s) are being blacklisted:") + except ResponseTimedOut: + raise ResponseTimedOut("Reason prompt timed out, no users were blacklisted.") + + # Add to the blacklist + ctx.client.data.global_user_blacklist.insert_many( + *((userid, ctx.author.id, reason) for userid in to_add), + insert_keys=('userid', 'ownerid', 'reason') + ) + + # Ack the addition + await ctx.embed_reply( + "Added the following user(s) to the blacklist:\n{}".format( + ', '.join('<@{}>'.format(userid) for userid in to_add) + ) + ) + + # Refresh the cached blacklist after modification + ctx.client.objects['blacklisted_users'] = set( + row['userid'] for row in ctx.client.data.global_user_blacklist.select_where() + ) + else: + # Display the current blacklist + # First fetch the full blacklist data + rows = ctx.client.data.global_user_blacklist.select_where() + if not rows: + await ctx.reply("There are no blacklisted users!") + else: + # Text blocks for each blacklisted user + lines = [ + "<@{}> blacklisted by <@{}> at \n**Reason:** {}".format( + row['userid'], + row['ownerid'], + row['created_at'].timestamp(), + row['reason'] + ) for row in sorted(rows, key=lambda row: row['created_at'].timestamp(), reverse=True) + ] + + # Split lines across pages + blocks = [] + block_len = 0 + block_lines = [] + i = 0 + while i < len(lines): + line = lines[i] + line_len = len(line) + + if block_len + line_len > 2000: + if block_lines: + # Flush block, run line again on next page + blocks.append('\n'.join(block_lines)) + block_lines = [] + block_len = 0 + else: + # Too long for the block, but empty block! + # Truncate + blocks.append(line[:2000]) + i += 1 + else: + block_lines.append(line) + i += 1 + + if block_lines: + # Flush block + blocks.append('\n'.join(block_lines)) + + # Build embed pages + pages = [ + discord.Embed( + title="Blacklisted Users", + description=block, + colour=discord.Colour.orange() + ) for block in blocks + ] + page_count = len(blocks) + if page_count > 1: + for i, page in enumerate(pages): + page.set_footer(text="Page {}/{}".format(i + 1, page_count)) + + # Finally, post + await ctx.pager(pages) diff --git a/bot/modules/workout/tracker.py b/bot/modules/workout/tracker.py index ac1d9630..3eebf849 100644 --- a/bot/modules/workout/tracker.py +++ b/bot/modules/workout/tracker.py @@ -170,6 +170,10 @@ async def workout_voice_tracker(client, member, before, after): if member.bot: return + if member.id in client.objects['blacklisted_users']: + return + if member.id in client.objcts['ignored_members'][member.guild.id]: + return # Check whether we are moving to/from a workout channel settings = GuildSettings(member.guild.id) diff --git a/data/migration/v2-v3/migration.sql b/data/migration/v2-v3/migration.sql new file mode 100644 index 00000000..387a1171 --- /dev/null +++ b/data/migration/v2-v3/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE global_user_blacklist( + userid BIGINT PRIMARY KEY, + ownerid BIGINT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE global_guild_blacklist( + guildid BIGINT PRIMARY KEY, + ownerid BIGINT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE ignored_members( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL +); +CREATE INDEX ignored_member_guilds ON ignored_members (guildid); + + +INSERT INTO VersionHistory (version, author) VALUES (3, 'v2-v3 Migration'); diff --git a/data/schema.sql b/data/schema.sql index d46b533b..0c9c2f64 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (2, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (3, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -21,6 +21,20 @@ CREATE TABLE AppData( appid TEXT PRIMARY KEY, last_study_badge_scan TIMESTAMP ); + +CREATE TABLE global_user_blacklist( + userid BIGINT PRIMARY KEY, + ownerid BIGINT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE global_guild_blacklist( + guildid BIGINT PRIMARY KEY, + ownerid BIGINT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); -- }}} @@ -61,6 +75,12 @@ CREATE TABLE guild_config( video_grace_period INTEGER ); +CREATE TABLE ignored_members( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL +); +CREATE INDEX ignored_member_guilds ON ignored_members (guildid); + CREATE TABLE unranked_roles( guildid BIGINT NOT NULL, roleid BIGINT NOT NULL