diff --git a/bot/core/data.py b/bot/core/data.py index 6113ab4e..c33914d7 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -74,6 +74,7 @@ lions = RowTable( 'workout_count', 'last_workout_start', 'last_study_badgeid', 'video_warned', + '_timestamp' ), ('guildid', 'userid'), cache=TTLCache(5000, ttl=60*5), diff --git a/bot/core/lion.py b/bot/core/lion.py index d090f176..b9b10092 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -2,7 +2,7 @@ import pytz from meta import client from data import tables as tb -from settings import UserSettings +from settings import UserSettings, GuildSettings class Lion: @@ -41,7 +41,13 @@ class Lion: if key in cls._lions: return cls._lions[key] else: - tb.lions.fetch_or_create(key) + lion = tb.lions.fetch(key) + if not lion: + tb.lions.create_row( + guildid=guildid, + userid=userid, + coins=GuildSettings(guildid).starting_funds.value + ) return cls(guildid, userid) @property diff --git a/bot/core/module.py b/bot/core/module.py index daba7dc3..e46f9875 100644 --- a/bot/core/module.py +++ b/bot/core/module.py @@ -40,6 +40,32 @@ def setting_initialisation(client): setting.init_task(client) +@module.launch_task +async def preload_guild_configuration(client): + """ + Loads the plain guild configuration for all guilds the client is part of into data. + """ + guildids = [guild.id for guild in client.guilds] + rows = client.data.guild_config.fetch_rows_where(guildid=guildids) + client.log( + "Preloaded guild configuration for {} guilds.".format(len(rows)), + context="CORE_LOADING" + ) + + +@module.launch_task +async def preload_studying_members(client): + """ + Loads the member data for all members who are currently in voice channels. + """ + userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members)) + rows = client.data.lions.fetch_rows_where(userid=userids) + client.log( + "Preloaded member data for {} members.".format(len(rows)), + context="CORE_LOADING" + ) + + @module.launch_task async def launch_lion_sync_loop(client): asyncio.create_task(_lion_sync_loop()) diff --git a/bot/modules/accountability/commands.py b/bot/modules/accountability/commands.py index c1580dd8..6180d4bd 100644 --- a/bot/modules/accountability/commands.py +++ b/bot/modules/accountability/commands.py @@ -62,7 +62,7 @@ def ensure_exclusive(ctx): @module.cmd( name="rooms", - desc="Book an accountability timeslot", + desc="Schedule an accountability study session.", group="Productivity" ) @in_guild() diff --git a/bot/modules/accountability/tracker.py b/bot/modules/accountability/tracker.py index e10d2772..e1136b39 100644 --- a/bot/modules/accountability/tracker.py +++ b/bot/modules/accountability/tracker.py @@ -8,6 +8,7 @@ from typing import Dict from discord.utils import sleep_until from meta import client +from utils.interactive import discord_shield from data import NULL, NOTNULL, tables from data.conditions import LEQ from settings import GuildSettings @@ -167,7 +168,7 @@ async def turnover(): to_update = [ (mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()), None, mem.slotid, mem.userid) for slot in last_slots for mem in slot.members.values() - if mem.data.last_joined_at + if mem.data and mem.data.last_joined_at ] if to_update: accountability_members.update_many( @@ -177,6 +178,15 @@ async def turnover(): cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)' ) + # Close all completed rooms, update data + await asyncio.gather(*(slot.close() for slot in last_slots), return_exceptions=True) + update_slots = [slot.data.slotid for slot in last_slots if slot.data] + if update_slots: + accountability_rooms.update_where( + {'closed_at': utc_now()}, + slotid=update_slots + ) + # Rotate guild sessions [aguild.advance() for aguild in AccountabilityGuild.cache.values()] @@ -184,7 +194,6 @@ async def turnover(): # We could break up the session starting? # Move members of the next session over to the session channel - # This includes any members of the session just complete current_slots = [ aguild.current_slot for aguild in AccountabilityGuild.cache.values() if aguild.current_slot is not None @@ -206,21 +215,12 @@ async def turnover(): return_exceptions=True ) - # Close all completed rooms, update data - await asyncio.gather(*(slot.close() for slot in last_slots)) - update_slots = [slot.data.slotid for slot in last_slots if slot.data] - if update_slots: - accountability_rooms.update_where( - {'closed_at': utc_now()}, - slotid=update_slots - ) - # Update session data of all members in new channels member_session_data = [ (0, slot.start_time, mem.slotid, mem.userid) for slot in current_slots for mem in slot.members.values() - if mem.member.voice and mem.member.voice.channel == slot.channel + if mem.data and mem.member and mem.member.voice and mem.member.voice.channel == slot.channel ] if member_session_data: accountability_members.update_many( @@ -460,3 +460,31 @@ async def unload_accountability(client): Save the current sessions and cancel the runloop in preparation for client shutdown. """ ... + + +@client.add_after_event('member_join') +async def restore_accountability(client, member): + """ + Restore accountability channel permissions when a member rejoins the server, if applicable. + """ + aguild = AccountabilityGuild.cache.get(member.guild.id, None) + if aguild: + if aguild.current_slot and member.id in aguild.current_slot.members: + # Restore member permission for current slot + slot = aguild.current_slot + if slot.channel: + asyncio.create_task(discord_shield( + slot.channel.set_permissions( + member, + overwrite=slot._member_overwrite + ) + )) + if aguild.upcoming_slot and member.id in aguild.upcoming_slot.members: + slot = aguild.upcoming_slot + if slot.channel: + asyncio.create_task(discord_shield( + slot.channel.set_permissions( + member, + overwrite=slot._member_overwrite + ) + )) diff --git a/bot/modules/guild_admin/__init__.py b/bot/modules/guild_admin/__init__.py index 37d38183..74d5644d 100644 --- a/bot/modules/guild_admin/__init__.py +++ b/bot/modules/guild_admin/__init__.py @@ -2,4 +2,5 @@ from .module import module from . import guild_config from . import statreset +from . import new_members from . import reaction_roles diff --git a/bot/modules/guild_admin/guild_config.py b/bot/modules/guild_admin/guild_config.py index b15e9c01..21f81baf 100644 --- a/bot/modules/guild_admin/guild_config.py +++ b/bot/modules/guild_admin/guild_config.py @@ -11,7 +11,7 @@ from .module import module # Pages of configuration categories to display cat_pages = { - 'Administration': ('Meta', 'Guild Roles'), + 'Administration': ('Meta', 'Guild Roles', 'New Members'), 'Moderation': ('Moderation', 'Video Channels'), 'Productivity': ('Study Tracking', 'TODO List', 'Workout'), 'Study Rooms': ('Rented Rooms', 'Accountability Rooms'), @@ -21,6 +21,7 @@ cat_pages = { descriptions = { } + @module.cmd("config", desc="View and modify the server settings.", flags=('add', 'remove'), @@ -91,27 +92,27 @@ async def cmd_config(ctx, flags): ) ) - if len(parts) == 1: + if len(parts) == 1 and not ctx.msg.attachments: # config # View config embed for provided setting - await ctx.reply(embed=setting.get(ctx.guild.id).embed) + await setting.get(ctx.guild.id).widget(ctx, flags=flags) else: # config # Check the write ward if not await setting.write_ward.run(ctx): - await ctx.error_reply(setting.msg) + await ctx.error_reply(setting.write_ward.msg) # Attempt to set config setting try: - parsed = await setting.parse(ctx.guild.id, ctx, parts[1]) + parsed = await setting.parse(ctx.guild.id, ctx, parts[1] if len(parts) > 1 else '') parsed.write(add_only=flags['add'], remove_only=flags['remove']) except UserInputError as e: await ctx.reply(embed=discord.Embed( description="{} {}".format('❌', e.msg), - Colour=discord.Colour.red() + colour=discord.Colour.red() )) else: await ctx.reply(embed=discord.Embed( description="{} {}".format('✅', setting.get(ctx.guild.id).success_response), - Colour=discord.Colour.green() + colour=discord.Colour.green() )) diff --git a/bot/modules/guild_admin/new_members/__init__.py b/bot/modules/guild_admin/new_members/__init__.py new file mode 100644 index 00000000..ced75ef6 --- /dev/null +++ b/bot/modules/guild_admin/new_members/__init__.py @@ -0,0 +1,3 @@ +from . import settings +from . import greetings +from . import roles diff --git a/bot/modules/guild_admin/new_members/data.py b/bot/modules/guild_admin/new_members/data.py new file mode 100644 index 00000000..25b0872c --- /dev/null +++ b/bot/modules/guild_admin/new_members/data.py @@ -0,0 +1,6 @@ +from data import Table, RowTable + + +autoroles = Table('autoroles') +bot_autoroles = Table('bot_autoroles') +past_member_roles = Table('past_member_roles') diff --git a/bot/modules/guild_admin/new_members/greetings.py b/bot/modules/guild_admin/new_members/greetings.py new file mode 100644 index 00000000..74ebe980 --- /dev/null +++ b/bot/modules/guild_admin/new_members/greetings.py @@ -0,0 +1,29 @@ +import discord +from cmdClient.Context import Context + +from meta import client + +from .settings import greeting_message, greeting_channel, returning_message + + +@client.add_after_event('member_join') +async def send_greetings(client, member): + guild = member.guild + + returning = bool(client.data.lions.fetch((guild.id, member.id))) + + # Handle greeting message + channel = greeting_channel.get(guild.id).value + if channel is not None: + if channel == greeting_channel.DMCHANNEL: + channel = member + + ctx = Context(client, guild=guild, author=member) + if returning: + args = returning_message.get(guild.id).args(ctx) + else: + args = greeting_message.get(guild.id).args(ctx) + try: + await channel.send(**args) + except discord.HTTPException: + pass diff --git a/bot/modules/guild_admin/new_members/roles.py b/bot/modules/guild_admin/new_members/roles.py new file mode 100644 index 00000000..94f62b22 --- /dev/null +++ b/bot/modules/guild_admin/new_members/roles.py @@ -0,0 +1,115 @@ +import asyncio +import discord +from collections import defaultdict + +from meta import client +from core import Lion +from settings import GuildSettings + +from .settings import autoroles, bot_autoroles, role_persistence +from .data import past_member_roles + + +# Locks to avoid storing the roles while adding them +# The locking is cautious, leaving data unchanged upon collision +locks = defaultdict(asyncio.Lock) + + +@client.add_after_event('member_join') +async def join_role_tracker(client, member): + """ + Add autoroles or saved roles as needed. + """ + guild = member.guild + if not guild.me.guild_permissions.manage_roles: + # We can't manage the roles here, don't try to give/restore the member roles + return + + async with locks[(guild.id, member.id)]: + if role_persistence.get(guild.id).value and client.data.lions.fetch((guild.id, member.id)): + # Lookup stored roles + role_rows = past_member_roles.select_where( + guildid=guild.id, + userid=member.id + ) + # Identify roles from roleids + roles = (guild.get_role(row['roleid']) for row in role_rows) + # Remove non-existent roles + roles = (role for role in roles if role is not None) + # Remove roles the client can't add + roles = [role for role in roles if role < guild.me.top_role] + if roles: + try: + await member.add_roles( + *roles, + reason="Restoring saved roles.", + ) + except discord.HTTPException: + # This shouldn't ususally happen, but there are valid cases where it can + # E.g. the user left while we were restoring their roles + pass + # Event log! + GuildSettings(guild.id).event_log.log( + "Restored the following roles for returning member {}:\n{}".format( + member.mention, + ', '.join(role.mention for role in roles) + ), + title="Saved roles restored" + ) + else: + # Add autoroles + roles = bot_autoroles.get(guild.id).value if member.bot else autoroles.get(guild.id).value + # Remove roles the client can't add + roles = [role for role in roles if role < guild.me.top_role] + if roles: + try: + await member.add_roles( + *roles, + reason="Adding autoroles.", + ) + except discord.HTTPException: + # This shouldn't ususally happen, but there are valid cases where it can + # E.g. the user left while we were adding autoroles + pass + # Event log! + GuildSettings(guild.id).event_log.log( + "Gave {} the guild autoroles:\n{}".format( + member.mention, + ', '.join(role.mention for role in roles) + ), + titles="Autoroles added" + ) + + +@client.add_after_event('member_remove') +async def left_role_tracker(client, member): + """ + Delete and re-store member roles when they leave the server. + """ + if (member.guild.id, member.id) in locks and locks[(member.guild.id, member.id)].locked(): + # Currently processing a join event + # Which means the member left while we were adding their roles + # Cautiously return, not modifying the saved role data + return + + # Delete existing member roles for this user + # NOTE: Not concurrency-safe + past_member_roles.delete_where( + guildid=member.guild.id, + userid=member.id, + ) + if role_persistence.get(member.guild.id).value: + # Make sure the user has an associated lion, so we can detect when they rejoin + Lion.fetch(member.guild.id, member.id) + + # Then insert the current member roles + values = [ + (member.guild.id, member.id, role.id) + for role in member.roles + if not role.is_bot_managed() and not role.is_integration() and not role.is_default() + ] + if values: + past_member_roles.insert_many( + *values, + insert_keys=('guildid', 'userid', 'roleid') + ) diff --git a/bot/modules/guild_admin/new_members/settings.py b/bot/modules/guild_admin/new_members/settings.py new file mode 100644 index 00000000..26f1c185 --- /dev/null +++ b/bot/modules/guild_admin/new_members/settings.py @@ -0,0 +1,303 @@ +import datetime +import discord + +import settings +from settings import GuildSettings, GuildSetting +import settings.setting_types as stypes +from wards import guild_admin + +from .data import autoroles, bot_autoroles + + +@GuildSettings.attach_setting +class greeting_channel(stypes.Channel, GuildSetting): + """ + Setting describing the destination of the greeting message. + + Extended to support the following special values, with input and output supported. + Data `None` corresponds to `Off`. + Data `1` corresponds to `DM`. + """ + DMCHANNEL = object() + + category = "New Members" + + attr_name = 'greeting_channel' + _data_column = 'greeting_channel' + + display_name = "greeting_channel" + desc = "Channel to send the greeting message in" + + long_desc = ( + "Channel to post the `greeting_message` in when a new user joins the server. " + "Accepts `DM` to indicate the greeting should be direct messaged to the new member." + ) + _accepts = ( + "Text Channel name/id/mention, or `DM`, or `None` to disable." + ) + _chan_type = discord.ChannelType.text + + @classmethod + def _data_to_value(cls, id, data, **kwargs): + if data is None: + return None + elif data == 1: + return cls.DMCHANNEL + else: + return super()._data_to_value(id, data, **kwargs) + + @classmethod + def _data_from_value(cls, id, value, **kwargs): + if value is None: + return None + elif value == cls.DMCHANNEL: + return 1 + else: + return super()._data_from_value(id, value, **kwargs) + + @classmethod + async def _parse_userstr(cls, ctx, id, userstr, **kwargs): + lower = userstr.lower() + if lower in ('0', 'none', 'off'): + return None + elif lower == 'dm': + return 1 + else: + return await super()._parse_userstr(ctx, id, userstr, **kwargs) + + @classmethod + def _format_data(cls, id, data, **kwargs): + if data is None: + return "Off" + elif data == 1: + return "DM" + else: + return "<#{}>".format(data) + + @property + def success_response(self): + value = self.value + if not value: + return "Greeting messages are disabled." + elif value == self.DMCHANNEL: + return "Greeting messages will be sent via direct message." + else: + return "Greeting messages will be posted in {}".format(self.formatted) + + +@GuildSettings.attach_setting +class greeting_message(stypes.Message, GuildSetting): + category = "New Members" + + attr_name = 'greeting_message' + _data_column = 'greeting_message' + + display_name = 'greeting_message' + desc = "Greeting message sent to welcome new members." + + long_desc = ( + "Message to send to the configured `greeting_channel` when a member joins the server for the first time." + ) + + _default = r""" + { + "embed": { + "title": "Welcome!", + "thumbnail": {"url": "{guild_icon}"}, + "description": "Hi {mention}!\nWelcome to **{guild_name}**! You are the **{member_count}**th member.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!", + "color": 15695665 + } + } + """ + + _substitution_desc = { + '{mention}': "Mention the new member.", + '{user_name}': "Username of the new member.", + '{user_avatar}': "Avatar of the new member.", + '{guild_name}': "Name of this server.", + '{guild_icon}': "Server icon url.", + '{member_count}': "Number of members in the server.", + '{studying_count}': "Number of current voice channel members.", + } + + def substitution_keys(self, ctx, **kwargs): + return { + '{mention}': ctx.author.mention, + '{user_name}': ctx.author.name, + '{user_avatar}': str(ctx.author.avatar_url), + '{guild_name}': ctx.guild.name, + '{guild_icon}': str(ctx.guild.icon_url), + '{member_count}': str(len(ctx.guild.members)), + '{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members])) + } + + @property + def success_response(self): + return "The greeting message has been set!" + + +@GuildSettings.attach_setting +class returning_message(stypes.Message, GuildSetting): + category = "New Members" + + attr_name = 'returning_message' + _data_column = 'returning_message' + + display_name = 'returning_message' + desc = "Greeting message sent to returning members." + + long_desc = ( + "Message to send to the configured `greeting_channel` when a member returns to the server." + ) + + _default = r""" + { + "embed": { + "title": "Welcome Back {user_name}!", + "thumbnail": {"url": "{guild_icon}"}, + "description": "Welcome back to **{guild_name}**!\nYou last studied with us .\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!", + "color": 15695665 + } + } + """ + + _substitution_desc = { + '{mention}': "Mention the returning member.", + '{user_name}': "Username of the member.", + '{user_avatar}': "Avatar of the member.", + '{guild_name}': "Name of this server.", + '{guild_icon}': "Server icon url.", + '{member_count}': "Number of members in the server.", + '{studying_count}': "Number of current voice channel members.", + '{last_time}': "Unix timestamp of the last time the member studied.", + } + + def substitution_keys(self, ctx, **kwargs): + return { + '{mention}': ctx.author.mention, + '{user_name}': ctx.author.name, + '{user_avatar}': str(ctx.author.avatar_url), + '{guild_name}': ctx.guild.name, + '{guild_icon}': str(ctx.guild.icon_url), + '{member_count}': str(len(ctx.guild.members)), + '{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members])), + '{last_time}': int(ctx.alion.data._timestamp.replace(tzinfo=datetime.timezone.utc).timestamp()), + } + + @property + def success_response(self): + return "The returning message has been set!" + + +@GuildSettings.attach_setting +class starting_funds(stypes.Integer, GuildSetting): + category = "New Members" + + attr_name = 'starting_funds' + _data_column = 'starting_funds' + + display_name = 'starting_funds' + desc = "Coins given when a user first joins." + + long_desc = ( + "Members will be given this number of coins the first time they join the server." + ) + + _default = 0 + + @property + def success_response(self): + return "Members will be given `{}` coins when they first join the server.".format(self.formatted) + + +@GuildSettings.attach_setting +class autoroles(stypes.RoleList, settings.ListData, settings.Setting): + category = "New Members" + write_ward = guild_admin + + attr_name = 'autoroles' + + _table_interface = autoroles + _id_column = 'guildid' + _data_column = 'roleid' + + display_name = "autoroles" + desc = "Roles to give automatically to new members." + + _force_unique = True + + long_desc = ( + "These roles will be given automatically to users when they join the server. " + "If `role_persistence` is enabled, the roles will only be given the first time a user joins the server." + ) + + # Flat cache, no need to expire + _cache = {} + + @property + def success_response(self): + if self.value: + return "New members will be given the following roles:\n{}".format(self.formatted) + else: + return "New members will not automatically be given any roles." + + +@GuildSettings.attach_setting +class bot_autoroles(stypes.RoleList, settings.ListData, settings.Setting): + category = "New Members" + write_ward = guild_admin + + attr_name = 'bot_autoroles' + + _table_interface = bot_autoroles + _id_column = 'guildid' + _data_column = 'roleid' + + display_name = "bot_autoroles" + desc = "Roles to give automatically to new bots." + + _force_unique = True + + long_desc = ( + "These roles will be given automatically to bots when they join the server. " + "If `role_persistence` is enabled, the roles will only be given the first time a bot joins the server." + ) + + # Flat cache, no need to expire + _cache = {} + + @property + def success_response(self): + if self.value: + return "New bots will be given the following roles:\n{}".format(self.formatted) + else: + return "New bots will not automatically be given any roles." + + +@GuildSettings.attach_setting +class role_persistence(stypes.Boolean, GuildSetting): + category = "New Members" + + attr_name = "role_persistence" + + _data_column = 'persist_roles' + + display_name = "role_persistence" + desc = "Whether to remember member roles when they leave the server." + _outputs = {True: "Enabled", False: "Disabled"} + _default = True + + long_desc = ( + "When enabled, restores member roles when they rejoin the server.\n" + "This enables profile roles and purchased roles, such as field of study and colour roles, " + "as well as moderation roles, " + "such as the studyban and mute roles, to persist even when a member leaves and rejoins.\n" + "Note: Members who leave while this is disabled will not have their roles restored." + ) + + @property + def success_response(self): + if self.value: + return "Roles will now be restored when a member rejoins." + else: + return "Member roles will no longer be saved or restored." diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index b6b92765..2c7bb899 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -40,15 +40,15 @@ bot_admin_group_order = ( # Help embed format # TODO: Add config fields for this -title = "LionBot Command List" +title = "StudyLion Command List" header = """ -Use `{ctx.best_prefix}help ` (e.g. `{ctx.best_prefix}help send`) to see how to use each command. +Use `{ctx.best_prefix}help ` (e.g. `{ctx.best_prefix}help send`) to see how to use each command. """ @module.cmd("help", group="Meta", - desc="LionBot command list.") + desc="StudyLion command list.") async def cmd_help(ctx): """ Usage``: diff --git a/bot/modules/moderation/commands.py b/bot/modules/moderation/commands.py index af70e532..a6dc150f 100644 --- a/bot/modules/moderation/commands.py +++ b/bot/modules/moderation/commands.py @@ -120,9 +120,9 @@ async def cmd_tickets(ctx, flags): if not tickets: if filters: - return ctx.embed_reply("There are no tickets with these criteria!") + return await ctx.embed_reply("There are no tickets with these criteria!") else: - return ctx.embed_reply("There are no moderation tickets in this server!") + return await ctx.embed_reply("There are no moderation tickets in this server!") tickets = sorted(tickets, key=lambda ticket: ticket.data.guild_ticketid, reverse=True) ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets} diff --git a/bot/modules/renting/commands.py b/bot/modules/renting/commands.py index 72b5c8f3..ccaaece2 100644 --- a/bot/modules/renting/commands.py +++ b/bot/modules/renting/commands.py @@ -7,7 +7,7 @@ from .rooms import Room @module.cmd( name="rent", - desc="Rent a private study room!", + desc="Rent a private study room with your friends!", group="Productivity", aliases=('add',) ) diff --git a/bot/modules/renting/rooms.py b/bot/modules/renting/rooms.py index 9f0e7b54..a4c7ce47 100644 --- a/bot/modules/renting/rooms.py +++ b/bot/modules/renting/rooms.py @@ -243,7 +243,7 @@ class Room: guild_settings.event_log.log( title="Failed to update study room permissions!", description=("An error occured while removing the " - "following members from the private room {}.\n{}").format( + "following members from the private room {}.\n{}").format( self.channel.mention, ', '.join(member.mention for member in members) ), @@ -282,3 +282,38 @@ async def load_rented_rooms(client): "Loaded {} private study channels.".format(len(rows)), context="LOAD_RENTED_ROOMS" ) + + +@client.add_after_event('member_join') +async def restore_room_permission(client, member): + """ + If a member has, or is part of, a private room when they rejoin, restore their permissions. + """ + # First check whether they own a room + owned = Room.fetch(member.guild.id, member.id) + if owned and owned.channel: + # Restore their room permissions + try: + await owned.channel.set_permissions( + member, + overwrite=Room.owner_overwrite + ) + except discord.HTTPException: + pass + + # Then check if they are in any other rooms + in_room_rows = rented_members.select_where( + _extra="LEFT JOIN rented USING (channelid) WHERE userid={} AND guildid={}".format( + member.id, member.guild.id + ) + ) + for row in in_room_rows: + room = Room.fetch(member.guild.id, row['ownerid']) + if room and row['ownerid'] != member.id and room.channel: + try: + await room.channel.set_permissions( + member, + overwrite=Room.member_overwrite + ) + except discord.HTTPException: + pass diff --git a/bot/modules/study/admin.py b/bot/modules/study/admin.py index 6a1a9b6b..5861ab95 100644 --- a/bot/modules/study/admin.py +++ b/bot/modules/study/admin.py @@ -1,3 +1,5 @@ +from collections import defaultdict + import settings from settings import GuildSettings from wards import guild_admin @@ -37,6 +39,34 @@ class untracked_channels(settings.ChannelList, settings.ListData, settings.Setti else: return "Study time will now be counted in all channels." + @classmethod + async def launch_task(cls, client): + """ + Launch initialisation step for the `untracked_channels` setting. + + Pre-fill cache for the guilds with currently active voice channels. + """ + active_guildids = [ + guild.id + for guild in client.guilds + if any(channel.members for channel in guild.voice_channels) + ] + if active_guildids: + rows = cls._table_interface.select_where( + guildid=active_guildids + ) + cache = defaultdict(list) + for row in rows: + cache[row['guildid']].append(row['channelid']) + cls._cache.update(cache) + client.log( + "Cached {} untracked channels for {} active guilds.".format( + len(rows), + len(cache) + ), + context="UNTRACKED_CHANNELS" + ) + @GuildSettings.attach_setting class hourly_reward(settings.Integer, settings.GuildSetting): diff --git a/bot/modules/study/time_tracker.py b/bot/modules/study/time_tracker.py index 1bf82b42..26afddd6 100644 --- a/bot/modules/study/time_tracker.py +++ b/bot/modules/study/time_tracker.py @@ -101,6 +101,8 @@ async def _study_tracker(): @module.launch_task async def launch_study_tracker(client): + # First pre-load the untracked channels + await admin.untracked_channels.launch_task(client) asyncio.create_task(_study_tracker()) diff --git a/bot/modules/todo/commands.py b/bot/modules/todo/commands.py index 2b21dc4b..9932c6d4 100644 --- a/bot/modules/todo/commands.py +++ b/bot/modules/todo/commands.py @@ -9,7 +9,7 @@ from .Tasklist import Tasklist @module.cmd( name="todo", - desc="Display and edit your personal TODO list.", + desc="Display and edit your personal To-Do list.", group="Productivity", flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==') ) diff --git a/bot/settings/base.py b/bot/settings/base.py index 8e86654b..c8929d58 100644 --- a/bot/settings/base.py +++ b/bot/settings/base.py @@ -51,6 +51,14 @@ class Setting: embed.description = "{}\n{}".format(self.long_desc.format(self=self, client=self.client), table) return embed + async def widget(self, ctx: Context, **kwargs): + """ + Show the setting widget for this setting. + By default this displays the setting embed. + Settings may override this if they need more complex widget context or logic. + """ + return await ctx.reply(embed=self.embed) + @property def summary(self): """ diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index 8d9b5934..89b1b0a6 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -1,5 +1,8 @@ +import json +import asyncio import datetime import itertools +from io import StringIO from enum import IntEnum from typing import Any, Optional @@ -9,11 +12,14 @@ from cmdClient.Context import Context from cmdClient.lib import SafeCancellation from meta import client -from utils.lib import parse_dur, strfdur, strfdelta +from utils.lib import parse_dur, strfdur, strfdelta, prop_tabulate, multiple_replace from .base import UserInputError +preview_emoji = '🔍' + + class SettingType: """ Abstract class representing a setting type. @@ -687,6 +693,209 @@ class Duration(SettingType): return "`{}`".format(strfdur(data, short=False, show_days=cls._show_days)) +class Message(SettingType): + """ + Message type storing json-encoded message arguments. + Messages without an embed are displayed differently from those with an embed. + + Types: + data: str + A json dictionary with the fields `content` and `embed`. + value: dict + An argument dictionary suitable for `Message.send` or `Message.edit`. + """ + + _substitution_desc = { + } + + @classmethod + def _data_from_value(cls, id, value, **kwargs): + if value is None: + return None + + return json.dumps(value) + + @classmethod + def _data_to_value(cls, id, data, **kwargs): + if data is None: + return None + + return json.loads(data) + + @classmethod + async def parse(cls, id: int, ctx: Context, userstr: str, **kwargs): + """ + Return a setting instance initialised from a parsed user string. + """ + if ctx.msg.attachments: + attachment = ctx.msg.attachments[0] + if 'text' in attachment.content_type or 'json' in attachment.content_type: + userstr = (await attachment.read()).decode() + data = await cls._parse_userstr(ctx, id, userstr, as_json=True, **kwargs) + else: + raise UserInputError("Can't read the attached file!") + else: + data = await cls._parse_userstr(ctx, id, userstr, **kwargs) + return cls(id, data, **kwargs) + + @classmethod + async def _parse_userstr(cls, ctx, id, userstr, as_json=False, **kwargs): + """ + Parse the provided string as either a content-only string, or json-format arguments. + Provided string is not trusted, and is parsed in a safe manner. + """ + if userstr.lower() == 'none': + return None + + if as_json: + try: + args = json.loads(userstr) + except json.JSONDecodeError: + raise UserInputError( + "Couldn't parse your message! " + "You can test and fix it on the embed builder " + "[here](https://glitchii.github.io/embedbuilder/?editor=json)." + ) + if 'embed' in args and 'timestamp' in args['embed']: + args['embed'].pop('timestamp') + return json.dumps(args) + else: + return json.dumps({'content': userstr}) + + @classmethod + def _format_data(cls, id, data, **kwargs): + if data is None: + return "Empty" + value = cls._data_to_value(id, data, **kwargs) + if 'embed' not in value and len(value['content']) < 100: + return "`{}`".format(value['content']) + else: + return "Too long to display here!" + + def substitution_keys(self, ctx, **kwargs): + """ + Instances should override this to provide their own substitution implementation. + """ + return {} + + def args(self, ctx, **kwargs): + """ + Applies the substitutions with the given context to generate the final message args. + """ + value = self.value + substitutions = self.substitution_keys(ctx, **kwargs) + args = {} + if 'content' in value: + args['content'] = multiple_replace(value['content'], substitutions) + if 'embed' in value: + args['embed'] = discord.Embed.from_dict( + json.loads(multiple_replace(json.dumps(value['embed']), substitutions)) + ) + return args + + async def widget(self, ctx, **kwargs): + value = self.value + args = self.args(ctx, **kwargs) + + if not value: + return await ctx.reply(embed=self.embed) + + current_str = None + preview = None + file_content = None + if 'embed' in value or len(value['content']) > 1024: + current_str = "See attached file." + file_content = json.dumps(value, indent=4) + elif "`" in value['content']: + current_str = "```{}```".format(value['content']) + if len(args['content']) < 1000: + preview = args['content'] + else: + current_str = "`{}`".format(value['content']) + if len(args['content']) < 1000: + preview = args['content'] + + description = "{}\n\n**Current Value**: {}".format( + self.long_desc.format(self=self, client=self.client), + current_str + ) + + embed = discord.Embed( + title="Configuration options for `{}`".format(self.display_name), + description=description + ) + if preview: + embed.add_field(name="Message Preview", value=preview, inline=False) + embed.add_field( + name="Setting Guide", + value=( + "• For plain text without an embed, use `{prefix}config {setting} `.\n" + "• To include an embed, build the message [here]({builder}) " + "and upload the json code as a file with the `{prefix}config {setting}` command.\n" + "• To reset the message to the default, use `{prefix}config {setting} None`." + ).format( + prefix=ctx.best_prefix, + setting=self.display_name, + builder="https://glitchii.github.io/embedbuilder/?editor=gui" + ), + inline=False + ) + if self._substitution_desc: + embed.add_field( + name="Substitution Keys", + value=( + "*The following keys will be substituted for their current values.*\n{}" + ).format( + prop_tabulate(*zip(*self._substitution_desc.items()), colon=False) + ), + inline=False + ) + embed.set_footer( + text="React with {} to preview the message.".format(preview_emoji) + ) + if file_content: + with StringIO() as message_file: + message_file.write(file_content) + message_file.seek(0) + out_file = discord.File(message_file, filename="{}.json".format(self.display_name)) + out_msg = await ctx.reply(embed=embed, file=out_file) + else: + out_msg = await ctx.reply(embed=embed) + + # Add the preview reaction and send the preview when requested + try: + await out_msg.add_reaction(preview_emoji) + except discord.HTTPException: + return + + try: + await ctx.client.wait_for( + 'reaction_add', + check=lambda r, u: r.message.id == out_msg.id and r.emoji == preview_emoji and u == ctx.author, + timeout=180 + ) + except asyncio.TimeoutError: + try: + await out_msg.remove_reaction(preview_emoji, ctx.client.user) + except discord.HTTPException: + pass + else: + try: + await ctx.offer_delete( + await ctx.reply(**args, allowed_mentions=discord.AllowedMentions.none()) + ) + except discord.HTTPException as e: + await ctx.reply( + embed=discord.Embed( + colour=discord.Colour.red(), + title="Preview failed! Error below", + description="```{}```".format( + e + ) + ) + ) + + class SettingList(SettingType): """ List of a particular type of setting. diff --git a/bot/utils/lib.py b/bot/utils/lib.py index 684814a3..27ca01f8 100644 --- a/bot/utils/lib.py +++ b/bot/utils/lib.py @@ -17,7 +17,7 @@ tick = '✅' cross = '❌' -def prop_tabulate(prop_list, value_list, indent=True): +def prop_tabulate(prop_list, value_list, indent=True, colon=True): """ Turns a list of properties and corresponding list of values into a pretty string with one `prop: value` pair each line, @@ -39,7 +39,7 @@ def prop_tabulate(prop_list, value_list, indent=True): max_len = max(len(prop) for prop in prop_list) return "".join(["`{}{}{}`\t{}{}".format("​ " * (max_len - len(prop)) if indent else "", prop, - ":" if len(prop) else "​ " * 2, + (":" if len(prop) else "​ " * 2) if colon else '', value_list[i], '' if str(value_list[i]).endswith("```") else '\n') for i, prop in enumerate(prop_list)]) @@ -540,3 +540,14 @@ def utc_now(): Return the current timezone-aware utc timestamp. """ return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + + +def multiple_replace(string, rep_dict): + if rep_dict: + pattern = re.compile( + "|".join([re.escape(k) for k in sorted(rep_dict, key=len, reverse=True)]), + flags=re.DOTALL + ) + return pattern.sub(lambda x: str(rep_dict[x.group(0)]), string) + else: + return string diff --git a/data/migration/v3-v4/migration.sql b/data/migration/v3-v4/migration.sql new file mode 100644 index 00000000..260d8b2d --- /dev/null +++ b/data/migration/v3-v4/migration.sql @@ -0,0 +1,31 @@ +ALTER TABLE guild_config + ADD COLUMN greeting_channel BIGINT, + ADD COLUMN greeting_message TEXT, + ADD COLUMN returning_message TEXT, + ADD COLUMN starting_funds INTEGER, + ADD COLUMN persist_roles BOOLEAN; + +CREATE INDEX rented_members_users ON rented_members (userid); + +CREATE TABLE autoroles( + guildid BIGINT NOT NULL , + roleid BIGINT NOT NULL +); +CREATE INDEX autoroles_guilds ON autoroles (guildid); + +CREATE TABLE bot_autoroles( + guildid BIGINT NOT NULL , + roleid BIGINT NOT NULL +); +CREATE INDEX bot_autoroles_guilds ON bot_autoroles (guildid); + +CREATE TABLE past_member_roles( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + roleid BIGINT NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); +CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid); + +INSERT INTO VersionHistory (version, author) VALUES (4, 'v3-v4 Migration'); diff --git a/data/schema.sql b/data/schema.sql index c1ff07a4..b9e704a5 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 (3, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (4, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -72,7 +72,12 @@ CREATE TABLE guild_config( accountability_reward INTEGER, accountability_price INTEGER, video_studyban BOOLEAN, - video_grace_period INTEGER + video_grace_period INTEGER, + greeting_channel BIGINT, + greeting_message TEXT, + returning_message TEXT, + starting_funds INTEGER, + persist_roles BOOLEAN ); CREATE TABLE ignored_members( @@ -92,6 +97,18 @@ CREATE TABLE donator_roles( roleid BIGINT NOT NULL ); CREATE INDEX donator_roles_guilds ON donator_roles (guildid); + +CREATE TABLE autoroles( + guildid BIGINT NOT NULL, + roleid BIGINT NOT NULL +); +CREATE INDEX autoroles_guilds ON autoroles (guildid); + +CREATE TABLE bot_autoroles( + guildid BIGINT NOT NULL , + roleid BIGINT NOT NULL +); +CREATE INDEX bot_autoroles_guilds ON bot_autoroles (guildid); -- }}} -- Workout data {{{ @@ -434,6 +451,7 @@ CREATE TABLE rented_members( userid BIGINT NOT NULL ); CREATE INDEX rented_members_channels ON rented_members (channelid); +CREATE INDEX rented_members_users ON rented_members (userid); -- }}} -- Accountability Rooms {{{ @@ -510,5 +528,14 @@ CREATE TABLE reaction_role_expiring( ); CREATE UNIQUE INDEX reaction_role_expiry_members ON reaction_role_expiring (guildid, userid, roleid); +-- Member Role Data {{{ +CREATE TABLE past_member_roles( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + roleid BIGINT NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); +CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid); -- }}} -- vim: set fdm=marker: