diff --git a/src/modules/pending-rewrite/__init__.py b/src/modules/pending-rewrite/__init__.py deleted file mode 100644 index ee4a7bb4..00000000 --- a/src/modules/pending-rewrite/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from .sysadmin import * -from .guild_admin import * -from .meta import * -from .economy import * -from .study import * -from .stats import * -from .user_config import * -from .workout import * -from .todo import * -from .topgg import * -from .reminders import * -from .renting import * -from .moderation import * -from .accountability import * -from .plugins import * -from .sponsors import * diff --git a/src/modules/pending-rewrite/accountability/TimeSlot.py b/src/modules/pending-rewrite/accountability/TimeSlot.py deleted file mode 100644 index dcb87e14..00000000 --- a/src/modules/pending-rewrite/accountability/TimeSlot.py +++ /dev/null @@ -1,477 +0,0 @@ -from typing import List, Dict -import datetime -import discord -import asyncio -import random - -from settings import GuildSettings -from utils.lib import tick, cross -from core import Lion -from meta import client - -from .lib import utc_now -from .data import accountability_members, accountability_rooms - - -class SlotMember: - """ - Class representing a member booked into an accountability room. - Mostly acts as an interface to the corresponding TableRow. - But also stores the discord.Member associated, and has several computed properties. - The member may be None. - """ - ___slots__ = ('slotid', 'userid', 'guild') - - def __init__(self, slotid, userid, guild): - self.slotid = slotid - self.userid = userid - self.guild = guild - - self._member = None - - @property - def key(self): - return (self.slotid, self.userid) - - @property - def data(self): - return accountability_members.fetch(self.key) - - @property - def member(self): - return self.guild.get_member(self.userid) - - @property - def has_attended(self): - return self.data.duration > 0 or self.data.last_joined_at - - -class TimeSlot: - """ - Class representing an accountability slot. - """ - __slots__ = ( - 'guild', - 'start_time', - 'data', - 'lobby', - 'category', - 'channel', - 'message', - 'members' - ) - - slots = {} - - _member_overwrite = discord.PermissionOverwrite( - view_channel=True, - connect=True - ) - - _everyone_overwrite = discord.PermissionOverwrite( - view_channel=False, - connect=False, - speak=False - ) - - happy_lion = "https://media.discordapp.net/stickers/898266283559227422.png" - sad_lion = "https://media.discordapp.net/stickers/898266548421148723.png" - - def __init__(self, guild, start_time, data=None): - self.guild: discord.Guild = guild - self.start_time: datetime.datetime = start_time - self.data = data - - self.lobby: discord.TextChannel = None # Text channel to post the slot status - self.category: discord.CategoryChannel = None # Category to create the voice rooms in - self.channel: discord.VoiceChannel = None # Text channel associated with this time slot - self.message: discord.Message = None # Status message in lobby channel - - self.members: Dict[int, SlotMember] = {} # memberid -> SlotMember - - @property - def open_embed(self): - timestamp = int(self.start_time.timestamp()) - - embed = discord.Embed( - title="Session - ".format( - timestamp, timestamp + 3600 - ), - colour=discord.Colour.orange(), - timestamp=self.start_time - ).set_footer( - text="About to start!\nJoin the session with {}schedule book".format(client.prefix) - ) - - if self.members: - embed.description = "Starting .".format(timestamp) - embed.add_field( - name="Members", - value=( - ', '.join('<@{}>'.format(key) for key in self.members.keys()) - ) - ) - else: - embed.description = "No members scheduled this session!" - - return embed - - @property - def status_embed(self): - timestamp = int(self.start_time.timestamp()) - embed = discord.Embed( - title="Session - ".format( - timestamp, timestamp + 3600 - ), - description="Finishing .".format(timestamp + 3600), - colour=discord.Colour.orange(), - timestamp=self.start_time - ).set_footer(text="Join the next session using {}schedule book".format(client.prefix)) - - if self.members: - classifications = { - "Attended": [], - "Studying Now": [], - "Waiting for": [] - } - for memid, mem in self.members.items(): - mention = '<@{}>'.format(memid) - if not mem.has_attended: - classifications["Waiting for"].append(mention) - elif mem.member in self.channel.members: - classifications["Studying Now"].append(mention) - else: - classifications["Attended"].append(mention) - - all_attended = all(mem.has_attended for mem in self.members.values()) - bonus_line = ( - "{tick} Everyone attended, and will get a `{bonus} LC` bonus!".format( - tick=tick, - bonus=GuildSettings(self.guild.id).accountability_bonus.value - ) - if all_attended else "" - ) - if all_attended: - embed.set_thumbnail(url=self.happy_lion) - - embed.description += "\n" + bonus_line - for field, value in classifications.items(): - if value: - embed.add_field(name=field, value='\n'.join(value)) - else: - embed.description = "No members scheduled this session!" - - return embed - - @property - def summary_embed(self): - timestamp = int(self.start_time.timestamp()) - embed = discord.Embed( - title="Session - ".format( - timestamp, timestamp + 3600 - ), - description="Finished .".format(timestamp + 3600), - colour=discord.Colour.orange(), - timestamp=self.start_time - ).set_footer(text="Completed!") - - if self.members: - classifications = { - "Attended": [], - "Missing": [] - } - for memid, mem in sorted(self.members.items(), key=lambda mem: mem[1].data.duration, reverse=True): - mention = '<@{}>'.format(memid) - if mem.has_attended: - classifications["Attended"].append( - "{} ({}%)".format(mention, (mem.data.duration * 100) // 3600) - ) - else: - classifications["Missing"].append(mention) - - all_attended = all(mem.has_attended for mem in self.members.values()) - - bonus_line = ( - "{tick} Everyone attended, and received a `{bonus} LC` bonus!".format( - tick=tick, - bonus=GuildSettings(self.guild.id).accountability_bonus.value - ) - if all_attended else - "{cross} Some members missed the session, so everyone missed out on the bonus!".format( - cross=cross - ) - ) - if all_attended: - embed.set_thumbnail(url=self.happy_lion) - else: - embed.set_thumbnail(url=self.sad_lion) - - embed.description += "\n" + bonus_line - for field, value in classifications.items(): - if value: - embed.add_field(name=field, value='\n'.join(value)) - else: - embed.description = "No members scheduled this session!" - - return embed - - def load(self, memberids: List[int] = None): - """ - Load data and update applicable caches. - """ - if not self.guild: - return self - - # Load setting data - self.category = GuildSettings(self.guild.id).accountability_category.value - self.lobby = GuildSettings(self.guild.id).accountability_lobby.value - - if self.data: - # Load channel - if self.data.channelid: - self.channel = self.guild.get_channel(self.data.channelid) - - # Load message - if self.data.messageid and self.lobby: - self.message = discord.PartialMessage( - channel=self.lobby, - id=self.data.messageid - ) - - # Load members - if memberids: - self.members = { - memberid: SlotMember(self.data.slotid, memberid, self.guild) - for memberid in memberids - } - - return self - - async def _reload_members(self, memberids=None): - """ - Reload the timeslot members from the provided list, or data. - Also updates the channel overwrites if required. - To be used before the session has started. - """ - if self.data: - if memberids is None: - member_rows = accountability_members.fetch_rows_where(slotid=self.data.slotid) - memberids = [row.userid for row in member_rows] - - self.members = members = { - memberid: SlotMember(self.data.slotid, memberid, self.guild) - for memberid in memberids - } - - if self.channel: - # Check and potentially update overwrites - current_overwrites = self.channel.overwrites - overwrites = { - mem.member: self._member_overwrite - for mem in members.values() - if mem.member - } - overwrites[self.guild.default_role] = self._everyone_overwrite - if current_overwrites != overwrites: - await self.channel.edit(overwrites=overwrites) - - def _refresh(self): - """ - Refresh the stored data row and reload. - """ - rows = accountability_rooms.fetch_rows_where( - guildid=self.guild.id, - start_at=self.start_time - ) - self.data = rows[0] if rows else None - - memberids = [] - if self.data: - member_rows = accountability_members.fetch_rows_where( - slotid=self.data.slotid - ) - memberids = [row.userid for row in member_rows] - self.load(memberids=memberids) - - async def open(self): - """ - Open the accountability room. - Creates a new voice channel, and sends the status message. - Event logs any issues. - Adds the TimeSlot to cache. - Returns the (channelid, messageid). - """ - # Cleanup any non-existent members - for memid, mem in list(self.members.items()): - if not mem.data or not mem.member: - self.members.pop(memid) - - # Calculate overwrites - overwrites = { - mem.member: self._member_overwrite - for mem in self.members.values() - } - overwrites[self.guild.default_role] = self._everyone_overwrite - - # Create the channel. Log and bail if something went wrong. - if self.data and not self.channel: - try: - self.channel = await self.guild.create_voice_channel( - "Upcoming Scheduled Session", - overwrites=overwrites, - category=self.category - ) - except discord.HTTPException: - GuildSettings(self.guild.id).event_log.log( - "Failed to create the scheduled session voice channel. Skipping this session.", - colour=discord.Colour.red() - ) - return None - elif not self.data: - self.channel = None - - # Send the inital status message. Log and bail if something goes wrong. - if not self.message: - try: - self.message = await self.lobby.send( - embed=self.open_embed - ) - except discord.HTTPException: - GuildSettings(self.guild.id).event_log.log( - "Failed to post the status message in the scheduled session lobby {}.\n" - "Skipping this session.".format(self.lobby.mention), - colour=discord.Colour.red() - ) - return None - if self.members: - await self.channel_notify() - return (self.channel.id if self.channel else None, self.message.id) - - async def channel_notify(self, content=None): - """ - Ghost pings the session members in the lobby channel. - """ - if self.members: - content = content or "Your scheduled session has started! Please join!" - out = "{}\n\n{}".format( - content, - ' '.join('<@{}>'.format(memid) for memid, mem in self.members.items() if not mem.has_attended) - ) - out_msg = await self.lobby.send(out) - await out_msg.delete() - - async def start(self): - """ - Start the accountability room slot. - Update the status message, and launch the DM reminder. - """ - dither = 15 * random.random() - await asyncio.sleep(dither) - if self.channel: - try: - await self.channel.edit(name="Scheduled Session Room") - await self.channel.set_permissions(self.guild.default_role, view_channel=True, connect=False) - except discord.HTTPException: - pass - asyncio.create_task(self.dm_reminder(delay=60)) - try: - await self.message.edit(embed=self.status_embed) - except discord.NotFound: - try: - self.message = await self.lobby.send( - embed=self.status_embed - ) - except discord.HTTPException: - self.message = None - - async def dm_reminder(self, delay=60): - """ - Notifies missing members with a direct message after 1 minute. - """ - await asyncio.sleep(delay) - - embed = discord.Embed( - title="The scheduled session you booked has started!", - description="Please join {}.".format(self.channel.mention), - colour=discord.Colour.orange() - ).set_footer( - text=self.guild.name, - icon_url=self.guild.icon_url - ) - - members = (mem.member for mem in self.members.values() if not mem.has_attended) - members = (member for member in members if member) - await asyncio.gather( - *(member.send(embed=embed) for member in members), - return_exceptions=True - ) - - async def close(self): - """ - Delete the channel and update the status message to display a session summary. - Unloads the TimeSlot from cache. - """ - dither = 15 * random.random() - await asyncio.sleep(dither) - if self.channel: - try: - await self.channel.delete() - except discord.HTTPException: - pass - if self.message: - try: - await self.message.edit(embed=self.summary_embed) - except discord.HTTPException: - pass - - # Reward members appropriately - if self.guild: - guild_settings = GuildSettings(self.guild.id) - reward = guild_settings.accountability_reward.value - if all(mem.has_attended for mem in self.members.values()): - reward += guild_settings.accountability_bonus.value - - for memid in self.members: - Lion.fetch(self.guild.id, memid).addCoins(reward, bonus=True) - - async def cancel(self): - """ - Cancel the slot, generally due to missing data. - Updates the message and channel if possible, removes slot from cache, and also updates data. - # TODO: Refund members - """ - if self.data: - self.data.closed_at = utc_now() - - if self.channel: - try: - await self.channel.delete() - self.channel = None - except discord.HTTPException: - pass - - if self.message: - try: - timestamp = int(self.start_time.timestamp()) - embed = discord.Embed( - title="Session - ".format( - timestamp, timestamp + 3600 - ), - description="Session canceled!", - colour=discord.Colour.red() - ) - await self.message.edit(embed=embed) - except discord.HTTPException: - pass - - async def update_status(self): - """ - Intelligently update the status message. - """ - if self.message: - if utc_now() < self.start_time: - await self.message.edit(embed=self.open_embed) - elif utc_now() < self.start_time + datetime.timedelta(hours=1): - await self.message.edit(embed=self.status_embed) - else: - await self.message.edit(embed=self.summary_embed) diff --git a/src/modules/pending-rewrite/accountability/__init__.py b/src/modules/pending-rewrite/accountability/__init__.py deleted file mode 100644 index 79fd7162..00000000 --- a/src/modules/pending-rewrite/accountability/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .module import module - -from . import data -from . import admin -from . import commands -from . import tracker diff --git a/src/modules/pending-rewrite/accountability/admin.py b/src/modules/pending-rewrite/accountability/admin.py deleted file mode 100644 index 2e26a42f..00000000 --- a/src/modules/pending-rewrite/accountability/admin.py +++ /dev/null @@ -1,140 +0,0 @@ -import asyncio -import discord - -import settings -from settings import GuildSettings, GuildSetting - -from .tracker import AccountabilityGuild as AG - - -@GuildSettings.attach_setting -class accountability_category(settings.Channel, settings.GuildSetting): - category = "Scheduled Sessions" - - attr_name = "accountability_category" - _data_column = "accountability_category" - - display_name = "session_category" - desc = "Category in which to make the scheduled session rooms." - - _default = None - - long_desc = ( - "\"Schedule session\" category channel.\n" - "Scheduled sessions will be held in voice channels created under this category." - ) - _accepts = "A category channel." - - _chan_type = discord.ChannelType.category - - @property - def success_response(self): - if self.value: - # TODO Move this somewhere better - if self.id not in AG.cache: - AG(self.id) - return "The session category has been changed to **{}**.".format(self.value.name) - else: - return "The scheduled session system has been started in **{}**.".format(self.value.name) - else: - if self.id in AG.cache: - aguild = AG.cache.pop(self.id) - if aguild.current_slot: - asyncio.create_task(aguild.current_slot.cancel()) - if aguild.upcoming_slot: - asyncio.create_task(aguild.upcoming_slot.cancel()) - return "The scheduled session system has been shut down." - else: - return "The scheduled session category has been unset." - - -@GuildSettings.attach_setting -class accountability_lobby(settings.Channel, settings.GuildSetting): - category = "Scheduled Sessions" - - attr_name = "accountability_lobby" - _data_column = attr_name - - display_name = "session_lobby" - desc = "Category in which to post scheduled session notifications updates." - - _default = None - - long_desc = ( - "Scheduled session updates will be posted here, and members will be notified in this channel.\n" - "The channel will be automatically created in the configured `session_category` if it does not exist.\n" - "Members do not need to be able to write in the channel." - ) - _accepts = "Any text channel." - - _chan_type = discord.ChannelType.text - - async def auto_create(self): - # TODO: FUTURE - ... - - -@GuildSettings.attach_setting -class accountability_price(settings.Integer, GuildSetting): - category = "Scheduled Sessions" - - attr_name = "accountability_price" - _data_column = attr_name - - display_name = "session_price" - desc = "Cost of booking a scheduled session." - - _default = 500 - - long_desc = ( - "The price of booking each one hour scheduled session slot." - ) - _accepts = "An integer number of coins." - - @property - def success_response(self): - return "Scheduled session slots now cost `{}` coins.".format(self.value) - - -@GuildSettings.attach_setting -class accountability_bonus(settings.Integer, GuildSetting): - category = "Scheduled Sessions" - - attr_name = "accountability_bonus" - _data_column = attr_name - - display_name = "session_bonus" - desc = "Bonus given when everyone attends a scheduled session slot." - - _default = 750 - - long_desc = ( - "The extra bonus given to each scheduled session member when everyone who booked attended the session." - ) - _accepts = "An integer number of coins." - - @property - def success_response(self): - return "Scheduled session members will now get `{}` coins if everyone joins.".format(self.value) - - -@GuildSettings.attach_setting -class accountability_reward(settings.Integer, GuildSetting): - category = "Scheduled Sessions" - - attr_name = "accountability_reward" - _data_column = attr_name - - display_name = "session_reward" - desc = "The individual reward given when a member attends their booked scheduled session." - - _default = 500 - - long_desc = ( - "Reward given to a member who attends a booked scheduled session." - ) - _accepts = "An integer number of coins." - - @property - def success_response(self): - return "Members will now get `{}` coins when they attend their scheduled session.".format(self.value) diff --git a/src/modules/pending-rewrite/accountability/commands.py b/src/modules/pending-rewrite/accountability/commands.py deleted file mode 100644 index ae0afc69..00000000 --- a/src/modules/pending-rewrite/accountability/commands.py +++ /dev/null @@ -1,596 +0,0 @@ -import re -import datetime -import discord -import asyncio -import contextlib -from cmdClient.checks import in_guild - -from meta import client -from utils.lib import multiselect_regex, parse_ranges, prop_tabulate -from data import NOTNULL -from data.conditions import GEQ, LEQ - -from .module import module -from .lib import utc_now -from .tracker import AccountabilityGuild as AGuild -from .tracker import room_lock -from .TimeSlot import SlotMember -from .data import accountability_members, accountability_member_info, accountability_rooms - - -hint_icon = "https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png" - - -def time_format(time): - diff = (time - utc_now()).total_seconds() - if diff < 0: - diffstr = "`Right Now!!`" - elif diff < 600: - diffstr = "`Very soon!!`" - elif diff < 3600: - diffstr = "`In <1 hour `" - else: - hours = round(diff / 3600) - diffstr = "`In {:>2} hour{}`".format(hours, 's' if hours > 1 else ' ') - - return "{} | - ".format( - diffstr, - time.timestamp(), - time.timestamp() + 3600, - ) - - -user_locks = {} # Map userid -> ctx - - -@contextlib.contextmanager -def ensure_exclusive(ctx): - """ - Cancel any existing exclusive contexts for the author. - """ - old_ctx = user_locks.pop(ctx.author.id, None) - if old_ctx: - [task.cancel() for task in old_ctx.tasks] - - user_locks[ctx.author.id] = ctx - try: - yield - finally: - new_ctx = user_locks.get(ctx.author.id, None) - if new_ctx and new_ctx.msg.id == ctx.msg.id: - user_locks.pop(ctx.author.id) - - -@module.cmd( - name="schedule", - desc="View your schedule, and get rewarded for attending scheduled sessions!", - group="Productivity", - aliases=('rooms', 'sessions') -) -@in_guild() -async def cmd_rooms(ctx): - """ - Usage``: - {prefix}schedule - {prefix}schedule book - {prefix}schedule cancel - Description: - View your schedule with `{prefix}schedule`. - Use `{prefix}schedule book` to schedule a session at a selected time.. - Use `{prefix}schedule cancel` to cancel a scheduled session. - """ - lower = ctx.args.lower() - splits = lower.split() - command = splits[0] if splits else None - - if not ctx.guild_settings.accountability_category.value: - return await ctx.error_reply("The scheduled session system isn't set up!") - - # First grab the sessions the member is booked in - joined_rows = accountability_member_info.select_where( - userid=ctx.author.id, - start_at=GEQ(utc_now()), - _extra="ORDER BY start_at ASC" - ) - - if command == 'cancel': - if not joined_rows: - return await ctx.error_reply("You have no scheduled sessions to cancel!") - - # Show unbooking menu - lines = [ - "`[{:>2}]` | {}".format(i, time_format(row['start_at'])) - for i, row in enumerate(joined_rows) - ] - out_msg = await ctx.reply( - content="Please reply with the number(s) of the sessions you want to cancel. E.g. `1, 3, 5` or `1-3, 7-8`.", - embed=discord.Embed( - title="Please choose the sessions you want to cancel.", - description='\n'.join(lines), - colour=discord.Colour.orange() - ).set_footer( - text=( - "All times are in your own timezone! Hover over a time to see the date." - ) - ) - ) - - await ctx.cancellable( - out_msg, - cancel_message="Cancel menu closed, no scheduled sessions were cancelled.", - timeout=70 - ) - - def check(msg): - valid = msg.channel == ctx.ch and msg.author == ctx.author - valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c') - return valid - - with ensure_exclusive(ctx): - try: - message = await ctx.client.wait_for('message', check=check, timeout=60) - except asyncio.TimeoutError: - try: - await out_msg.edit( - content=None, - embed=discord.Embed( - description="Cancel menu timed out, no scheduled sessions were cancelled.", - colour=discord.Colour.red() - ) - ) - await out_msg.clear_reactions() - except discord.HTTPException: - pass - return - - try: - await out_msg.delete() - await message.delete() - except discord.HTTPException: - pass - - if message.content.lower() == 'c': - return - - to_cancel = [ - joined_rows[index] - for index in parse_ranges(message.content) if index < len(joined_rows) - ] - if not to_cancel: - return await ctx.error_reply("No valid sessions selected for cancellation.") - elif any(row['start_at'] < utc_now() for row in to_cancel): - return await ctx.error_reply("You can't cancel a running session!") - - slotids = [row['slotid'] for row in to_cancel] - async with room_lock: - deleted = accountability_members.delete_where( - userid=ctx.author.id, - slotid=slotids - ) - - # Handle case where the slot has already opened - # TODO: Possible race condition if they open over the hour border? Might never cancel - for row in to_cancel: - aguild = AGuild.cache.get(row['guildid'], None) - if aguild and aguild.upcoming_slot and aguild.upcoming_slot.data: - if aguild.upcoming_slot.data.slotid in slotids: - aguild.upcoming_slot.members.pop(ctx.author.id, None) - if aguild.upcoming_slot.channel: - try: - await aguild.upcoming_slot.channel.set_permissions( - ctx.author, - overwrite=None - ) - except discord.HTTPException: - pass - await aguild.upcoming_slot.update_status() - break - - ctx.alion.addCoins(sum(row[2] for row in deleted)) - - remaining = [row for row in joined_rows if row['slotid'] not in slotids] - if not remaining: - await ctx.embed_reply("Cancelled all your upcoming scheduled sessions!") - else: - next_booked_time = min(row['start_at'] for row in remaining) - if len(to_cancel) > 1: - await ctx.embed_reply( - "Cancelled `{}` upcoming sessions!\nYour next session is at .".format( - len(to_cancel), - next_booked_time.timestamp() - ) - ) - else: - await ctx.embed_reply( - "Cancelled your session at !\n" - "Your next session is at .".format( - to_cancel[0]['start_at'].timestamp(), - next_booked_time.timestamp() - ) - ) - elif command == 'book': - # Show booking menu - # Get attendee count - rows = accountability_member_info.select_where( - guildid=ctx.guild.id, - userid=NOTNULL, - select_columns=( - 'slotid', - 'start_at', - 'COUNT(*) as num' - ), - _extra="GROUP BY start_at, slotid" - ) - attendees = {row['start_at']: row['num'] for row in rows} - attendee_pad = max((len(str(num)) for num in attendees.values()), default=1) - - # Build lines - already_joined_times = set(row['start_at'] for row in joined_rows) - start_time = utc_now().replace(minute=0, second=0, microsecond=0) - times = ( - start_time + datetime.timedelta(hours=n) - for n in range(1, 25) - ) - times = [ - time for time in times - if time not in already_joined_times and (time - utc_now()).total_seconds() > 660 - ] - lines = [ - "`[{num:>2}]` | `{count:>{count_pad}}` attending | {time}".format( - num=i, - count=attendees.get(time, 0), count_pad=attendee_pad, - time=time_format(time), - ) - for i, time in enumerate(times) - ] - # TODO: Nicer embed - # TODO: Don't allow multi bookings if the member has a bad attendance rate - out_msg = await ctx.reply( - content=( - "Please reply with the number(s) of the sessions you want to book. E.g. `1, 3, 5` or `1-3, 7-8`." - ), - embed=discord.Embed( - title="Please choose the sessions you want to schedule.", - description='\n'.join(lines), - colour=discord.Colour.orange() - ).set_footer( - text=( - "All times are in your own timezone! Hover over a time to see the date." - ) - ) - ) - await ctx.cancellable( - out_msg, - cancel_message="Booking menu cancelled, no sessions were booked.", - timeout=60 - ) - - def check(msg): - valid = msg.channel == ctx.ch and msg.author == ctx.author - valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c') - return valid - - with ensure_exclusive(ctx): - try: - message = await ctx.client.wait_for('message', check=check, timeout=30) - except asyncio.TimeoutError: - try: - await out_msg.edit( - content=None, - embed=discord.Embed( - description="Booking menu timed out, no sessions were booked.", - colour=discord.Colour.red() - ) - ) - await out_msg.clear_reactions() - except discord.HTTPException: - pass - return - - try: - await out_msg.delete() - await message.delete() - except discord.HTTPException: - pass - - if message.content.lower() == 'c': - return - - to_book = [ - times[index] - for index in parse_ranges(message.content) if index < len(times) - ] - if not to_book: - return await ctx.error_reply("No valid sessions selected.") - elif any(time < utc_now() for time in to_book): - return await ctx.error_reply("You can't book a running session!") - cost = len(to_book) * ctx.guild_settings.accountability_price.value - if cost > ctx.alion.coins: - return await ctx.error_reply( - "Sorry, booking `{}` sessions costs `{}` coins, and you only have `{}`!".format( - len(to_book), - cost, - ctx.alion.coins - ) - ) - - # Add the member to data, creating the row if required - slot_rows = accountability_rooms.fetch_rows_where( - guildid=ctx.guild.id, - start_at=to_book - ) - slotids = [row.slotid for row in slot_rows] - to_add = set(to_book).difference((row.start_at for row in slot_rows)) - if to_add: - slotids.extend(row['slotid'] for row in accountability_rooms.insert_many( - *((ctx.guild.id, start_at) for start_at in to_add), - insert_keys=('guildid', 'start_at'), - )) - accountability_members.insert_many( - *((slotid, ctx.author.id, ctx.guild_settings.accountability_price.value) for slotid in slotids), - insert_keys=('slotid', 'userid', 'paid') - ) - - # Handle case where the slot has already opened - # TODO: Fix this, doesn't always work - aguild = AGuild.cache.get(ctx.guild.id, None) - if aguild: - if aguild.upcoming_slot and aguild.upcoming_slot.start_time in to_book: - slot = aguild.upcoming_slot - if not slot.data: - # Handle slot activation - slot._refresh() - channelid, messageid = await slot.open() - accountability_rooms.update_where( - {'channelid': channelid, 'messageid': messageid}, - slotid=slot.data.slotid - ) - else: - slot.members[ctx.author.id] = SlotMember(slot.data.slotid, ctx.author.id, ctx.guild) - # Also update the channel permissions - try: - await slot.channel.set_permissions(ctx.author, view_channel=True, connect=True) - except discord.HTTPException: - pass - await slot.update_status() - ctx.alion.addCoins(-cost) - - # Ack purchase - embed = discord.Embed( - title="You have scheduled the following session{}!".format('s' if len(to_book) > 1 else ''), - description=( - "*Please attend all your scheduled sessions!*\n" - "*If you can't attend, cancel with* `{}schedule cancel`\n\n{}" - ).format( - ctx.best_prefix, - '\n'.join(time_format(time) for time in to_book), - ), - colour=discord.Colour.orange() - ).set_footer( - text=( - "Use {prefix}schedule to see your current schedule.\n" - ).format(prefix=ctx.best_prefix) - ) - try: - await ctx.reply( - embed=embed, - reference=ctx.msg - ) - except discord.NotFound: - await ctx.reply(embed=embed) - else: - # Show accountability room information for this user - # Accountability profile - # Author - # Special case for no past bookings, emphasis hint - # Hint on Bookings section for booking/cancelling as applicable - # Description has stats - # Footer says that all times are in their timezone - # TODO: attendance requirement shouldn't be retroactive! Add attended data column - # Attended `{}` out of `{}` booked (`{}%` attendance rate!) - # Attendance streak: `{}` days attended with no missed sessions! - # Add explanation for first time users - - # Get all slots the member has ever booked - history = accountability_member_info.select_where( - userid=ctx.author.id, - # start_at=LEQ(utc_now() - datetime.timedelta(hours=1)), - start_at=LEQ(utc_now()), - select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"), - _extra="ORDER BY start_at DESC" - ) - - if not (history or joined_rows): - # First-timer information - about = ( - "You haven't scheduled any study sessions yet!\n" - "Schedule a session by typing **`{}schedule book`** and selecting " - "the hours you intend to study, " - "then attend by joining the session voice channel when it starts!\n" - "Only if everyone attends will they get the bonus of `{}` LionCoins!\n" - "Let's all do our best and keep each other accountable 🔥" - ).format( - ctx.best_prefix, - ctx.guild_settings.accountability_bonus.value - ) - embed = discord.Embed( - description=about, - colour=discord.Colour.orange() - ) - embed.set_footer( - text="Please keep your DMs open so I can notify you when the session starts!\n", - icon_url=hint_icon - ) - await ctx.reply(embed=embed) - else: - # Build description with stats - if history: - # First get the counts - attended_count = sum(row['attended'] for row in history) - total_count = len(history) - total_duration = sum(row['duration'] for row in history) - - # Add current session to duration if it exists - if history[0]['last_joined_at'] and (utc_now() - history[0]['start_at']).total_seconds() < 3600: - total_duration += int((utc_now() - history[0]['last_joined_at']).total_seconds()) - - # Calculate the streak - timezone = ctx.alion.settings.timezone.value - - streak = 0 - current_streak = None - max_streak = 0 - day_attended = None - date = utc_now().astimezone(timezone).replace(hour=0, minute=0, second=0, microsecond=0) - daydiff = datetime.timedelta(days=1) - - i = 0 - while i < len(history): - row = history[i] - i += 1 - if not row['attended']: - # Not attended, streak broken - pass - elif row['start_at'] > date: - # They attended this day - day_attended = True - continue - elif day_attended is None: - # Didn't attend today, but don't break streak - day_attended = False - date -= daydiff - i -= 1 - continue - elif not day_attended: - # Didn't attend the day, streak broken - date -= daydiff - i -= 1 - pass - else: - # Attended the day - streak += 1 - - # Move window to the previous day and try the row again - date -= daydiff - day_attended = False - i -= 1 - continue - - max_streak = max(max_streak, streak) - if current_streak is None: - current_streak = streak - streak = 0 - - # Handle loop exit state, i.e. the last streak - if day_attended: - streak += 1 - max_streak = max(max_streak, streak) - if current_streak is None: - current_streak = streak - - # Build the stats - table = { - "Sessions": "**{}** attended out of **{}**, `{:.0f}%` attendance rate.".format( - attended_count, - total_count, - (attended_count * 100) / total_count, - ), - "Time": "**{:02}:{:02}** in scheduled sessions.".format( - total_duration // 3600, - (total_duration % 3600) // 60 - ), - "Streak": "**{}** day{} with no missed sessions! (Longest: **{}** day{}.)".format( - current_streak, - 's' if current_streak != 1 else '', - max_streak, - 's' if max_streak != 1 else '', - ), - } - desc = prop_tabulate(*zip(*table.items())) - else: - desc = ( - "Good luck with your next session!\n" - ) - - # Build currently booked list - - if joined_rows: - # TODO: (Future) calendar link - # Get attendee counts for currently booked sessions - rows = accountability_member_info.select_where( - slotid=[row["slotid"] for row in joined_rows], - userid=NOTNULL, - select_columns=( - 'slotid', - 'guildid', - 'start_at', - 'COUNT(*) as num' - ), - _extra="GROUP BY start_at, slotid, guildid ORDER BY start_at ASC" - ) - attendees = { - row['start_at']: (row['num'], row['guildid']) for row in rows - } - attendee_pad = max((len(str(num)) for num, _ in attendees.values()), default=1) - - # TODO: Allow cancel to accept multiselect keys as args - show_guild = any(guildid != ctx.guild.id for _, guildid in attendees.values()) - guild_map = {} - if show_guild: - for _, guildid in attendees.values(): - if guildid not in guild_map: - guild = ctx.client.get_guild(guildid) - if not guild: - try: - guild = await ctx.client.fetch_guild(guildid) - except discord.HTTPException: - guild = None - guild_map[guildid] = guild - - booked_list = '\n'.join( - "`{:>{}}` attendees | {} {}".format( - num, - attendee_pad, - time_format(start), - "" if not show_guild else ( - "on this server" if guildid == ctx.guild.id else "in **{}**".format( - guild_map[guildid] or "Unknown" - ) - ) - ) for start, (num, guildid) in attendees.items() - ) - booked_field = ( - "{}\n\n" - "*If you can't make your session, please cancel using `{}schedule cancel`!*" - ).format(booked_list, ctx.best_prefix) - - # Temporary footer for acclimatisation - # footer = "All times are displayed in your own timezone!" - footer = "Book another session using {}schedule book".format(ctx.best_prefix) - else: - booked_field = ( - "Your schedule is empty!\n" - "Book another session using `{}schedule book`." - ).format(ctx.best_prefix) - footer = "Please keep your DMs open for notifications!" - - # Finally, build embed - embed = discord.Embed( - colour=discord.Colour.orange(), - description=desc, - ).set_author( - name="Schedule statistics for {}".format(ctx.author.name), - icon_url=ctx.author.avatar_url - ).set_footer( - text=footer, - icon_url=hint_icon - ).add_field( - name="Upcoming sessions", - value=booked_field - ) - - # And send it! - await ctx.reply(embed=embed) - - -# TODO: roomadmin diff --git a/src/modules/pending-rewrite/accountability/data.py b/src/modules/pending-rewrite/accountability/data.py deleted file mode 100644 index 293a69b6..00000000 --- a/src/modules/pending-rewrite/accountability/data.py +++ /dev/null @@ -1,34 +0,0 @@ -from data import Table, RowTable - -from cachetools import TTLCache - - -accountability_rooms = RowTable( - 'accountability_slots', - ('slotid', 'channelid', 'guildid', 'start_at', 'messageid', 'closed_at'), - 'slotid', - cache=TTLCache(5000, ttl=60*70), - attach_as='accountability_rooms' -) - - -accountability_members = RowTable( - 'accountability_members', - ('slotid', 'userid', 'paid', 'duration', 'last_joined_at'), - ('slotid', 'userid'), - cache=TTLCache(5000, ttl=60*70) -) - -accountability_member_info = Table('accountability_member_info') -accountability_open_slots = Table('accountability_open_slots') - -# @accountability_member_info.save_query -# def user_streaks(userid, min_duration): -# with accountability_member_info.conn as conn: -# cursor = conn.cursor() -# with cursor: -# cursor.execute( -# """ - -# """ -# ) diff --git a/src/modules/pending-rewrite/accountability/lib.py b/src/modules/pending-rewrite/accountability/lib.py deleted file mode 100644 index daf11e9a..00000000 --- a/src/modules/pending-rewrite/accountability/lib.py +++ /dev/null @@ -1,8 +0,0 @@ -import datetime - - -def utc_now(): - """ - Return the current timezone-aware utc timestamp. - """ - return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) diff --git a/src/modules/pending-rewrite/accountability/module.py b/src/modules/pending-rewrite/accountability/module.py deleted file mode 100644 index 7efc388b..00000000 --- a/src/modules/pending-rewrite/accountability/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from LionModule import LionModule - - -module = LionModule("Accountability") diff --git a/src/modules/pending-rewrite/accountability/tracker.py b/src/modules/pending-rewrite/accountability/tracker.py deleted file mode 100644 index 21377361..00000000 --- a/src/modules/pending-rewrite/accountability/tracker.py +++ /dev/null @@ -1,515 +0,0 @@ -import asyncio -import datetime -import collections -import traceback -import logging -import discord -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, THIS_SHARD -from settings import GuildSettings - -from .TimeSlot import TimeSlot -from .lib import utc_now -from .data import accountability_rooms, accountability_members -from .module import module - - -voice_ignore_lock = asyncio.Lock() -room_lock = asyncio.Lock() - - -def locker(lock): - """ - Function decorator to wrap the function in a provided Lock - """ - def decorator(func): - async def wrapped(*args, **kwargs): - async with lock: - return await func(*args, **kwargs) - return wrapped - return decorator - - -class AccountabilityGuild: - __slots__ = ('guildid', 'current_slot', 'upcoming_slot') - - cache: Dict[int, 'AccountabilityGuild'] = {} # Map guildid -> AccountabilityGuild - - def __init__(self, guildid): - self.guildid = guildid - self.current_slot = None - self.upcoming_slot = None - - self.cache[guildid] = self - - @property - def guild(self): - return client.get_guild(self.guildid) - - @property - def guild_settings(self): - return GuildSettings(self.guildid) - - def advance(self): - self.current_slot = self.upcoming_slot - self.upcoming_slot = None - - -async def open_next(start_time): - """ - Open all the upcoming accountability rooms, and fire channel notify. - To be executed ~5 minutes to the hour. - """ - # Pre-fetch the new slot data, also populating the table caches - room_data = accountability_rooms.fetch_rows_where( - start_at=start_time, - guildid=THIS_SHARD - ) - guild_rows = {row.guildid: row for row in room_data} - member_data = accountability_members.fetch_rows_where( - slotid=[row.slotid for row in room_data] - ) if room_data else [] - slot_memberids = collections.defaultdict(list) - for row in member_data: - slot_memberids[row.slotid].append(row.userid) - - # Open a new slot in each accountability guild - to_update = [] # Cache of slot update data to be applied at the end - for aguild in list(AccountabilityGuild.cache.values()): - guild = aguild.guild - if guild: - # Initialise next TimeSlot - slot = TimeSlot( - guild, - start_time, - data=guild_rows.get(aguild.guildid, None) - ) - slot.load(memberids=slot_memberids[slot.data.slotid] if slot.data else None) - - if not slot.category: - # Log and unload guild - aguild.guild_settings.event_log.log( - "The scheduled session category couldn't be found!\n" - "Shutting down the scheduled session system in this server.\n" - "To re-activate, please reconfigure `config session_category`." - ) - AccountabilityGuild.cache.pop(aguild.guildid, None) - await slot.cancel() - continue - elif not slot.lobby: - # TODO: Consider putting in TimeSlot.open().. or even better in accountability_lobby.create() - # Create a new lobby - try: - channel = await guild.create_text_channel( - name="session-lobby", - category=slot.category, - reason="Automatic creation of scheduled session lobby." - ) - aguild.guild_settings.accountability_lobby.value = channel - slot.lobby = channel - except discord.HTTPException: - # Event log failure and skip session - aguild.guild_settings.event_log.log( - "Failed to create the scheduled session lobby text channel.\n" - "Please set the lobby channel manually with `config`." - ) - await slot.cancel() - continue - - # Event log creation - aguild.guild_settings.event_log.log( - "Automatically created a scheduled session lobby channel {}.".format(channel.mention) - ) - - results = await slot.open() - if results is None: - # Couldn't open the channel for some reason. - # Should already have been logged in `open`. - # Skip this session - await slot.cancel() - continue - elif slot.data: - to_update.append((results[0], results[1], slot.data.slotid)) - - # Time slot should now be open and ready to start - aguild.upcoming_slot = slot - else: - # Unload guild from cache - AccountabilityGuild.cache.pop(aguild.guildid, None) - - # Update slot data - if to_update: - accountability_rooms.update_many( - *to_update, - set_keys=('channelid', 'messageid'), - where_keys=('slotid',) - ) - - -async def turnover(): - """ - Switchover from the current accountability rooms to the next ones. - To be executed as close as possible to the hour. - """ - now = utc_now() - - # Open event lock so we don't read voice channel movement - async with voice_ignore_lock: - # Update session data for completed sessions - last_slots = [ - aguild.current_slot for aguild in AccountabilityGuild.cache.values() - if aguild.current_slot is not None - ] - - 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 and mem.data.last_joined_at - ] - if to_update: - accountability_members.update_many( - *to_update, - set_keys=('duration', 'last_joined_at'), - where_keys=('slotid', 'userid'), - 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()] - - # TODO: (FUTURE) with high volume, we might want to start the sessions before moving the members. - # We could break up the session starting? - - # ---------- Start next session ---------- - current_slots = [ - aguild.current_slot for aguild in AccountabilityGuild.cache.values() - if aguild.current_slot is not None - ] - slotmap = {slot.data.slotid: slot for slot in current_slots if slot.data} - - # Reload the slot members in case they cancelled from another shard - member_data = accountability_members.fetch_rows_where( - slotid=list(slotmap.keys()) - ) if slotmap else [] - slot_memberids = {slotid: [] for slotid in slotmap} - for row in member_data: - slot_memberids[row.slotid].append(row.userid) - reload_tasks = ( - slot._reload_members(memberids=slot_memberids[slotid]) - for slotid, slot in slotmap.items() - ) - await asyncio.gather( - *reload_tasks, - return_exceptions=True - ) - - # Move members of the next session over to the session channel - movement_tasks = ( - mem.member.edit( - voice_channel=slot.channel, - reason="Moving to scheduled session." - ) - for slot in current_slots - for mem in slot.members.values() - if mem.data and mem.member and mem.member.voice and mem.member.voice.channel != slot.channel - ) - # We return exceptions here to ignore any permission issues that occur with moving members. - # It's also possible (likely) that members will move while we are moving other members - # Returning the exceptions ensures that they are explicitly ignored - await asyncio.gather( - *movement_tasks, - return_exceptions=True - ) - - # 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.data and mem.member and mem.member.voice and mem.member.voice.channel == slot.channel - ] - if member_session_data: - accountability_members.update_many( - *member_session_data, - set_keys=('duration', 'last_joined_at'), - where_keys=('slotid', 'userid'), - cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)' - ) - - # Start all the current rooms - await asyncio.gather( - *(slot.start() for slot in current_slots), - return_exceptions=True - ) - - -@client.add_after_event('voice_state_update') -async def room_watchdog(client, member, before, after): - """ - Update session data when a member joins or leaves an accountability room. - Ignores events that occur while `voice_ignore_lock` is held. - """ - if not voice_ignore_lock.locked() and before.channel != after.channel: - aguild = AccountabilityGuild.cache.get(member.guild.id) - if aguild and aguild.current_slot and aguild.current_slot.channel: - slot = aguild.current_slot - if member.id in slot.members: - if after.channel and after.channel.id != slot.channel.id: - # Summon them back! - asyncio.create_task(member.edit(voice_channel=slot.channel)) - - slot_member = slot.members[member.id] - data = slot_member.data - - if before.channel and before.channel.id == slot.channel.id: - # Left accountability room - with data.batch_update(): - data.duration += int((utc_now() - data.last_joined_at).total_seconds()) - data.last_joined_at = None - await slot.update_status() - elif after.channel and after.channel.id == slot.channel.id: - # Joined accountability room - with data.batch_update(): - data.last_joined_at = utc_now() - await slot.update_status() - - -async def _accountability_loop(): - """ - Runloop in charge of executing the room update tasks at the correct times. - """ - # Wait until ready - while not client.is_ready(): - await asyncio.sleep(0.1) - - # Calculate starting next_time - # Assume the resume logic has taken care of all events/tasks before current_time - now = utc_now() - if now.minute < 55: - next_time = now.replace(minute=55, second=0, microsecond=0) - else: - next_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) - - # Executor loop - while True: - # TODO: (FUTURE) handle cases where we actually execute much late than expected - await sleep_until(next_time) - if next_time.minute == 55: - next_time = next_time + datetime.timedelta(minutes=5) - # Open next sessions - try: - async with room_lock: - await open_next(next_time) - except Exception: - # Unknown exception. Catch it so the loop doesn't die. - client.log( - "Error while opening new scheduled sessions! " - "Exception traceback follows.\n{}".format( - traceback.format_exc() - ), - context="ACCOUNTABILITY_LOOP", - level=logging.ERROR - ) - elif next_time.minute == 0: - # Start new sessions - try: - async with room_lock: - await turnover() - except Exception: - # Unknown exception. Catch it so the loop doesn't die. - client.log( - "Error while starting scheduled sessions! " - "Exception traceback follows.\n{}".format( - traceback.format_exc() - ), - context="ACCOUNTABILITY_LOOP", - level=logging.ERROR - ) - next_time = next_time + datetime.timedelta(minutes=55) - - -async def _accountability_system_resume(): - """ - Logic for starting the accountability system from cold. - Essentially, session and state resume logic. - """ - now = utc_now() - - # Fetch the open room data, only takes into account currently running sessions. - # May include sessions that were never opened, or opened but never started - # Does not include sessions that were opened that start on the next hour - open_room_data = accountability_rooms.fetch_rows_where( - closed_at=NULL, - start_at=LEQ(now), - guildid=THIS_SHARD, - _extra="ORDER BY start_at ASC" - ) - - if open_room_data: - # Extract member data of these rows - member_data = accountability_members.fetch_rows_where( - slotid=[row.slotid for row in open_room_data] - ) - slot_members = collections.defaultdict(list) - for row in member_data: - slot_members[row.slotid].append(row) - - # Filter these into expired rooms and current rooms - expired_room_data = [] - current_room_data = [] - for row in open_room_data: - if row.start_at + datetime.timedelta(hours=1) < now: - expired_room_data.append(row) - else: - current_room_data.append(row) - - session_updates = [] - - # TODO URGENT: Batch room updates here - - # Expire the expired rooms - for row in expired_room_data: - if row.channelid is None or row.messageid is None: - # TODO refunds here - # If the rooms were never opened, close them and skip - row.closed_at = now - else: - # If the rooms were opened and maybe started, make optimistic guesses on session data and close. - session_end = row.start_at + datetime.timedelta(hours=1) - session_updates.extend( - (mow.duration + int((session_end - mow.last_joined_at).total_seconds()), - None, mow.slotid, mow.userid) - for mow in slot_members[row.slotid] if mow.last_joined_at - ) - if client.get_guild(row.guildid): - slot = TimeSlot(client.get_guild(row.guildid), row.start_at, data=row).load( - memberids=[mow.userid for mow in slot_members[row.slotid]] - ) - try: - await slot.close() - except discord.HTTPException: - pass - row.closed_at = now - - # Load the in-progress room data - if current_room_data: - async with voice_ignore_lock: - current_hour = now.replace(minute=0, second=0, microsecond=0) - await open_next(current_hour) - [aguild.advance() for aguild in AccountabilityGuild.cache.values()] - - current_slots = [ - aguild.current_slot - for aguild in AccountabilityGuild.cache.values() - if aguild.current_slot - ] - - session_updates.extend( - (mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()), - None, mem.slotid, mem.userid) - for slot in current_slots - for mem in slot.members.values() - if mem.data.last_joined_at and mem.member not in slot.channel.members - ) - - session_updates.extend( - (mem.data.duration, - now, mem.slotid, mem.userid) - for slot in current_slots - for mem in slot.members.values() - if not mem.data.last_joined_at and mem.member in slot.channel.members - ) - - if session_updates: - accountability_members.update_many( - *session_updates, - set_keys=('duration', 'last_joined_at'), - where_keys=('slotid', 'userid'), - cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)' - ) - - await asyncio.gather( - *(aguild.current_slot.start() - for aguild in AccountabilityGuild.cache.values() if aguild.current_slot) - ) - else: - if session_updates: - accountability_members.update_many( - *session_updates, - set_keys=('duration', 'last_joined_at'), - where_keys=('slotid', 'userid'), - cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)' - ) - - # If we are in the last five minutes of the hour, open new rooms. - # Note that these may already have been opened, or they may not have been. - if now.minute >= 55: - await open_next( - now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) - ) - - -@module.launch_task -async def launch_accountability_system(client): - """ - Launcher for the accountability system. - Resumes saved sessions, and starts the accountability loop. - """ - # Load the AccountabilityGuild cache - guilds = tables.guild_config.fetch_rows_where( - accountability_category=NOTNULL, - guildid=THIS_SHARD - ) - # Further filter out any guilds that we aren't in - [AccountabilityGuild(guild.guildid) for guild in guilds if client.get_guild(guild.guildid)] - await _accountability_system_resume() - asyncio.create_task(_accountability_loop()) - - -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/src/modules/pending-rewrite/gui-commands/__init__.py b/src/modules/pending-rewrite/gui-commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/modules/pending-rewrite/gui-commands/goals.py b/src/modules/pending-rewrite/gui-commands/goals.py deleted file mode 100644 index fe77babf..00000000 --- a/src/modules/pending-rewrite/gui-commands/goals.py +++ /dev/null @@ -1,320 +0,0 @@ -from datetime import timedelta -import asyncio - -from data.conditions import GEQ - -from modules.stats import goals - -from ..module import module, ratelimit - -from ...cards import WeeklyGoalCard, MonthlyGoalCard -from ...cards import WeeklyStatsCard, MonthlyStatsCard -from ...utils import get_avatar_key, image_as_file - - -async def _get_weekly_goals(ctx): - # Fetch goal data - goal_row = ctx.client.data.weekly_goals.fetch_or_create( - (ctx.guild.id, ctx.author.id, ctx.alion.week_timestamp) - ) - tasklist_rows = ctx.client.data.member_weekly_goal_tasks.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - weekid=ctx.alion.week_timestamp, - _extra="ORDER BY taskid ASC" - ) - tasklist = [ - (i, task['content'], task['completed']) - for i, task in enumerate(tasklist_rows) - ] - - day_start = ctx.alion.day_start - week_start = day_start - timedelta(days=day_start.weekday()) - - # Fetch study data - week_study_time = ctx.client.data.session_history.queries.study_time_since( - ctx.guild.id, ctx.author.id, week_start - ) - study_hours = week_study_time // 3600 - - # Fetch task data - tasks_done = ctx.client.data.tasklist.select_one_where( - userid=ctx.author.id, - completed_at=GEQ(week_start), - select_columns=('COUNT(*)',) - )[0] - - # Fetch accountability data - accountability = ctx.client.data.accountability_member_info.select_where( - userid=ctx.author.id, - start_at=GEQ(week_start), - select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"), - ) - if len(accountability): - acc_attended = sum(row['attended'] for row in accountability) - acc_total = len(accountability) - acc_rate = acc_attended / acc_total - else: - acc_rate = None - - goalpage = await WeeklyGoalCard.request( - name=ctx.author.name, - discrim=f"#{ctx.author.discriminator}", - avatar=get_avatar_key(ctx.client, ctx.author.id), - badges=ctx.alion.profile_tags, - tasks_done=tasks_done, - studied_hours=study_hours, - attendance=acc_rate, - tasks_goal=goal_row.task_goal, - studied_goal=goal_row.study_goal, - goals=tasklist, - date=ctx.alion.day_start, - skin=WeeklyGoalCard.skin_args_for(ctx) - ) - return goalpage - - -@ratelimit.ward() -async def show_weekly_goals(ctx): - image = await _get_weekly_goals(ctx) - await ctx.reply(file=image_as_file(image, 'weekly_stats_1.png')) - -goals.display_weekly_goals_for = show_weekly_goals - - -@module.cmd( - "weekly", - group="Statistics", - desc="View your weekly study statistics!" -) -@ratelimit.ward() -async def cmd_weekly(ctx): - """ - Usage``: - {prefix}weekly - Description: - View your weekly study profile. - See `{prefix}weeklygoals` to edit your goals! - """ - day_start = ctx.alion.day_start - last_week_start = day_start - timedelta(days=7 + day_start.weekday()) - - history = ctx.client.data.session_history.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - start_time=GEQ(last_week_start - timedelta(days=1)), - select_columns=( - "start_time", - "(start_time + duration * interval '1 second') AS end_time" - ), - _extra="ORDER BY start_time ASC" - ) - timezone = ctx.alion.timezone - sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history] - - page_1_task = asyncio.create_task(_get_weekly_goals(ctx)) - page_2_task = asyncio.create_task( - WeeklyStatsCard.request( - ctx.author.name, - f"#{ctx.author.discriminator}", - sessions, - day_start, - skin=WeeklyStatsCard.skin_args_for(ctx) - ) - ) - - await asyncio.gather(page_1_task, page_2_task) - page_1 = page_1_task.result() - page_2 = page_2_task.result() - - await ctx.reply( - files=[ - image_as_file(page_1, "weekly_stats_1.png"), - image_as_file(page_2, "weekly_stats_2.png") - ] - ) - - -async def _get_monthly_goals(ctx): - # Fetch goal data - goal_row = ctx.client.data.monthly_goals.fetch_or_create( - (ctx.guild.id, ctx.author.id, ctx.alion.month_timestamp) - ) - tasklist_rows = ctx.client.data.member_monthly_goal_tasks.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - monthid=ctx.alion.month_timestamp, - _extra="ORDER BY taskid ASC" - ) - tasklist = [ - (i, task['content'], task['completed']) - for i, task in enumerate(tasklist_rows) - ] - - day_start = ctx.alion.day_start - month_start = day_start.replace(day=1) - - # Fetch study data - month_study_time = ctx.client.data.session_history.queries.study_time_since( - ctx.guild.id, ctx.author.id, month_start - ) - study_hours = month_study_time // 3600 - - # Fetch task data - tasks_done = ctx.client.data.tasklist.select_one_where( - userid=ctx.author.id, - completed_at=GEQ(month_start), - select_columns=('COUNT(*)',) - )[0] - - # Fetch accountability data - accountability = ctx.client.data.accountability_member_info.select_where( - userid=ctx.author.id, - start_at=GEQ(month_start), - select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"), - ) - if len(accountability): - acc_attended = sum(row['attended'] for row in accountability) - acc_total = len(accountability) - acc_rate = acc_attended / acc_total - else: - acc_rate = None - - goalpage = await MonthlyGoalCard.request( - name=ctx.author.name, - discrim=f"#{ctx.author.discriminator}", - avatar=get_avatar_key(ctx.client, ctx.author.id), - badges=ctx.alion.profile_tags, - tasks_done=tasks_done, - studied_hours=study_hours, - attendance=acc_rate, - tasks_goal=goal_row.task_goal, - studied_goal=goal_row.study_goal, - goals=tasklist, - date=ctx.alion.day_start, - skin=MonthlyGoalCard.skin_args_for(ctx) - ) - return goalpage - - -@ratelimit.ward() -async def show_monthly_goals(ctx): - image = await _get_monthly_goals(ctx) - await ctx.reply(file=image_as_file(image, 'monthly_stats_1.png')) - -goals.display_monthly_goals_for = show_monthly_goals - - -@module.cmd( - "monthly", - group="Statistics", - desc="View your monthly study statistics!" -) -async def cmd_monthly(ctx): - """ - Usage``: - {prefix}monthly - Description: - View your monthly study profile. - See `{prefix}monthlygoals` to edit your goals! - """ - day_start = ctx.alion.day_start - period_start = day_start - timedelta(days=31*4) - - history = ctx.client.data.session_history.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - select_columns=( - "start_time", - "(start_time + duration * interval '1 second') AS end_time" - ), - _extra="ORDER BY start_time DESC" - ) - timezone = ctx.alion.timezone - sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history] - if not sessions: - return await ctx.error_reply( - "No statistics to show, because you have never studied in this server before!" - ) - - # Streak statistics - streak = 0 - current_streak = None - max_streak = 0 - - day_attended = True if 'sessions' in ctx.client.objects and ctx.alion.session else None - date = day_start - daydiff = timedelta(days=1) - - periods = sessions - - i = 0 - while i < len(periods): - row = periods[i] - i += 1 - if row[1] > date: - # They attended this day - day_attended = True - continue - elif day_attended is None: - # Didn't attend today, but don't break streak - day_attended = False - date -= daydiff - i -= 1 - continue - elif not day_attended: - # Didn't attend the day, streak broken - date -= daydiff - i -= 1 - pass - else: - # Attended the day - streak += 1 - - # Move window to the previous day and try the row again - day_attended = False - prev_date = date - date -= daydiff - i -= 1 - - # Special case, when the last session started in the previous day - # Then the day is already attended - if i > 1 and date < periods[i-2][0] <= prev_date: - day_attended = True - - continue - - max_streak = max(max_streak, streak) - if current_streak is None: - current_streak = streak - streak = 0 - - # Handle loop exit state, i.e. the last streak - if day_attended: - streak += 1 - max_streak = max(max_streak, streak) - if current_streak is None: - current_streak = streak - - first_session_start = sessions[-1][0] - sessions = [session for session in sessions if session[1] > period_start] - page_1_task = asyncio.create_task(_get_monthly_goals(ctx)) - page_2_task = asyncio.create_task(MonthlyStatsCard.request( - ctx.author.name, - f"#{ctx.author.discriminator}", - sessions, - day_start.date(), - current_streak or 0, - max_streak or 0, - first_session_start, - skin=MonthlyStatsCard.skin_args_for(ctx) - )) - await asyncio.gather(page_1_task, page_2_task) - page_1 = page_1_task.result() - page_2 = page_2_task.result() - await ctx.reply( - files=[ - image_as_file(page_1, "monthly_stats_1.png"), - image_as_file(page_2, "monthly_stats_2.png") - ] - ) diff --git a/src/modules/pending-rewrite/gui-commands/leaderboard.py b/src/modules/pending-rewrite/gui-commands/leaderboard.py deleted file mode 100644 index 554258ca..00000000 --- a/src/modules/pending-rewrite/gui-commands/leaderboard.py +++ /dev/null @@ -1,198 +0,0 @@ -import gc -import asyncio -import discord -from cmdClient.checks import in_guild - -import data -from data import tables -from utils.interactive import discord_shield -from meta import conf - -from ...cards import LeaderboardCard -from ...utils import image_as_file, edit_files, get_avatar_key - -from ..module import module, ratelimit - - -next_emoji = conf.emojis.forward -my_emoji = conf.emojis.person -prev_emoji = conf.emojis.backward - - -@module.cmd( - "top", - desc="View the Study Time leaderboard.", - group="Statistics", - aliases=('ttop', 'toptime', 'top100') -) -@in_guild() -@ratelimit.ward(member=False) -async def cmd_top(ctx): - """ - Usage``: - {prefix}top - {prefix}top100 - Description: - Display the study time leaderboard, or the top 100. - """ - # Handle args - if ctx.args and not ctx.args == "100": - return await ctx.error_reply( - "**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix) - ) - top100 = (ctx.args == "100" or ctx.alias == "top100") - - # Fetch the leaderboard - exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.user_blacklist()) - exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) - - args = { - 'guildid': ctx.guild.id, - 'select_columns': ('userid', 'total_tracked_time::INTEGER', 'display_name'), - '_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "") - } - if exclude: - args['userid'] = data.NOT(list(exclude)) - - user_data = tables.members_totals.select_where(**args) - - # Quit early if the leaderboard is empty - if not user_data: - return await ctx.reply("No leaderboard entries yet!") - - # Extract entries - author_rank = None - entries = [] - for i, (userid, time, display_name) in enumerate(user_data): - if (member := ctx.guild.get_member(userid)): - name = member.display_name - elif display_name: - name = display_name - else: - name = str(userid) - - entries.append( - (userid, i + 1, time, name, get_avatar_key(ctx.client, userid)) - ) - - if ctx.author.id == userid: - author_rank = i + 1 - - # Break into pages - entry_pages = [entries[i:i+10] for i in range(0, len(entries), 10)] - page_count = len(entry_pages) - author_page = (author_rank - 1) // 10 if author_rank is not None else None - - if page_count == 1: - image = await LeaderboardCard.request( - ctx.guild.name, - entries=entry_pages[0], - highlight=author_rank, - skin=LeaderboardCard.skin_args_for(ctx) - ) - _file = image_as_file(image, "leaderboard.png") - await ctx.reply(file=_file) - del image - else: - page_i = 0 - - page_futures = {} - - def submit_page_request(i): - if (_existing := page_futures.get(i, None)) is not None: - # A future was already submitted - _future = _existing - else: - _future = asyncio.create_task( - LeaderboardCard.request( - ctx.guild.name, - entries=entry_pages[i % page_count], - highlight=author_rank, - skin=LeaderboardCard.skin_args_for(ctx) - ) - ) - page_futures[i] = _future - return _future - - # Draw first page - out_msg = await ctx.reply(file=image_as_file(await submit_page_request(0), "leaderboard.png")) - - # Prefetch likely next page - submit_page_request(author_page or 1) - - # Add reactions - try: - await out_msg.add_reaction(prev_emoji) - if author_page is not None: - await out_msg.add_reaction(my_emoji) - await out_msg.add_reaction(next_emoji) - except discord.Forbidden: - perms = ctx.ch.permissions_for(ctx.guild.me) - if not perms.add_reactions: - await ctx.error_reply( - "Cannot page leaderboard because I do not have the `add_reactions` permission!" - ) - elif not perms.read_message_history: - await ctx.error_reply( - "Cannot page leaderboard because I do not have the `read_message_history` permission!" - ) - else: - await ctx.error_reply( - "Cannot page leaderboard due to insufficient permissions!" - ) - return - - def reaction_check(reaction, user): - result = reaction.message.id == out_msg.id - result = result and reaction.emoji in [next_emoji, my_emoji, prev_emoji] - result = result and not (user.id == ctx.client.user.id) - return result - - while True: - try: - reaction, user = await ctx.client.wait_for('reaction_add', check=reaction_check, timeout=60) - except asyncio.TimeoutError: - break - - asyncio.create_task(discord_shield(out_msg.remove_reaction(reaction.emoji, user))) - - # Change the page number - if reaction.emoji == next_emoji: - page_i += 1 - page_i %= page_count - elif reaction.emoji == prev_emoji: - page_i -= 1 - page_i %= page_count - else: - page_i = author_page - - # Edit the message - image = await submit_page_request(page_i) - image_file = image_as_file(image, f"leaderboard_{page_i}.png") - - await edit_files( - ctx.client._connection.http, - ctx.ch.id, - out_msg.id, - files=[image_file] - ) - # Prefetch surrounding pages - submit_page_request((page_i + 1) % page_count) - submit_page_request((page_i - 1) % page_count) - - # Clean up reactions - try: - await out_msg.clear_reactions() - except discord.Forbidden: - try: - await out_msg.remove_reaction(next_emoji, ctx.client.user) - await out_msg.remove_reaction(prev_emoji, ctx.client.user) - except discord.NotFound: - pass - except discord.NotFound: - pass - - # Delete the image cache and explicit garbage collect - del page_futures - gc.collect() diff --git a/src/modules/pending-rewrite/gui-commands/module.py b/src/modules/pending-rewrite/gui-commands/module.py deleted file mode 100644 index 28610cb7..00000000 --- a/src/modules/pending-rewrite/gui-commands/module.py +++ /dev/null @@ -1,90 +0,0 @@ -import logging -import time -import traceback -import discord - -from LionModule import LionModule - -from meta import client -from utils.ratelimits import RateLimit - -from ..client import EmptyResponse, request - - -class PluginModule(LionModule): - def cmd(self, name, **kwargs): - # Remove any existing command with this name - for module in client.modules: - for i, cmd in enumerate(module.cmds): - if cmd.name == name: - module.cmds.pop(i) - - return super().cmd(name, **kwargs) - - async def on_exception(self, ctx, exception): - try: - raise exception - except (ConnectionError, EmptyResponse) as e: - full_traceback = traceback.format_exc() - only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only()) - - client.log( - ("Caught a communication exception while " - "executing command '{cmdname}' from module '{module}' " - "from user '{message.author}' (uid:{message.author.id}) " - "in guild '{message.guild}' (gid:{guildid}) " - "in channel '{message.channel}' (cid:{message.channel.id}).\n" - "Message Content:\n" - "{content}\n" - "{traceback}\n\n" - "{flat_ctx}").format( - cmdname=ctx.cmd.name, - module=ctx.cmd.module.name, - message=ctx.msg, - guildid=ctx.guild.id if ctx.guild else None, - content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()), - traceback=full_traceback, - flat_ctx=ctx.flatten() - ), - context="mid:{}".format(ctx.msg.id), - level=logging.ERROR - ) - error_embed = discord.Embed(title="Sorry, something went wrong!") - error_embed.description = ( - "An unexpected error occurred while communicating with our rendering server!\n" - "Our development team has been notified, and the issue should be fixed soon.\n" - ) - if logging.getLogger().getEffectiveLevel() < logging.INFO: - error_embed.add_field( - name="Exception", - value="`{}`".format(only_error) - ) - - await ctx.reply(embed=error_embed) - except Exception: - await super().on_exception(ctx, exception) - - -module = PluginModule("GUI") - -ratelimit = RateLimit(5, 30) - -logging.getLogger('PIL').setLevel(logging.WARNING) - - -@module.launch_task -async def ping_server(client): - start = time.time() - try: - await request('ping') - except Exception: - logging.error( - "Failed to ping the rendering server!", - exc_info=True - ) - else: - end = time.time() - client.log( - f"Rendering server responded in {end-start:.6f} seconds!", - context="GUI INIT", - ) diff --git a/src/modules/pending-rewrite/gui-commands/reloadgui.py b/src/modules/pending-rewrite/gui-commands/reloadgui.py deleted file mode 100644 index 811da06d..00000000 --- a/src/modules/pending-rewrite/gui-commands/reloadgui.py +++ /dev/null @@ -1,24 +0,0 @@ -import importlib -from .. import drawing -from . import goals, leaderboard, stats, tasklist - -from cmdClient import cmd, checks - - -@cmd("reloadgui", - desc="Reload all GUI drawing modules.") -@checks.is_owner() -async def cmd_reload_gui(ctx): - importlib.reload(drawing.goals) - importlib.reload(drawing.leaderboard) - importlib.reload(drawing.profile) - importlib.reload(drawing.stats) - importlib.reload(drawing.tasklist) - importlib.reload(drawing.weekly) - importlib.reload(drawing.monthly) - - importlib.reload(goals) - importlib.reload(leaderboard) - importlib.reload(stats) - importlib.reload(tasklist) - await ctx.reply("GUI plugin reloaded.") diff --git a/src/modules/pending-rewrite/gui-commands/stats.py b/src/modules/pending-rewrite/gui-commands/stats.py deleted file mode 100644 index a1e00232..00000000 --- a/src/modules/pending-rewrite/gui-commands/stats.py +++ /dev/null @@ -1,278 +0,0 @@ -import asyncio -import time -from datetime import datetime, timedelta -from cmdClient.checks import in_guild - -from utils.lib import utc_now -from data import tables -from data.conditions import LEQ -from core import Lion -from LionContext import LionContext as Context - -from modules.study.tracking.data import session_history -from modules.stats.achievements import get_achievements_for - -from ...cards import StatsCard, ProfileCard -from ...utils import get_avatar_key, image_as_file - -from ..module import module, ratelimit - - -async def get_stats_card_for(ctx: Context, target): - lion = Lion.fetch(ctx.guild.id, target.id) - - history = session_history.select_where( - guildid=ctx.guild.id, - userid=target.id, - select_columns=( - "start_time", - "(start_time + duration * interval '1 second') AS end_time" - ), - _extra="ORDER BY start_time DESC" - ) - - # Current economy balance (accounting for current session) - workout_total = lion.data.workout_count - - # Leaderboard ranks - exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.user_blacklist()) - exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) - if target.bot or target.id in exclude: - time_rank = None - coin_rank = None - else: - time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0])) - - # Study time - # First get the all/month/week/day timestamps - day_start = lion.day_start - month_start = day_start.replace(day=1) - period_timestamps = ( - datetime(1970, 1, 1), - month_start, - day_start - timedelta(days=day_start.weekday()), - day_start - ) - study_times = [0, 0, 0, 0] - for i, timestamp in enumerate(period_timestamps): - study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp) - if not study_time: - # So we don't make unecessary database calls - break - study_times[i] = study_time - - # Streak data for the study run view - streaks = [] - - streak = 0 - streak_end = None - date = day_start - daydiff = timedelta(days=1) - - if 'sessions' in ctx.client.objects and lion.session: - day_attended = True - streak_end = day_start.day - else: - day_attended = None - - periods = [(row['start_time'], row['end_time']) for row in history] - - i = 0 - while i < len(periods): - row = periods[i] - i += 1 - if row[1] > date: - # They attended this day - day_attended = True - if streak_end is None: - streak_end = (date - month_start).days + 1 - continue - elif day_attended is None: - # Didn't attend today, but don't break streak - day_attended = False - date -= daydiff - i -= 1 - continue - elif not day_attended: - # Didn't attend the day, streak broken - date -= daydiff - i -= 1 - pass - else: - # Attended the day - streak += 1 - - # Move window to the previous day and try the row again - day_attended = False - prev_date = date - date -= daydiff - i -= 1 - - # Special case, when the last session started in the previous day - # Then the day is already attended - if i > 1 and date < periods[i-2][0] <= prev_date: - day_attended = True - if streak_end is None: - streak_end = (date - month_start).days + 1 - - continue - - if streak_end: - streaks.append((streak_end - streak + 1, streak_end)) - streak_end = None - streak = 0 - if date.month != day_start.month: - break - - # Handle loop exit state, i.e. the last streak - if day_attended: - streak += 1 - streaks.append((streak_end - streak + 1, streak_end)) - - # We have all the data for the stats card - return await StatsCard.request( - (time_rank, coin_rank), - list(reversed(study_times)), - workout_total, - streaks, - skin=StatsCard.skin_args_for(ctx) - ) - - -async def get_profile_card_for(ctx: Context, target): - lion = Lion.fetch(ctx.guild.id, target.id) - - # Current economy balance (accounting for current session) - coins = lion.coins - season_time = lion.time - - # Study League - guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id) - if lion.data.last_study_badgeid: - current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid) - else: - current_badge = None - - next_badge = min( - (badge for badge in guild_badges - if badge.required_time > (current_badge.required_time if current_badge else 0)), - key=lambda badge: badge.required_time, - default=None - ) - if current_badge: - current_rank = ( - role.name if (role := ctx.guild.get_role(current_badge.roleid)) else str(current_badge.roleid), - current_badge.required_time // 3600, - next_badge.required_time // 3600 if next_badge else None - ) - else: - current_rank = None - if next_badge: - next_next_badge = min( - (badge for badge in guild_badges if badge.required_time > next_badge.required_time), - key=lambda badge: badge.required_time, - default=None - ) - next_rank = ( - role.name if (role := ctx.guild.get_role(next_badge.roleid)) else str(next_badge.roleid), - next_badge.required_time // 3600, - next_next_badge.required_time // 3600 if next_next_badge else None - ) - else: - next_rank = None - - achievements = await get_achievements_for(target) - - # We have all the data for the profile card - avatar = get_avatar_key(ctx.client, target.id) - return await ProfileCard.request( - target.name, - '#{}'.format(target.discriminator), - coins, - season_time, - avatar=avatar, - gems=ctx.client.data.gem_transactions.queries.get_gems_for(target.id), - gifts=ctx.client.data.gem_transactions.queries.get_gifts_for(target.id), - achievements=[i for i, ach in enumerate(achievements) if ach.level_id > 0], - current_rank=current_rank, - next_rank=next_rank, - badges=lion.profile_tags, - skin=ProfileCard.skin_args_for(ctx) - ) - - -@module.cmd( - "stats", - group="Statistics", - desc="View your server study statistics!" -) -@in_guild() -@ratelimit.ward(member=False) -async def cmd_stats(ctx): - """ - Usage``: - {prefix}stats - {prefix}stats - Description: - View your study statistics in this server, or those of the mentioned member. - """ - # Identify the target - if ctx.args: - if not ctx.msg.mentions: - return await ctx.error_reply("Please mention a user to view their statistics!") - target = ctx.msg.mentions[0] - else: - target = ctx.author - - # System sync - Lion.sync() - - # Fetch the cards - futures = ( - asyncio.create_task(get_profile_card_for(ctx, target)), - asyncio.create_task(get_stats_card_for(ctx, target)) - ) - await futures[0] - await futures[1] - - profile_image = futures[0].result() - stats_image = futures[1].result() - - profile_file = image_as_file(profile_image, f"profile_{target.id}.png") - stats_file = image_as_file(stats_image, f"stats_{target.id}.png") - - await ctx.reply(files=[profile_file, stats_file]) - - -@module.cmd( - "profile", - group="Statistics", - desc="View your personal study profile!" -) -@in_guild() -@ratelimit.ward(member=False) -async def cmd_profile(ctx): - """ - Usage``: - {prefix}profile - {prefix}profile - Description: - View your server study profile, or that of the mentioned user. - """ - # Identify the target - if ctx.args: - if not ctx.msg.mentions: - return await ctx.error_reply("Please mention a user to view their profile!") - target = ctx.msg.mentions[0] - else: - target = ctx.author - - # System sync - Lion.sync() - - # Fetch the cards - profile_image = await get_profile_card_for(ctx, target) - profile_file = image_as_file(profile_image, f"profile_{target.id}.png") - - await ctx.reply(file=profile_file) diff --git a/src/modules/pending-rewrite/gui-commands/tasklist.py b/src/modules/pending-rewrite/gui-commands/tasklist.py deleted file mode 100644 index 8f123490..00000000 --- a/src/modules/pending-rewrite/gui-commands/tasklist.py +++ /dev/null @@ -1,111 +0,0 @@ -import asyncio -import discord - -from core import Lion -from meta import client - -from modules.todo.Tasklist import Tasklist as TextTasklist - -from ...cards import TasklistCard -from ...utils import get_avatar_key, image_as_file, edit_files - - -widget_help = """ -Open your interactive tasklist with `{prefix}todo`, \ - then use the following commands to update your tasks. \ - The `` may be given as comma separated numbers and ranges. - -`` Toggle the status (done/notdone) of the provided tasks. -`add/+ ` Add a new TODO `task`. Each line is added as a separate task. -`d/rm/- ` Remove the specified tasks. -`c/check ` Check (mark as done) the specified tasks. -`u/uncheck ` Uncheck (mark incomplete) the specified tasks. -`cancel` Cancel the interactive tasklist mode. - -*You do not need to write `{prefix}todo` before each command when the list is visible.* - -**Examples** -`add Read chapter 1` Add a new task `Read chapter 1`. -`e 0 Notes chapter 1` Edit task `0` to say `Notes chapter 1`. -`d 0, 5-7, 9` Delete tasks `0, 5, 6, 7, 9`. -`0, 2-5, 9` Toggle the completion status of tasks `0, 2, 3, 4, 5, 9`. - -[Click here to jump back]({jump_link}) -""" - - -class GUITasklist(TextTasklist): - async def _format_tasklist(self): - tasks = [ - (i, task.content, bool(task.completed_at)) - for (i, task) in enumerate(self.tasklist) - ] - avatar = get_avatar_key(client, self.member.id) - lion = Lion.fetch(self.member.guild.id, self.member.id) - date = lion.day_start - self.pages = await TasklistCard.request( - self.member.name, - f"#{self.member.discriminator}", - tasks, - date, - avatar=avatar, - badges=lion.profile_tags, - skin=TasklistCard.skin_args_for(guildid=self.member.guild.id, userid=self.member.id) - ) - - return self.pages - - async def _post(self): - pages = self.pages - - message = await self.channel.send(file=image_as_file(pages[self.current_page], "tasklist.png")) - - # Add the reactions - self.has_paging = len(pages) > 1 - for emoji in (self.paged_reaction_order if self.has_paging else self.non_paged_reaction_order): - await message.add_reaction(emoji) - - # Register - if self.message: - self.messages.pop(self.message.id, None) - - self.message = message - self.messages[message.id] = self - - async def _update(self): - if self.show_help: - embed = discord.Embed( - title="Tasklist widget guide", - description=widget_help.format( - prefix=client.prefix, - jump_link=self.message.jump_url - ), - colour=discord.Colour.orange() - ) - try: - await self.member.send(embed=embed) - except discord.Forbidden: - await self.channel.send("Could not send you the guide! Please open your DMs first.") - except discord.HTTPException: - pass - self.show_help = False - await edit_files( - self.message._state.http, - self.channel.id, - self.message.id, - files=[image_as_file(self.pages[self.current_page], "tasklist.png")] - ) - - -# Monkey patch the Tasklist fetch method to conditionally point to the GUI tasklist -# TODO: Config setting for text/gui -@classmethod -def fetch_or_create(cls, ctx, flags, member, channel): - factory = TextTasklist if flags['text'] else GUITasklist - tasklist = GUITasklist.active.get((member.id, channel.id), None) - if type(tasklist) != factory: - tasklist = None - return tasklist if tasklist is not None else factory(member, channel) - - -TextTasklist.fetch_or_create = fetch_or_create diff --git a/src/modules/pending-rewrite/gui-commands/timer.py b/src/modules/pending-rewrite/gui-commands/timer.py deleted file mode 100644 index 1d22e04e..00000000 --- a/src/modules/pending-rewrite/gui-commands/timer.py +++ /dev/null @@ -1,169 +0,0 @@ -import asyncio -import time -import logging -import traceback -from collections import defaultdict - -import discord -from utils.lib import utc_now -from core import Lion -from meta import client - -from modules.study.timers.Timer import Timer - -from ...cards import FocusTimerCard, BreakTimerCard - -from ...utils import get_avatar_key, image_as_file, edit_files, asset_path - - -async def status(self): - stage = self.current_stage - - name = self.data.pretty_name - remaining = int((stage.end - utc_now()).total_seconds()) - duration = int(stage.duration) - next_starts = int(stage.end.timestamp()) - users = [ - (get_avatar_key(client, member.id), - session.duration if (session := Lion.fetch(member.guild.id, member.id).session) else 0, - session.data.tag if session else None) - for member in self.members - ] - if stage.name == 'FOCUS': - card_class = FocusTimerCard - content = f"**Focus!** Session ends ." - else: - card_class = BreakTimerCard - content = f"**Have a rest!** Break finishes ." - - page = await card_class.request( - name, - remaining, - duration, - users=users, - skin=card_class.skin_args_for(guildid=self.data.guildid) - ) - - return { - 'content': content, - 'files': [image_as_file(page, name="timer.png")] - } - - -_guard_delay = 60 -_guarded = {} # timer channel id -> (last_executed_time, currently_waiting) - - -async def guard_request(id): - if (result := _guarded.get(id, None)): - last, currently = result - if currently: - return False - else: - _guarded[id] = (last, True) - await asyncio.sleep(_guard_delay - (time.time() - last)) - _guarded[id] = (time.time(), False) - return True - else: - _guarded[id] = (time.time(), False) - return True - - -async def update_last_status(self): - """ - Update the last posted status message, if it exists. - """ - old_message = self.reaction_message - - if not await guard_request(self.channelid): - return - if old_message != self.reaction_message: - return - - args = await self.status() - repost = True - if self.reaction_message: - try: - await edit_files( - client._connection.http, - self.reaction_message.channel.id, - self.reaction_message.id, - **args - ) - except discord.HTTPException: - pass - else: - repost = False - - if repost and self.text_channel: - try: - self.reaction_message = await self.text_channel.send(**args) - await self.reaction_message.add_reaction('✅') - except discord.HTTPException: - pass - return - - -guild_locks = defaultdict(asyncio.Lock) - - -async def play_alert(channel: discord.VoiceChannel, alert_file): - if not channel.members: - # Don't notify an empty channel - return - - async with guild_locks[channel.guild.id]: - try: - vc = channel.guild.voice_client - if not vc: - vc = await asyncio.wait_for( - channel.connect(timeout=10, reconnect=False), - 20 - ) - elif vc.channel != channel: - await vc.move_to(channel) - except asyncio.TimeoutError: - client.log( - f"Timed out while attempting to connect to '{channel.name}' (cid:{channel.id}) " - f"in '{channel.guild.name}' (gid:{channel.guild.id}).", - context="TIMER_ALERT", - level=logging.WARNING - ) - vc = channel.guild.voice_client - if vc: - await vc.disconnect(force=True) - return - - audio_stream = open(alert_file, 'rb') - try: - vc.play(discord.PCMAudio(audio_stream), after=lambda e: audio_stream.close()) - except discord.HTTPException: - pass - - count = 0 - while vc.is_playing() and count < 10: - await asyncio.sleep(1) - count += 1 - - await vc.disconnect(force=True) - - -async def notify_hook(self, old_stage, new_stage): - try: - if new_stage.name == 'BREAK': - await play_alert(self.channel, asset_path('timer/voice/break_alert.wav')) - else: - await play_alert(self.channel, asset_path('timer/voice/focus_alert.wav')) - except Exception: - full_traceback = traceback.format_exc() - client.log( - f"Caught an unhandled exception while playing timer alert in '{self.channel.name}' (cid:{self.channel.id})" - f" in '{self.channel.guild.name}' (gid:{self.channel.guild.id}).\n" - f"{full_traceback}", - context="TIMER_ALERT", - level=logging.ERROR - ) - -Timer.status = status -Timer.update_last_status = update_last_status -Timer.notify_hook = notify_hook diff --git a/src/modules/pending-rewrite/gui-commands/weekly_test.py b/src/modules/pending-rewrite/gui-commands/weekly_test.py deleted file mode 100644 index 0ad63048..00000000 --- a/src/modules/pending-rewrite/gui-commands/weekly_test.py +++ /dev/null @@ -1,43 +0,0 @@ -import importlib -from datetime import datetime, timedelta - -from data.conditions import GEQ - -from ..module import module - -from .. import drawing -from ..utils import get_avatar, image_as_file - - -@module.cmd( - 'tasktest' -) -async def cmd_tasktest(ctx): - importlib.reload(drawing.weekly) - WeeklyStatsPage = drawing.weekly.WeeklyStatsPage - - day_start = ctx.alion.day_start - last_week_start = day_start - timedelta(days=7 + day_start.weekday()) - - history = ctx.client.data.session_history.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - start_time=GEQ(last_week_start - timedelta(days=1)), - select_columns=( - "start_time", - "(start_time + duration * interval '1 second') AS end_time" - ), - _extra="ORDER BY start_time ASC" - ) - timezone = ctx.alion.timezone - sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history] - - page = WeeklyStatsPage( - ctx.author.name, - f"#{ctx.author.discriminator}", - sessions, - day_start - ) - image = page.draw() - - await ctx.reply(file=image_as_file(image, 'weekly_stats.png')) diff --git a/src/modules/pending-rewrite/guide/__init__.py b/src/modules/pending-rewrite/guide/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/modules/pending-rewrite/guild_admin/reaction_roles/__init__.py b/src/modules/pending-rewrite/guild_admin/reaction_roles/__init__.py deleted file mode 100644 index 22235f67..00000000 --- a/src/modules/pending-rewrite/guild_admin/reaction_roles/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .module import module - -from . import data -from . import settings -from . import tracker -from . import command diff --git a/src/modules/pending-rewrite/guild_admin/reaction_roles/command.py b/src/modules/pending-rewrite/guild_admin/reaction_roles/command.py deleted file mode 100644 index ab9de7b1..00000000 --- a/src/modules/pending-rewrite/guild_admin/reaction_roles/command.py +++ /dev/null @@ -1,943 +0,0 @@ -import asyncio -import discord -from discord import PartialEmoji - -from cmdClient.lib import ResponseTimedOut, UserCancelled -from wards import guild_admin -from settings import UserInputError -from utils.lib import tick, cross - -from .module import module -from .tracker import ReactionRoleMessage -from .data import reaction_role_reactions, reaction_role_messages -from . import settings - - -example_emoji = "🧮" -example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine" - - -def _parse_messageref(ctx): - """ - Parse a message reference from the context message and return it. - Removes the parsed string from `ctx.args` if applicable. - Supports the following reference types, in precedence order: - - A Discord message reply reference. - - A message link. - - A message id. - - Returns: (channelid, messageid) - `messageid` will be `None` if a valid reference was not found. - `channelid` will be `None` if the message was provided by pure id. - """ - target_id = None - target_chid = None - - if ctx.msg.reference: - # True message reference extract message and return - target_id = ctx.msg.reference.message_id - target_chid = ctx.msg.reference.channel_id - elif ctx.args: - # Parse the first word of the message arguments - splits = ctx.args.split(maxsplit=1) - maybe_target = splits[0] - - # Expect a message id or message link - if maybe_target.isdigit(): - # Assume it is a message id - target_id = int(maybe_target) - elif '/' in maybe_target: - # Assume it is a link - # Split out the channelid and messageid, if possible - link_splits = maybe_target.rsplit('/', maxsplit=2) - if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit(): - target_id = int(link_splits[-1]) - target_chid = int(link_splits[-2]) - - # If we found a target id, truncate the arguments - if target_id is not None: - if len(splits) > 1: - ctx.args = splits[1].strip() - else: - ctx.args = "" - else: - # Last-ditch attempt, see if the argument could be a stored reaction - maybe_emoji = maybe_target.strip(',') - guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id) - messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows] - emojis = {reaction.emoji: message for message in messages for reaction in message.reactions} - emoji_name_map = {emoji.name.lower(): emoji for emoji in emojis} - emoji_id_map = {emoji.id: emoji for emoji in emojis if emoji.id} - result = _parse_emoji(maybe_emoji, emoji_name_map, emoji_id_map) - if result and result in emojis: - message = emojis[result] - target_id = message.messageid - target_chid = message.data.channelid - - # Return the message reference - return (target_chid, target_id) - - -def _parse_emoji(emoji_str, name_map, id_map): - """ - Extract a PartialEmoji from a user provided emoji string, given the accepted raw names and ids. - """ - emoji = None - if len(emoji_str) < 10 and all(ord(char) >= 256 for char in emoji_str): - # The string is pure unicode, we assume built in emoji - emoji = PartialEmoji(name=emoji_str) - elif emoji_str.lower() in name_map: - emoji = name_map[emoji_str.lower()] - elif emoji_str.isdigit() and int(emoji_str) in id_map: - emoji = id_map[int(emoji_str)] - else: - # Attempt to parse as custom emoji - # Accept custom emoji provided in the full form - emoji_split = emoji_str.strip('<>:').split(':') - if len(emoji_split) in (2, 3) and emoji_split[-1].isdigit(): - emoji_id = int(emoji_split[-1]) - emoji_name = emoji_split[-2] - emoji_animated = emoji_split[0] == 'a' - emoji = PartialEmoji( - name=emoji_name, - id=emoji_id, - animated=emoji_animated - ) - return emoji - - -async def reaction_ask(ctx, question, timeout=120, timeout_msg=None, cancel_msg=None): - """ - Asks the author the provided question in an embed, and provides check/cross reactions for answering. - """ - embed = discord.Embed( - colour=discord.Colour.orange(), - description=question - ) - out_msg = await ctx.reply(embed=embed) - - # Wait for a tick/cross - asyncio.create_task(out_msg.add_reaction(tick)) - asyncio.create_task(out_msg.add_reaction(cross)) - - def check(reaction, user): - result = True - result = result and reaction.message == out_msg - result = result and user == ctx.author - result = result and (reaction.emoji == tick or reaction.emoji == cross) - return result - - try: - reaction, _ = await ctx.client.wait_for( - 'reaction_add', - check=check, - timeout=120 - ) - except asyncio.TimeoutError: - try: - await out_msg.edit( - embed=discord.Embed( - colour=discord.Colour.red(), - description=timeout_msg or "Prompt timed out." - ) - ) - except discord.HTTPException: - pass - raise ResponseTimedOut from None - if reaction.emoji == cross: - try: - await out_msg.edit( - embed=discord.Embed( - colour=discord.Colour.red(), - description=cancel_msg or "Cancelled." - ) - ) - except discord.HTTPException: - pass - raise UserCancelled from None - - try: - await out_msg.delete() - except discord.HTTPException: - pass - - return True - - -_message_setting_flags = { - 'removable': settings.removable, - 'maximum': settings.maximum, - 'required_role': settings.required_role, - 'log': settings.log, - 'refunds': settings.refunds, - 'default_price': settings.default_price, -} -_reaction_setting_flags = { - 'price': settings.price, - 'duration': settings.duration -} - - -@module.cmd( - "reactionroles", - group="Guild Configuration", - desc="Create and configure reaction role messages.", - aliases=('rroles',), - flags=( - 'delete', 'remove==', - 'enable', 'disable', - 'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=', - 'price=', 'duration==' - ) -) -@guild_admin() -async def cmd_reactionroles(ctx, flags): - """ - Usage``: - {prefix}rroles - {prefix}rroles [enable|disable|delete] msglink - {prefix}rroles msglink [emoji1 role1, emoji2 role2, ...] - {prefix}rroles msglink --remove emoji1, emoji2, ... - {prefix}rroles msglink --message_setting [value] - {prefix}rroles msglink emoji --reaction_setting [value] - Description: - Create and configure "reaction roles", i.e. roles obtainable by \ - clicking reactions on a particular message. - `msglink` is the link or message id of the message with reactions. - `emoji` should be given as the emoji itself, or the name or id. - `role` may be given by name, mention, or id. - Getting started: - First choose the message you want to add reaction roles to, \ - and copy the link or message id for that message. \ - Then run the command `{prefix}rroles link`, replacing `link` with the copied link, \ - and follow the prompts. - For faster setup, use `{prefix}rroles link emoji1 role1, emoji2 role2` instead. - Editing reaction roles: - Remove roles with `{prefix}rroles link --remove emoji1, emoji2, ...` - Add/edit roles with `{prefix}rroles link emoji1 role1, emoji2 role2, ...` - Examples``: - {prefix}rroles {ctx.msg.id} 🧮 mathematics, 🫀 biology, 🩺 medicine - {prefix}rroles disable {ctx.msg.id} - PAGEBREAK: - Page 2 - Advanced configuration: - Type `{prefix}rroles link` again to view the advanced setting window, \ - and use `{prefix}rroles link --setting value` to modify the settings. \ - See below for descriptions of each message setting. - For example to disable event logging, run `{prefix}rroles link --log off`. - - For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`. - - *(!) Replace `setting` with one of the settings below!* - Message Settings:: - maximum: Maximum number of roles obtainable from this message. - log: Whether to log reaction role usage into the event log. - removable: Whether the reactions roles can be remove by unreacting. - refunds: Whether to refund the role price when removing the role. - default_price: The default price of each role on this message. - required_role: The role required to use these reactions roles. - Reaction Settings:: - price: The price of this reaction role. (May be negative for a reward.) - tduration: How long this role will last after being selected or bought. - Configuration Examples``: - {prefix}rroles {ctx.msg.id} --maximum 5 - {prefix}rroles {ctx.msg.id} --default_price 20 - {prefix}rroles {ctx.msg.id} --required_role None - {prefix}rroles {ctx.msg.id} 🧮 --price 1024 - {prefix}rroles {ctx.msg.id} 🧮 --duration 7 days - """ - if not ctx.args: - # No target message provided, list the current reaction messages - # Or give a brief guide if there are no current reaction messages - guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id) - if guild_message_rows: - # List messages - - # First get the list of reaction role messages in the guild - messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows] - - # Sort them by channelid and messageid - messages.sort(key=lambda m: (m.data.channelid, m.messageid)) - - # Build the message description strings - message_strings = [] - for message in messages: - header = ( - "`{}` in <#{}> ([Click to jump]({})){}".format( - message.messageid, - message.data.channelid, - message.message_link, - " (disabled)" if not message.enabled else "" - ) - ) - role_strings = [ - "{} <@&{}>".format(reaction.emoji, reaction.data.roleid) - for reaction in message.reactions - ] - role_string = '\n'.join(role_strings) or "No reaction roles!" - - message_strings.append("{}\n{}".format(header, role_string)) - - pages = [] - page = [] - page_len = 0 - page_chars = 0 - i = 0 - while i < len(message_strings): - message_string = message_strings[i] - chars = len(message_string) - lines = len(message_string.splitlines()) - if (page and lines + page_len > 20) or (chars + page_chars > 2000): - pages.append('\n\n'.join(page)) - page = [] - page_len = 0 - page_chars = 0 - else: - page.append(message_string) - page_len += lines - page_chars += chars - i += 1 - if page: - pages.append('\n\n'.join(page)) - - page_count = len(pages) - title = "Reaction Roles in {}".format(ctx.guild.name) - embeds = [ - discord.Embed( - colour=discord.Colour.orange(), - description=page, - title=title - ) - for page in pages - ] - if page_count > 1: - [embed.set_footer(text="Page {} of {}".format(i + 1, page_count)) for i, embed in enumerate(embeds)] - await ctx.pager(embeds) - else: - # Send a setup guide - embed = discord.Embed( - title="No Reaction Roles set up!", - description=( - "To setup reaction roles, first copy the link or message id of the message you want to " - "add the roles to. Then run `{prefix}rroles link`, replacing `link` with the link you copied, " - "and follow the prompts.\n" - "See `{prefix}help rroles` for more information.".format(prefix=ctx.best_prefix) - ), - colour=discord.Colour.orange() - ) - await ctx.reply(embed=embed) - return - - # Extract first word, look for a subcommand - splits = ctx.args.split(maxsplit=1) - subcmd = splits[0].lower() - - if subcmd in ('enable', 'disable', 'delete'): - # Truncate arguments and extract target - if len(splits) > 1: - ctx.args = splits[1] - target_chid, target_id = _parse_messageref(ctx) - else: - target_chid = None - target_id = None - ctx.args = '' - - # Handle subcommand special cases - if subcmd == 'enable': - if ctx.args and not target_id: - await ctx.error_reply( - "Couldn't find the message to enable!\n" - "**Usage:** `{}rroles enable [message link or id]`.".format(ctx.best_prefix) - ) - elif not target_id: - # Confirm enabling of all reaction messages - await reaction_ask( - ctx, - "Are you sure you want to enable all reaction role messages in this server?", - timeout_msg="Prompt timed out, no reaction roles enabled.", - cancel_msg="User cancelled, no reaction roles enabled." - ) - reaction_role_messages.update_where( - {'enabled': True}, - guildid=ctx.guild.id - ) - await ctx.embed_reply( - "All reaction role messages have been enabled.", - colour=discord.Colour.green(), - ) - else: - # Fetch the target - target = ReactionRoleMessage.fetch(target_id) - if target is None: - await ctx.error_reply( - "This message doesn't have any reaction roles!\n" - "Run the command again without `enable` to assign reaction roles." - ) - else: - # We have a valid target - if target.enabled: - await ctx.error_reply( - "This message is already enabled!" - ) - else: - target.enabled = True - await ctx.embed_reply( - "The message has been enabled!" - ) - elif subcmd == 'disable': - if ctx.args and not target_id: - await ctx.error_reply( - "Couldn't find the message to disable!\n" - "**Usage:** `{}rroles disable [message link or id]`.".format(ctx.best_prefix) - ) - elif not target_id: - # Confirm disabling of all reaction messages - await reaction_ask( - ctx, - "Are you sure you want to disable all reaction role messages in this server?", - timeout_msg="Prompt timed out, no reaction roles disabled.", - cancel_msg="User cancelled, no reaction roles disabled." - ) - reaction_role_messages.update_where( - {'enabled': False}, - guildid=ctx.guild.id - ) - await ctx.embed_reply( - "All reaction role messages have been disabled.", - colour=discord.Colour.green(), - ) - else: - # Fetch the target - target = ReactionRoleMessage.fetch(target_id) - if target is None: - await ctx.error_reply( - "This message doesn't have any reaction roles! Nothing to disable." - ) - else: - # We have a valid target - if not target.enabled: - await ctx.error_reply( - "This message is already disabled!" - ) - else: - target.enabled = False - await ctx.embed_reply( - "The message has been disabled!" - ) - elif subcmd == 'delete': - if ctx.args and not target_id: - await ctx.error_reply( - "Couldn't find the message to remove!\n" - "**Usage:** `{}rroles remove [message link or id]`.".format(ctx.best_prefix) - ) - elif not target_id: - # Confirm disabling of all reaction messages - await reaction_ask( - ctx, - "Are you sure you want to remove all reaction role messages in this server?", - timeout_msg="Prompt timed out, no messages removed.", - cancel_msg="User cancelled, no messages removed." - ) - reaction_role_messages.delete_where( - guildid=ctx.guild.id - ) - await ctx.embed_reply( - "All reaction role messages have been removed.", - colour=discord.Colour.green(), - ) - else: - # Fetch the target - target = ReactionRoleMessage.fetch(target_id) - if target is None: - await ctx.error_reply( - "This message doesn't have any reaction roles! Nothing to remove." - ) - else: - # We have a valid target - target.delete() - await ctx.embed_reply( - "The message has been removed and is no longer a reaction role message." - ) - return - else: - # Just extract target - target_chid, target_id = _parse_messageref(ctx) - - # Handle target parsing issue - if target_id is None: - return await ctx.error_reply( - "Couldn't parse `{}` as a message id or message link!\n" - "See `{}help rroles` for detailed usage information.".format(ctx.args.split()[0], ctx.best_prefix) - ) - - # Get the associated ReactionRoleMessage, if it exists - target = ReactionRoleMessage.fetch(target_id) - - # Get the target message - if target: - message = await target.fetch_message() - if not message: - # TODO: Consider offering some sort of `move` option here. - await ctx.error_reply( - "This reaction role message no longer exists!\n" - "Use `{}rroles delete {}` to remove it from the list.".format(ctx.best_prefix, target.messageid) - ) - else: - message = None - if target_chid: - channel = ctx.guild.get_channel(target_chid) - if not channel: - await ctx.error_reply( - "The provided channel no longer exists!" - ) - elif not isinstance(channel, discord.TextChannel): - await ctx.error_reply( - "The provided channel is not a text channel!" - ) - else: - message = await channel.fetch_message(target_id) - if not message: - await ctx.error_reply( - "Couldn't find the specified message in {}!".format(channel.mention) - ) - else: - out_msg = await ctx.embed_reply("Searching for `{}`".format(target_id)) - message = await ctx.find_message(target_id) - try: - await out_msg.delete() - except discord.HTTPException: - pass - if not message: - await ctx.error_reply( - "Couldn't find the message `{}`!".format(target_id) - ) - if not message: - return - - # Handle the `remove` flag specially - # In particular, all other flags are ignored - if flags['remove']: - if not target: - await ctx.error_reply( - "The specified message has no reaction roles! Nothing to remove." - ) - else: - # Parse emojis and remove from target - target_emojis = {reaction.emoji: reaction for reaction in target.reactions} - emoji_name_map = {emoji.name.lower(): emoji for emoji in target_emojis} - emoji_id_map = {emoji.id: emoji for emoji in target_emojis} - - items = [item.strip() for item in flags['remove'].split(',')] - to_remove = [] # List of reactions to remove - for emoji_str in items: - emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map) - if emoji is None: - return await ctx.error_reply( - "Couldn't parse `{}` as an emoji! No reactions were removed.".format(emoji_str) - ) - if emoji not in target_emojis: - return await ctx.error_reply( - "{} is not a reaction role for this message!".format(emoji) - ) - to_remove.append(target_emojis[emoji]) - - # Delete reactions from data - description = '\n'.join("{} <@&{}>".format(reaction.emoji, reaction.data.roleid) for reaction in to_remove) - reaction_role_reactions.delete_where(reactionid=[reaction.reactionid for reaction in to_remove]) - target.refresh() - - # Ack - embed = discord.Embed( - colour=discord.Colour.green(), - title="Reaction Roles deactivated", - description=description - ) - await ctx.reply(embed=embed) - return - - # Any remaining arguments should be emoji specifications with optional role - # Parse these now - given_emojis = {} # Map PartialEmoji -> Optional[Role] - existing_emojis = set() # Set of existing reaction emoji identifiers - - if ctx.args: - # First build the list of custom emojis we can accept by name - # We do this by reverse precedence, so the highest priority emojis are added last - custom_emojis = [] - custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild - if target: - custom_emojis.extend([r.emoji for r in target.reactions]) # Configured reaction roles on the target - custom_emojis.extend([r.emoji for r in message.reactions if r.custom_emoji]) # Actual reactions on the message - - # Filter out the built in emojis and those without a name - custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id) - - # Build the maps to lookup provided custom emojis - emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis} - emoji_id_map = {emoji.id: emoji for emoji in custom_emojis} - - # Now parse the provided emojis - # Assume that all-unicode strings are built-in emojis - # We can't assume much else unless we have a list of such emojis - splits = (split.strip() for line in ctx.args.splitlines() for split in line.split(',') if split) - splits = (split.split(maxsplit=1) for split in splits if split) - arg_emoji_strings = { - split[0]: split[1] if len(split) > 1 else None - for split in splits - } # emoji_str -> Optional[role_str] - - arg_emoji_map = {} - for emoji_str, role_str in arg_emoji_strings.items(): - emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map) - if emoji is None: - return await ctx.error_reply( - "Couldn't parse `{}` as an emoji!".format(emoji_str) - ) - else: - arg_emoji_map[emoji] = role_str - - # Final pass extracts roles - # If any new emojis were provided, their roles should be specified, we enforce this during role parsing - # First collect the existing emoji strings - if target: - for reaction in target.reactions: - emoji_id = reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id - existing_emojis.add(emoji_id) - - # Now parse and assign the roles, building the final map - for emoji, role_str in arg_emoji_map.items(): - emoji_id = emoji.name if emoji.id is None else emoji.id - role = None - if role_str: - role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False) - elif emoji_id not in existing_emojis: - return await ctx.error_reply( - "New emoji {} was given without an associated role!".format(emoji) - ) - given_emojis[emoji] = role - - # Next manage target creation or emoji editing, if required - if target is None: - # Reaction message creation wizard - # Confirm that they want to create a new reaction role message. - await reaction_ask( - ctx, - question="Do you want to set up new reaction roles for [this message]({})?".format( - message.jump_url - ), - timeout_msg="Prompt timed out, no reaction roles created.", - cancel_msg="Reaction Role creation cancelled." - ) - - # Continue with creation - # Obtain emojis if not already provided - if not given_emojis: - # Prompt for the initial emojis - embed = discord.Embed( - colour=discord.Colour.orange(), - title="What reaction roles would you like to add?", - description=( - "Please now type the reaction roles you would like to add " - "in the form `emoji role`, where `role` is given by partial name or id. For example:" - "```{}```".format(example_str) - ) - ) - out_msg = await ctx.reply(embed=embed) - - # Wait for a response - def check(msg): - return msg.author == ctx.author and msg.channel == ctx.ch and msg.content - - try: - reply = await ctx.client.wait_for('message', check=check, timeout=300) - except asyncio.TimeoutError: - try: - await out_msg.edit( - embed=discord.Embed( - colour=discord.Colour.red(), - description="Prompt timed out, no reaction roles created." - ) - ) - except discord.HTTPException: - pass - return - - rolestrs = reply.content - - try: - await reply.delete() - except discord.HTTPException: - pass - - # Attempt to parse the emojis - # First build the list of custom emojis we can accept by name - custom_emojis = [] - custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild - custom_emojis.extend( - r.emoji for r in message.reactions if r.custom_emoji - ) # Actual reactions on the message - - # Filter out the built in emojis and those without a name - custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id) - - # Build the maps to lookup provided custom emojis - emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis} - emoji_id_map = {emoji.id: emoji for emoji in custom_emojis} - - # Now parse the provided emojis - # Assume that all-unicode strings are built-in emojis - # We can't assume much else unless we have a list of such emojis - splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(',') if split) - splits = (split.split(maxsplit=1) for split in splits if split) - arg_emoji_strings = { - split[0]: split[1] if len(split) > 1 else None - for split in splits - } # emoji_str -> Optional[role_str] - - # Check all the emojis have roles associated - for emoji_str, role_str in arg_emoji_strings.items(): - if role_str is None: - return await ctx.error_reply( - "No role provided for `{}`! Reaction role creation cancelled.".format(emoji_str) - ) - - # Parse the provided roles and emojis - for emoji_str, role_str in arg_emoji_strings.items(): - emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map) - if emoji is None: - return await ctx.error_reply( - "Couldn't parse `{}` as an emoji!".format(emoji_str) - ) - else: - given_emojis[emoji] = await ctx.find_role( - role_str, - create=True, - interactive=True, - allow_notfound=False - ) - - if len(given_emojis) > 20: - return await ctx.error_reply("A maximum of 20 reactions are possible per message! Cancelling creation.") - - # Create the ReactionRoleMessage - target = ReactionRoleMessage.create( - message.id, - message.guild.id, - message.channel.id - ) - - # Insert the reaction data directly - reaction_role_reactions.insert_many( - *((message.id, role.id, emoji.name, emoji.id, emoji.animated) for emoji, role in given_emojis.items()), - insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated') - ) - - # Refresh the message to pick up the new reactions - target.refresh() - - # Add the reactions to the message, if possible - existing_reactions = set( - reaction.emoji if not reaction.custom_emoji else - (reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id) - for reaction in message.reactions - ) - missing = [ - reaction.emoji for reaction in target.reactions - if (reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id) not in existing_reactions - ] - if not any(emoji.id not in set(cemoji.id for cemoji in ctx.guild.emojis) for emoji in missing if emoji.id): - # We can add the missing emojis - for emoji in missing: - try: - await message.add_reaction(emoji) - except discord.HTTPException: - break - else: - missing = [] - - # Ack the creation - ack_msg = "Created `{}` new reaction roles on [this message]({})!".format( - len(target.reactions), - target.message_link - ) - if missing: - ack_msg += "\nPlease add the missing reactions to the message!" - await ctx.embed_reply( - ack_msg - ) - elif given_emojis: - # Update the target reactions - # Create a map of the emojis that need to be added or updated - needs_update = { - emoji: role for emoji, role in given_emojis.items() if role - } - - # Fetch the existing target emojis to split the roles into inserts and updates - target_emojis = {reaction.emoji: reaction for reaction in target.reactions} - - # Handle the new roles - insert_targets = { - emoji: role for emoji, role in needs_update.items() if emoji not in target_emojis - } - if insert_targets: - if len(insert_targets) + len(target_emojis) > 20: - return await ctx.error_reply("Too many reactions! A maximum of 20 reactions are possible per message!") - reaction_role_reactions.insert_many( - *( - (message.id, role.id, emoji.name, emoji.id, emoji.animated) - for emoji, role in insert_targets.items() - ), - insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated') - ) - # Handle the updated roles - update_targets = { - target_emojis[emoji]: role for emoji, role in needs_update.items() if emoji in target_emojis - } - if update_targets: - reaction_role_reactions.update_many( - *((role.id, reaction.reactionid) for reaction, role in update_targets.items()), - set_keys=('roleid',), - where_keys=('reactionid',), - ) - - # Finally, refresh to load the new reactions - target.refresh() - - # Now that the target is created/updated, all the provided emojis should be reactions - given_reactions = [] - if given_emojis: - # Make a map of the existing reactions - existing_reactions = { - reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id: reaction - for reaction in target.reactions - } - given_reactions = [ - existing_reactions[emoji.name if emoji.id is None else emoji.id] - for emoji in given_emojis - ] - - # Handle message setting updates - update_lines = [] # Setting update lines to display - update_columns = {} # Message data columns to update - for flag in _message_setting_flags: - if flags[flag]: - setting_class = _message_setting_flags[flag] - try: - setting = await setting_class.parse(target.messageid, ctx, flags[flag]) - except UserInputError as e: - return await ctx.error_reply( - "{} {}\nNo settings were modified.".format(cross, e.msg), - title="Couldn't save settings!" - ) - else: - update_lines.append( - "{} {}".format(tick, setting.success_response) - ) - update_columns[setting._data_column] = setting.data - if update_columns: - # First write the data - reaction_role_messages.update_where( - update_columns, - messageid=target.messageid - ) - # Then ack the setting update - if len(update_lines) > 1: - embed = discord.Embed( - colour=discord.Colour.green(), - title="Reaction Role message settings updated!", - description='\n'.join(update_lines) - ) - else: - embed = discord.Embed( - colour=discord.Colour.green(), - description=update_lines[0] - ) - await ctx.reply(embed=embed) - - # Handle reaction setting updates - update_lines = [] # Setting update lines to display - update_columns = {} # Message data columns to update, for all given reactions - reactions = given_reactions or target.reactions - for flag in _reaction_setting_flags: - for reaction in reactions: - if flags[flag]: - setting_class = _reaction_setting_flags[flag] - try: - setting = await setting_class.parse(reaction.reactionid, ctx, flags[flag]) - except UserInputError as e: - return await ctx.error_reply( - "{} {}\nNo reaction roles were modified.".format(cross, e.msg), - title="Couldn't save reaction role settings!", - ) - else: - update_lines.append( - setting.success_response.format(reaction=reaction) - ) - update_columns[setting._data_column] = setting.data - if update_columns: - # First write the data - reaction_role_reactions.update_where( - update_columns, - reactionid=[reaction.reactionid for reaction in reactions] - ) - # Then ack the setting update - if len(update_lines) > 1: - blocks = ['\n'.join(update_lines[i:i+20]) for i in range(0, len(update_lines), 20)] - embeds = [ - discord.Embed( - colour=discord.Colour.green(), - title="Reaction Role settings updated!", - description=block - ) for block in blocks - ] - await ctx.pager(embeds) - else: - embed = discord.Embed( - colour=discord.Colour.green(), - description=update_lines[0] - ) - await ctx.reply(embed=embed) - - # Show the reaction role message summary - # Build the reaction fields - reaction_fields = [] # List of tuples (name, value) - for reaction in target.reactions: - reaction_fields.append( - ( - "{} {}".format(reaction.emoji.name, reaction.emoji if reaction.emoji.id else ''), - "<@&{}>\n{}".format(reaction.data.roleid, reaction.settings.tabulated()) - ) - ) - - # Build the final setting pages - description = ( - "{settings_table}\n" - "To update a message setting: `{prefix}rroles messageid --setting value`\n" - "To update an emoji setting: `{prefix}rroles messageid emoji --setting value`\n" - "See examples and more usage information with `{prefix}help rroles`.\n" - "**(!) Replace the `setting` with one of the settings on this page.**\n" - ).format( - prefix=ctx.best_prefix, - settings_table=target.settings.tabulated() - ) - - field_blocks = [reaction_fields[i:i+6] for i in range(0, len(reaction_fields), 6)] - page_count = len(field_blocks) - embeds = [] - for i, block in enumerate(field_blocks): - title = "Reaction role settings for message id `{}`".format(target.messageid) - embed = discord.Embed( - title=title, - description=description - ).set_author( - name="Click to jump to message", - url=target.message_link - ) - for name, value in block: - embed.add_field(name=name, value=value) - if page_count > 1: - embed.set_footer(text="Page {} of {}".format(i+1, page_count)) - embeds.append(embed) - - # Finally, send the reaction role information - await ctx.pager(embeds) diff --git a/src/modules/pending-rewrite/guild_admin/reaction_roles/data.py b/src/modules/pending-rewrite/guild_admin/reaction_roles/data.py deleted file mode 100644 index f5f03154..00000000 --- a/src/modules/pending-rewrite/guild_admin/reaction_roles/data.py +++ /dev/null @@ -1,22 +0,0 @@ -from data import Table, RowTable - - -reaction_role_messages = RowTable( - 'reaction_role_messages', - ('messageid', 'guildid', 'channelid', - 'enabled', - 'required_role', 'allow_deselction', - 'max_obtainable', 'allow_refunds', - 'event_log'), - 'messageid' -) - - -reaction_role_reactions = RowTable( - 'reaction_role_reactions', - ('reactionid', 'messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated', 'price', 'timeout'), - 'reactionid' -) - - -reaction_role_expiring = Table('reaction_role_expiring') diff --git a/src/modules/pending-rewrite/guild_admin/reaction_roles/expiry.py b/src/modules/pending-rewrite/guild_admin/reaction_roles/expiry.py deleted file mode 100644 index 7de2c592..00000000 --- a/src/modules/pending-rewrite/guild_admin/reaction_roles/expiry.py +++ /dev/null @@ -1,172 +0,0 @@ -import logging -import traceback -import asyncio -import discord - -from meta import client -from utils.lib import utc_now -from settings import GuildSettings - -from .module import module -from .data import reaction_role_expiring - -_expiring = {} -_wakeup_event = asyncio.Event() - - -# TODO: More efficient data structure for min optimisation, e.g. pre-sorted with bisection insert - - -# Public expiry interface -def schedule_expiry(guildid, userid, roleid, expiry, reactionid=None): - """ - Schedule expiry of the given role for the given member at the given time. - This will also cancel any existing expiry for this member, role pair. - """ - reaction_role_expiring.delete_where( - guildid=guildid, - userid=userid, - roleid=roleid, - ) - reaction_role_expiring.insert( - guildid=guildid, - userid=userid, - roleid=roleid, - expiry=expiry, - reactionid=reactionid - ) - key = (guildid, userid, roleid) - _expiring[key] = expiry.timestamp() - _wakeup_event.set() - - -def cancel_expiry(*key): - """ - Cancel expiry for the given member and role, if it exists. - """ - guildid, userid, roleid = key - reaction_role_expiring.delete_where( - guildid=guildid, - userid=userid, - roleid=roleid, - ) - if _expiring.pop(key, None) is not None: - # Wakeup the expiry tracker for recalculation - _wakeup_event.set() - - -def _next(): - """ - Calculate the next member, role pair to expire. - """ - if _expiring: - key, _ = min(_expiring.items(), key=lambda pair: pair[1]) - return key - else: - return None - - -async def _expire(key): - """ - Execute reaction role expiry for the given member and role. - This removes the role and logs the removal if applicable. - If the user is no longer in the guild, it removes the role from the persistent roles instead. - """ - guildid, userid, roleid = key - guild = client.get_guild(guildid) - if guild: - role = guild.get_role(roleid) - if role: - member = guild.get_member(userid) - if member: - log = GuildSettings(guildid).event_log.log - if role in member.roles: - # Remove role from member, and log if applicable - try: - await member.remove_roles( - role, - atomic=True, - reason="Expiring temporary reaction role." - ) - except discord.HTTPException: - log( - "Failed to remove expired reaction role {} from {}.".format( - role.mention, - member.mention - ), - colour=discord.Colour.red(), - title="Could not remove expired Reaction Role!" - ) - else: - log( - "Removing expired reaction role {} from {}.".format( - role.mention, - member.mention - ), - title="Reaction Role expired!" - ) - else: - # Remove role from stored persistent roles, if existent - client.data.past_member_roles.delete_where( - guildid=guildid, - userid=userid, - roleid=roleid - ) - reaction_role_expiring.delete_where( - guildid=guildid, - userid=userid, - roleid=roleid - ) - - -async def _expiry_tracker(client): - """ - Track and launch role expiry. - """ - while True: - try: - key = _next() - diff = _expiring[key] - utc_now().timestamp() if key else None - await asyncio.wait_for(_wakeup_event.wait(), timeout=diff) - except asyncio.TimeoutError: - # Timeout means next doesn't exist or is ready to expire - if key and key in _expiring and _expiring[key] <= utc_now().timestamp() + 1: - _expiring.pop(key) - asyncio.create_task(_expire(key)) - except Exception: - # This should be impossible, but catch and log anyway - client.log( - "Exception occurred while tracking reaction role expiry. Exception traceback follows.\n{}".format( - traceback.format_exc() - ), - context="REACTION_ROLE_EXPIRY", - level=logging.ERROR - ) - else: - # Wakeup event means that we should recalculate next - _wakeup_event.clear() - - -@module.launch_task -async def launch_expiry_tracker(client): - """ - Launch the role expiry tracker. - """ - asyncio.create_task(_expiry_tracker(client)) - client.log("Reaction role expiry tracker launched.", context="REACTION_ROLE_EXPIRY") - - -@module.init_task -def load_expiring_roles(client): - """ - Initialise the expiring reaction role map, and attach it to the client. - """ - rows = reaction_role_expiring.select_where() - _expiring.clear() - _expiring.update({(row['guildid'], row['userid'], row['roleid']): row['expiry'].timestamp() for row in rows}) - client.objects['expiring_reaction_roles'] = _expiring - if _expiring: - client.log( - "Loaded {} expiring reaction roles.".format(len(_expiring)), - context="REACTION_ROLE_EXPIRY" - ) diff --git a/src/modules/pending-rewrite/guild_admin/reaction_roles/module.py b/src/modules/pending-rewrite/guild_admin/reaction_roles/module.py deleted file mode 100644 index 23ed39b8..00000000 --- a/src/modules/pending-rewrite/guild_admin/reaction_roles/module.py +++ /dev/null @@ -1,3 +0,0 @@ -from LionModule import LionModule - -module = LionModule("Reaction_Roles") diff --git a/src/modules/pending-rewrite/guild_admin/reaction_roles/settings.py b/src/modules/pending-rewrite/guild_admin/reaction_roles/settings.py deleted file mode 100644 index 530eadbd..00000000 --- a/src/modules/pending-rewrite/guild_admin/reaction_roles/settings.py +++ /dev/null @@ -1,257 +0,0 @@ -from utils.lib import DotDict -from wards import guild_admin -from settings import ObjectSettings, ColumnData, Setting -import settings.setting_types as setting_types - -from .data import reaction_role_messages, reaction_role_reactions - - -class RoleMessageSettings(ObjectSettings): - settings = DotDict() - - -class RoleMessageSetting(ColumnData, Setting): - _table_interface = reaction_role_messages - _id_column = 'messageid' - _create_row = False - - write_ward = guild_admin - - -@RoleMessageSettings.attach_setting -class required_role(setting_types.Role, RoleMessageSetting): - attr_name = 'required_role' - _data_column = 'required_role' - - display_name = "required_role" - desc = "Role required to use the reaction roles." - - long_desc = ( - "Members will be required to have the specified role to use the reactions on this message." - ) - - @property - def success_response(self): - if self.value: - return "Members need {} to use these reaction roles.".format(self.formatted) - else: - return "All members can now use these reaction roles." - - @classmethod - def _get_guildid(cls, id: int, **kwargs): - return reaction_role_messages.fetch(id).guildid - - -@RoleMessageSettings.attach_setting -class removable(setting_types.Boolean, RoleMessageSetting): - attr_name = 'removable' - _data_column = 'removable' - - display_name = "removable" - desc = "Whether the role is removable by deselecting the reaction." - - long_desc = ( - "If enabled, the role will be removed when the reaction is deselected." - ) - - _default = True - - @property - def success_response(self): - if self.value: - return "Members will be able to remove roles by unreacting." - else: - return "Members will not be able to remove the reaction roles." - - -@RoleMessageSettings.attach_setting -class maximum(setting_types.Integer, RoleMessageSetting): - attr_name = 'maximum' - _data_column = 'maximum' - - display_name = "maximum" - desc = "The maximum number of roles a member can get from this message." - - long_desc = ( - "The maximum number of roles that a member can get from this message. " - "They will be notified by DM if they attempt to add more.\n" - "The `removable` setting should generally be enabled with this setting." - ) - - accepts = "An integer number of roles, or `None` to remove the maximum." - - _min = 0 - - @classmethod - def _format_data(cls, id, data, **kwargs): - if data is None: - return "No maximum!" - else: - return "`{}`".format(data) - - @property - def success_response(self): - if self.value: - return "Members can get a maximum of `{}` roles from this message.".format(self.value) - else: - return "Members can now get all the roles from this mesage." - - -@RoleMessageSettings.attach_setting -class refunds(setting_types.Boolean, RoleMessageSetting): - attr_name = 'refunds' - _data_column = 'refunds' - - display_name = "refunds" - desc = "Whether a user will be refunded when they deselect a role." - - long_desc = ( - "Whether to give the user a refund when they deselect a role by reaction. " - "This has no effect if `removable` is not enabled, or if the role removed has no cost." - ) - - _default = True - - @property - def success_response(self): - if self.value: - return "Members will get a refund when they remove a role." - else: - return "Members will not get a refund when they remove a role." - - -@RoleMessageSettings.attach_setting -class default_price(setting_types.Integer, RoleMessageSetting): - attr_name = 'default_price' - _data_column = 'default_price' - - display_name = "default_price" - desc = "Default price of reaction roles on this message." - - long_desc = ( - "Reaction roles on this message will have this cost if they do not have an individual price set." - ) - - accepts = "An integer number of coins. Use `0` or `None` to make roles free by default." - - _default = 0 - - @classmethod - def _format_data(cls, id, data, **kwargs): - if not data: - return "Free" - else: - return "`{}` coins".format(data) - - @property - def success_response(self): - if self.value: - return "Reaction roles on this message will cost `{}` coins by default.".format(self.value) - else: - return "Reaction roles on this message will be free by default." - - -@RoleMessageSettings.attach_setting -class log(setting_types.Boolean, RoleMessageSetting): - attr_name = 'log' - _data_column = 'event_log' - - display_name = "log" - desc = "Whether to log reaction role usage in the event log." - - long_desc = ( - "When enabled, roles added or removed with reactions will be logged in the configured event log." - ) - - _default = True - - @property - def success_response(self): - if self.value: - return "Role updates will now be logged." - else: - return "Role updates will not be logged." - - -class ReactionSettings(ObjectSettings): - settings = DotDict() - - -class ReactionSetting(ColumnData, Setting): - _table_interface = reaction_role_reactions - _id_column = 'reactionid' - _create_row = False - - write_ward = guild_admin - - -@ReactionSettings.attach_setting -class price(setting_types.Integer, ReactionSetting): - attr_name = 'price' - _data_column = 'price' - - display_name = "price" - desc = "Price of this reaction role (may be negative)." - - long_desc = ( - "The number of coins that will be deducted from the user when this reaction is used.\n" - "The number may be negative, in order to give a reward when the member choses the reaction." - ) - - accepts = "An integer number of coins. Use `0` to make the role free, or `None` to use the message default." - _max = 2 ** 20 - - @property - def default(self): - """ - The default price is given by the ReactionMessage price setting. - """ - return default_price.get(self._table_interface.fetch(self.id).messageid).value - - @classmethod - def _format_data(cls, id, data, **kwargs): - if not data: - return "Free" - else: - return "`{}` coins".format(data) - - @property - def success_response(self): - if self.value is not None: - return "{{reaction.emoji}} {{reaction.role.mention}} now costs `{}` coins.".format(self.value) - else: - return "{reaction.emoji} {reaction.role.mention} is now free." - - -@ReactionSettings.attach_setting -class duration(setting_types.Duration, ReactionSetting): - attr_name = 'duration' - _data_column = 'timeout' - - display_name = "duration" - desc = "How long this reaction role will last." - - long_desc = ( - "If set, the reaction role will be removed after the configured duration. " - "Note that this does not affect existing members with the role, or existing expiries." - ) - - _default_multiplier = 3600 - _show_days = True - _min = 600 - - @classmethod - def _format_data(cls, id, data, **kwargs): - if data is None: - return "Permanent" - else: - return super()._format_data(id, data, **kwargs) - - @property - def success_response(self): - if self.value is not None: - return "{{reaction.emoji}} {{reaction.role.mention}} will expire `{}` after selection.".format( - self.formatted - ) - else: - return "{reaction.emoji} {reaction.role.mention} will not expire." diff --git a/src/modules/pending-rewrite/guild_admin/reaction_roles/tracker.py b/src/modules/pending-rewrite/guild_admin/reaction_roles/tracker.py deleted file mode 100644 index 1ab71fe6..00000000 --- a/src/modules/pending-rewrite/guild_admin/reaction_roles/tracker.py +++ /dev/null @@ -1,590 +0,0 @@ -import asyncio -from codecs import ignore_errors -import logging -import traceback -import datetime -from collections import defaultdict -from typing import List, Mapping, Optional -from cachetools import LFUCache - -import discord -from discord import PartialEmoji - -from meta import client -from core import Lion -from data import Row -from data.conditions import THIS_SHARD -from utils.lib import utc_now -from settings import GuildSettings - -from ..module import module -from .data import reaction_role_messages, reaction_role_reactions -from .settings import RoleMessageSettings, ReactionSettings -from .expiry import schedule_expiry, cancel_expiry - - -class ReactionRoleReaction: - """ - Light data class representing a reaction role reaction. - """ - __slots__ = ('reactionid', '_emoji', '_message', '_role') - - def __init__(self, reactionid, message=None, **kwargs): - self.reactionid = reactionid - self._message: ReactionRoleMessage = None - self._role = None - self._emoji = None - - @classmethod - def create(cls, messageid, roleid, emoji: PartialEmoji, message=None, **kwargs) -> 'ReactionRoleReaction': - """ - Create a new ReactionRoleReaction with the provided attributes. - `emoji` sould be provided as a PartialEmoji. - `kwargs` are passed transparently to the `insert` method. - """ - row = reaction_role_reactions.create_row( - messageid=messageid, - roleid=roleid, - emoji_name=emoji.name, - emoji_id=emoji.id, - emoji_animated=emoji.animated, - **kwargs - ) - return cls(row.reactionid, message=message) - - @property - def emoji(self) -> PartialEmoji: - if self._emoji is None: - data = self.data - self._emoji = PartialEmoji( - name=data.emoji_name, - animated=data.emoji_animated, - id=data.emoji_id, - ) - return self._emoji - - @property - def data(self) -> Row: - return reaction_role_reactions.fetch(self.reactionid) - - @property - def settings(self) -> ReactionSettings: - return ReactionSettings(self.reactionid) - - @property - def reaction_message(self): - if self._message is None: - self._message = ReactionRoleMessage.fetch(self.data.messageid) - return self._message - - @property - def role(self): - if self._role is None: - guild = self.reaction_message.guild - if guild: - self._role = guild.get_role(self.data.roleid) - return self._role - - -class ReactionRoleMessage: - """ - Light data class representing a reaction role message. - Primarily acts as an interface to the corresponding Settings. - """ - __slots__ = ('messageid', '_message') - - # Full live messageid cache for this client. Should always be up to date. - _messages: Mapping[int, 'ReactionRoleMessage'] = {} # messageid -> associated Reaction message - - # Reaction cache for the live messages. Least frequently used, will be fetched on demand. - _reactions: Mapping[int, List[ReactionRoleReaction]] = LFUCache(1000) # messageid -> List of Reactions - - # User-keyed locks so we only handle one reaction per user at a time - _locks: Mapping[int, asyncio.Lock] = defaultdict(asyncio.Lock) # userid -> Lock - - def __init__(self, messageid): - self.messageid = messageid - self._message = None - - @classmethod - def fetch(cls, messageid) -> 'ReactionRoleMessage': - """ - Fetch the ReactionRoleMessage for the provided messageid. - Returns None if the messageid is not registered. - """ - # Since the cache is assumed to be always up to date, just pass to fetch-from-cache. - return cls._messages.get(messageid, None) - - @classmethod - def create(cls, messageid, guildid, channelid, **kwargs) -> 'ReactionRoleMessage': - """ - Create a ReactionRoleMessage with the given `messageid`. - Other `kwargs` are passed transparently to `insert`. - """ - # Insert the data - reaction_role_messages.create_row( - messageid=messageid, - guildid=guildid, - channelid=channelid, - **kwargs - ) - - # Create the ReactionRoleMessage - rmsg = cls(messageid) - - # Add to the global cache - cls._messages[messageid] = rmsg - - # Return the constructed ReactionRoleMessage - return rmsg - - def delete(self): - """ - Delete this ReactionRoleMessage. - """ - # Remove message from cache - self._messages.pop(self.messageid, None) - - # Remove reactions from cache - reactionids = [reaction.reactionid for reaction in self.reactions] - [self._reactions.pop(reactionid, None) for reactionid in reactionids] - - # Remove message from data - reaction_role_messages.delete_where(messageid=self.messageid) - - @property - def data(self) -> Row: - """ - Data row associated with this Message. - Passes directly to the RowTable cache. - Should not generally be used directly, use the settings interface instead. - """ - return reaction_role_messages.fetch(self.messageid) - - @property - def settings(self): - """ - RoleMessageSettings associated to this Message. - """ - return RoleMessageSettings(self.messageid) - - def refresh(self): - """ - Refresh the reaction cache for this message. - Returns the generated `ReactionRoleReaction`s for convenience. - """ - # Fetch reactions and pre-populate reaction cache - rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid, _extra="ORDER BY reactionid ASC") - reactions = [ReactionRoleReaction(row.reactionid) for row in rows] - self._reactions[self.messageid] = reactions - return reactions - - @property - def reactions(self) -> List[ReactionRoleReaction]: - """ - Returns the list of active reactions for this message, as `ReactionRoleReaction`s. - Lazily fetches the reactions from data if they have not been loaded. - """ - reactions = self._reactions.get(self.messageid, None) - if reactions is None: - reactions = self.refresh() - return reactions - - @property - def enabled(self) -> bool: - """ - Whether this Message is enabled. - Passes directly to data for efficiency. - """ - return self.data.enabled - - @enabled.setter - def enabled(self, value: bool): - self.data.enabled = value - - # Discord properties - @property - def guild(self) -> discord.Guild: - return client.get_guild(self.data.guildid) - - @property - def channel(self) -> discord.TextChannel: - return client.get_channel(self.data.channelid) - - async def fetch_message(self) -> discord.Message: - if self._message: - return self._message - - channel = self.channel - if channel: - try: - self._message = await channel.fetch_message(self.messageid) - return self._message - except discord.NotFound: - # The message no longer exists - # TODO: Cache and data cleanup? Or allow moving after death? - pass - - @property - def message(self) -> Optional[discord.Message]: - return self._message - - @property - def message_link(self) -> str: - """ - Jump link tho the reaction message. - """ - return 'https://discord.com/channels/{}/{}/{}'.format( - self.data.guildid, - self.data.channelid, - self.messageid - ) - - # Event handlers - async def process_raw_reaction_add(self, payload): - """ - Process a general reaction add payload. - """ - event_log = GuildSettings(self.guild.id).event_log - async with self._locks[payload.user_id]: - reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None) - if reaction: - # User pressed a live reaction. Process! - member = payload.member - lion = Lion.fetch(member.guild.id, member.id) - role = reaction.role - if reaction.role and (role not in member.roles): - # Required role check, make sure the user has the required role, if set. - required_role = self.settings.required_role.value - if required_role and required_role not in member.roles: - # Silently remove their reaction - try: - message = await self.fetch_message() - await message.remove_reaction( - payload.emoji, - member - ) - except discord.HTTPException: - pass - return - - # Maximum check, check whether the user already has too many roles from this message. - maximum = self.settings.maximum.value - if maximum is not None: - # Fetch the number of applicable roles the user has - roleids = set(reaction.data.roleid for reaction in self.reactions) - member_roleids = set(role.id for role in member.roles) - if len(roleids.intersection(member_roleids)) >= maximum: - # Notify the user - embed = discord.Embed( - title="Maximum group roles reached!", - description=( - "Couldn't give you **{}**, " - "because you already have `{}` roles from this group!".format( - role.name, - maximum - ) - ) - ) - # Silently try to notify the user - try: - await member.send(embed=embed) - except discord.HTTPException: - pass - # Silently remove the reaction - try: - message = await self.fetch_message() - await message.remove_reaction( - payload.emoji, - member - ) - except discord.HTTPException: - pass - - return - - # Economy hook, check whether the user can pay for the role. - price = reaction.settings.price.value - if price and price > lion.coins: - # They can't pay! - # Build the can't pay embed - embed = discord.Embed( - title="Insufficient funds!", - description="Sorry, **{}** costs `{}` coins, but you only have `{}`.".format( - role.name, - price, - lion.coins - ), - colour=discord.Colour.red() - ).set_footer( - icon_url=self.guild.icon_url, - text=self.guild.name - ).add_field( - name="Jump Back", - value="[Click here]({})".format(self.message_link) - ) - # Try to send them the embed, ignore errors - try: - await member.send( - embed=embed - ) - except discord.HTTPException: - pass - - # Remove their reaction, ignore errors - try: - message = await self.fetch_message() - await message.remove_reaction( - payload.emoji, - member - ) - except discord.HTTPException: - pass - - return - - # Add the role - try: - await member.add_roles( - role, - atomic=True, - reason="Adding reaction role." - ) - except discord.Forbidden: - event_log.log( - "Insufficient permissions to give {} the [reaction role]({}) {}".format( - member.mention, - self.message_link, - role.mention, - ), - title="Failed to add reaction role", - colour=discord.Colour.red() - ) - except discord.HTTPException: - event_log.log( - "Something went wrong while adding the [reaction role]({}) " - "{} to {}.".format( - self.message_link, - role.mention, - member.mention - ), - title="Failed to add reaction role", - colour=discord.Colour.red() - ) - client.log( - "Unexpected HTTPException encountered while adding '{}' (rid:{}) to " - "user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format( - role.name, - role.id, - member, - member.id, - member.guild.name, - member.guild.id, - traceback.format_exc() - ), - context="REACTION_ROLE_ADD", - level=logging.WARNING - ) - else: - # Charge the user and notify them, if the price is set - if price: - lion.addCoins(-price) - # Notify the user of their purchase - embed = discord.Embed( - title="Purchase successful!", - description="You have purchased **{}** for `{}` coins!".format( - role.name, - price - ), - colour=discord.Colour.green() - ).set_footer( - icon_url=self.guild.icon_url, - text=self.guild.name - ).add_field( - name="Jump Back", - value="[Click Here]({})".format(self.message_link) - ) - try: - await member.send(embed=embed) - except discord.HTTPException: - pass - - # Schedule the expiry, if required - duration = reaction.settings.duration.value - if duration: - expiry = utc_now() + datetime.timedelta(seconds=duration) - schedule_expiry(self.guild.id, member.id, role.id, expiry, reaction.reactionid) - else: - expiry = None - - # Log the role modification if required - if self.settings.log.value: - event_log.log( - "Added [reaction role]({}) {} " - "to {}{}.{}".format( - self.message_link, - role.mention, - member.mention, - " for `{}` coins".format(price) if price else '', - "\nThis role will expire at .".format( - expiry.timestamp() - ) if expiry else '' - ), - title="Reaction Role Added" - ) - - async def process_raw_reaction_remove(self, payload): - """ - Process a general reaction remove payload. - """ - if self.settings.removable.value: - event_log = GuildSettings(self.guild.id).event_log - async with self._locks[payload.user_id]: - reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None) - if reaction: - # User removed a live reaction. Process! - member = self.guild.get_member(payload.user_id) - role = reaction.role - if member and not member.bot and role and (role in member.roles): - # Check whether they have the required role, if set - required_role = self.settings.required_role.value - if required_role and required_role not in member.roles: - # Ignore the reaction removal - return - - try: - await member.remove_roles( - role, - atomic=True, - reason="Removing reaction role." - ) - except discord.Forbidden: - event_log.log( - "Insufficient permissions to remove " - "the [reaction role]({}) {} from {}".format( - self.message_link, - role.mention, - member.mention, - ), - title="Failed to remove reaction role", - colour=discord.Colour.red() - ) - except discord.HTTPException: - event_log.log( - "Something went wrong while removing the [reaction role]({}) " - "{} from {}.".format( - self.message_link, - role.mention, - member.mention - ), - title="Failed to remove reaction role", - colour=discord.Colour.red() - ) - client.log( - "Unexpected HTTPException encountered while removing '{}' (rid:{}) from " - "user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format( - role.name, - role.id, - member, - member.id, - member.guild.name, - member.guild.id, - traceback.format_exc() - ), - context="REACTION_ROLE_RM", - level=logging.WARNING - ) - else: - # Economy hook, handle refund if required - price = reaction.settings.price.value - refund = self.settings.refunds.value - if price and refund: - # Give the user the refund - lion = Lion.fetch(self.guild.id, member.id) - lion.addCoins(price) - - # Notify the user - embed = discord.Embed( - title="Role sold", - description=( - "You sold the role **{}** for `{}` coins.".format( - role.name, - price - ) - ), - colour=discord.Colour.green() - ).set_footer( - icon_url=self.guild.icon_url, - text=self.guild.name - ).add_field( - name="Jump Back", - value="[Click Here]({})".format(self.message_link) - ) - try: - await member.send(embed=embed) - except discord.HTTPException: - pass - - # Log role removal if required - if self.settings.log.value: - event_log.log( - "Removed [reaction role]({}) {} " - "from {}.".format( - self.message_link, - role.mention, - member.mention - ), - title="Reaction Role Removed" - ) - - # Cancel any existing expiry - cancel_expiry(self.guild.id, member.id, role.id) - - -# TODO: Make all the embeds a bit nicer, and maybe make a consistent interface for them -# TODO: Handle RawMessageDelete event -# TODO: Handle permission errors when fetching message in config - -@client.add_after_event('raw_reaction_add') -async def reaction_role_add(client, payload): - reaction_message = ReactionRoleMessage.fetch(payload.message_id) - if payload.guild_id and payload.user_id != client.user.id and reaction_message and reaction_message.enabled: - try: - await reaction_message.process_raw_reaction_add(payload) - except Exception: - # Unknown exception, catch and log it. - client.log( - "Unhandled exception while handling reaction message payload: {}\n{}".format( - payload, - traceback.format_exc() - ), - context="REACTION_ROLE_ADD", - level=logging.ERROR - ) - - -@client.add_after_event('raw_reaction_remove') -async def reaction_role_remove(client, payload): - reaction_message = ReactionRoleMessage.fetch(payload.message_id) - if payload.guild_id and reaction_message and reaction_message.enabled: - try: - await reaction_message.process_raw_reaction_remove(payload) - except Exception: - # Unknown exception, catch and log it. - client.log( - "Unhandled exception while handling reaction message payload: {}\n{}".format( - payload, - traceback.format_exc() - ), - context="REACTION_ROLE_RM", - level=logging.ERROR - ) - - -@module.init_task -def load_reaction_roles(client): - """ - Load the ReactionRoleMessages. - """ - rows = reaction_role_messages.fetch_rows_where(guildid=THIS_SHARD) - ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows} diff --git a/src/modules/pending-rewrite/plugins/.gitignore b/src/modules/pending-rewrite/plugins/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/src/modules/pending-rewrite/renting/__init__.py b/src/modules/pending-rewrite/renting/__init__.py deleted file mode 100644 index 08eba06b..00000000 --- a/src/modules/pending-rewrite/renting/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .module import module - -from . import commands -from . import rooms -from . import admin diff --git a/src/modules/pending-rewrite/renting/admin.py b/src/modules/pending-rewrite/renting/admin.py deleted file mode 100644 index 826910a8..00000000 --- a/src/modules/pending-rewrite/renting/admin.py +++ /dev/null @@ -1,76 +0,0 @@ -import discord - -from settings import GuildSettings, GuildSetting -import settings - - -@GuildSettings.attach_setting -class rent_category(settings.Channel, GuildSetting): - category = "Rented Rooms" - - attr_name = "rent_category" - _data_column = "renting_category" - - display_name = "rent_category" - desc = "Category in which members can rent their own study rooms." - - _default = None - - long_desc = ( - "Members can use the `rent` command to " - "buy the use of a new private voice channel in this category for `24h`." - ) - _accepts = "A category channel." - - _chan_type = discord.ChannelType.category - - @property - def success_response(self): - if self.value: - return "Members may now rent private voice channels under **{}**.".format(self.value.name) - else: - return "Members may no longer rent private voice channels." - - -@GuildSettings.attach_setting -class rent_member_limit(settings.Integer, GuildSetting): - category = "Rented Rooms" - - attr_name = "rent_member_limit" - _data_column = "renting_cap" - - display_name = "rent_member_limit" - desc = "Maximum number of people that can be added to a rented room." - - _default = 24 - - long_desc = ( - "Maximum number of people a member can add to a rented private voice channel." - ) - _accepts = "An integer number of members." - - @property - def success_response(self): - return "Members will now be able to add at most `{}` people to their channel.".format(self.value) - - -@GuildSettings.attach_setting -class rent_room_price(settings.Integer, GuildSetting): - category = "Rented Rooms" - - attr_name = "rent_room_price" - _data_column = "renting_price" - - display_name = "rent_price" - desc = "Price of a privated voice channel." - - _default = 1000 - - long_desc = ( - "How much it costs for a member to rent a private voice channel." - ) - _accepts = "An integer number of coins." - - @property - def success_response(self): - return "Private voice channels now cost `{}` coins.".format(self.value) diff --git a/src/modules/pending-rewrite/renting/commands.py b/src/modules/pending-rewrite/renting/commands.py deleted file mode 100644 index 0683d90f..00000000 --- a/src/modules/pending-rewrite/renting/commands.py +++ /dev/null @@ -1,215 +0,0 @@ -import discord -from cmdClient.checks import in_guild - -from .module import module -from .rooms import Room - - -@module.cmd( - name="rent", - desc="Rent a private study room with your friends!", - group="Productivity", - aliases=('add',) -) -@in_guild() -async def cmd_rent(ctx): - """ - Usage``: - {prefix}rent - {prefix}rent @user1 @user2 @user3 ... - {prefix}rent add @user1 @user2 @user3 ... - {prefix}rent remove @user1 @user2 @user3 ... - Description: - Rent a private voice channel for 24 hours,\ - and invite up to `{ctx.guild_settings.rent_member_limit.value}` mentioned users. - Use `{prefix}rent add` and `{prefix}rent remove` to give/revoke access to your room. - - *Renting a private channel costs `{ctx.guild_settings.rent_room_price.value} LC`.* - """ - # TODO: More gracefully handle unexpected channel deletion - - # Check if the category is set up - if not ctx.guild_settings.rent_category.value: - return await ctx.error_reply( - "The private study channel category has not been set up! Please come back later." - ) - - # Fetch the members' room, if it exists - room = Room.fetch(ctx.guild.id, ctx.author.id) - - # Handle pre-deletion of the room - if room and not room.channel: - ctx.guild_settings.event_log.log( - title="Private study room not found!", - description="{}'s study room was deleted before it expired!".format(ctx.author.mention) - ) - room.delete() - room = None - - if room: - # Show room status, or add/remove remebers - lower = ctx.args.lower() - if ctx.msg.mentions and lower and (lower.startswith('-') or lower.startswith('remove')): - # Remove the mentioned members - - # Extract members to remove - current_memberids = set(room.memberids) - if ctx.author in ctx.msg.mentions: - return await ctx.error_reply( - "You can't remove yourself from your own room!" - ) - to_remove = ( - member for member in ctx.msg.mentions - if member.id in current_memberids and member.id != ctx.author.id - ) - to_remove = list(set(to_remove)) # Remove duplicates - - # Check if there are no members to remove - if not to_remove: - return await ctx.error_reply( - "None of these members have access to your study room!" - ) - - # Finally, remove the members from the room and ack - await room.remove_members(*to_remove) - - await ctx.embed_reply( - "The following members have been removed from your room:\n{}".format( - ', '.join(member.mention for member in to_remove) - ) - ) - elif lower == 'delete': - if await ctx.ask("Are you sure you want to delete your study room? No refunds given!"): - # TODO: Better deletion log - await room._execute() - await ctx.embed_reply("Private study room deleted.") - elif ctx.msg.mentions: - # Add the mentioned members - - # Extract members to add - current_memberids = set(room.memberids) - to_add = ( - member for member in ctx.msg.mentions - if member.id not in current_memberids and member.id != ctx.author.id - ) - to_add = list(set(to_add)) # Remove duplicates - - # Check if there are no members to add - if not to_add: - return await ctx.error_reply( - "All of these members already have access to your room!" - ) - - # Check that they didn't provide too many members - limit = ctx.guild_settings.rent_member_limit.value - if len(to_add) + len(current_memberids) > limit: - return await ctx.error_reply( - "Too many members! You can invite at most `{}` members to your room.".format( - limit - ) - ) - - # Finally, add the members to the room and ack - await room.add_members(*to_add) - - await ctx.embed_reply( - "The following members have been given access to your room:\n{}".format( - ', '.join(member.mention for member in to_add) - ) - ) - else: - # Show room status with hints for adding and removing members - # Ack command - embed = discord.Embed( - colour=discord.Colour.orange() - ).set_author( - name="{}'s private room".format(ctx.author.display_name), - icon_url=ctx.author.avatar_url - ).add_field( - name="Channel", - value=room.channel.mention - ).add_field( - name="Expires", - value="".format(room.timestamp) - ).add_field( - name="Members", - value=', '.join('<@{}>'.format(memberid) for memberid in room.memberids) or "None", - inline=False - ).set_footer( - text=( - "Use '{prefix}rent add @mention' and '{prefix}rent remove @mention'\n" - "to add and remove members.".format(prefix=ctx.best_prefix) - ), - icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png" - ) - await ctx.reply(embed=embed) - else: - if ctx.args: - # Rent a new room - - to_add = ( - member for member in ctx.msg.mentions if member != ctx.author - ) - to_add = list(set(to_add)) - - # Check that they provided at least one member - if not to_add: - return await ctx.error_reply( - "Please mention at least one user to add to your new room." - ) - - # Check that they didn't provide too many members - limit = ctx.guild_settings.rent_member_limit.value - if len(ctx.msg.mentions) > limit: - return await ctx.error_reply( - "Too many members! You can invite at most `{}` members to your room.".format( - limit - ) - ) - - # Check that they have enough money for this - cost = ctx.guild_settings.rent_room_price.value - if ctx.alion.coins < cost: - return await ctx.error_reply( - "Sorry, a private room costs `{}` coins, but you only have `{}`.".format( - cost, - ctx.alion.coins - ) - ) - - # Create the room - room = await Room.create(ctx.author, to_add) - - # Deduct cost - ctx.alion.addCoins(-cost) - - # Ack command - embed = discord.Embed( - colour=discord.Colour.orange(), - title="Private study room rented!", - ).add_field( - name="Channel", - value=room.channel.mention - ).add_field( - name="Expires", - value="".format(room.timestamp) - ).add_field( - name="Members", - value=', '.join(member.mention for member in to_add), - inline=False - ).set_footer( - text="See your room status at any time with {prefix}rent".format(prefix=ctx.best_prefix), - icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png" - ) - await ctx.reply(embed=embed) - else: - # Suggest they get a room - await ctx.embed_reply( - "Rent a private study room for 24 hours with up to `{}` " - "friends by mentioning them with this command! (Rooms cost `{}` LionCoins.)\n" - "`{}rent @user1 @user2 ...`".format( - ctx.guild_settings.rent_member_limit.value, - ctx.guild_settings.rent_room_price.value, - ctx.best_prefix, - ) - ) diff --git a/src/modules/pending-rewrite/renting/data.py b/src/modules/pending-rewrite/renting/data.py deleted file mode 100644 index e0b3dfc6..00000000 --- a/src/modules/pending-rewrite/renting/data.py +++ /dev/null @@ -1,11 +0,0 @@ -from data import RowTable, Table - - -rented = RowTable( - 'rented', - ('channelid', 'guildid', 'ownerid', 'expires_at', 'created_at'), - 'channelid' -) - - -rented_members = Table('rented_members') diff --git a/src/modules/pending-rewrite/renting/module.py b/src/modules/pending-rewrite/renting/module.py deleted file mode 100644 index f4ab6a51..00000000 --- a/src/modules/pending-rewrite/renting/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from LionModule import LionModule - - -module = LionModule("Rented Rooms") diff --git a/src/modules/pending-rewrite/renting/rooms.py b/src/modules/pending-rewrite/renting/rooms.py deleted file mode 100644 index a8c29876..00000000 --- a/src/modules/pending-rewrite/renting/rooms.py +++ /dev/null @@ -1,321 +0,0 @@ -import discord -import asyncio -import datetime - -from cmdClient.lib import SafeCancellation - -from meta import client -from data.conditions import THIS_SHARD -from settings import GuildSettings - -from .data import rented, rented_members -from .module import module - - -class Room: - __slots__ = ('key', 'map_key', '_task') - - everyone_overwrite = discord.PermissionOverwrite( - view_channel=False - ) - owner_overwrite = discord.PermissionOverwrite( - view_channel=True, - connect=True, - priority_speaker=True - ) - member_overwrite = discord.PermissionOverwrite( - view_channel=True, - connect=True, - ) - - _table = rented - - _rooms = {} # map (guildid, userid) -> Room - - def __init__(self, channelid): - self.key = channelid - self.map_key = (self.data.guildid, self.data.ownerid) - - self._task = None - - @classmethod - async def create(cls, owner: discord.Member, initial_members): - ownerid = owner.id - guild = owner.guild - guildid = guild.id - guild_settings = GuildSettings(guildid) - - category = guild_settings.rent_category.value - if not category: - # This should never happen - return SafeCancellation("Rent category not set up!") - - # First create the channel, with the needed overrides - overwrites = { - guild.default_role: cls.everyone_overwrite, - owner: cls.owner_overwrite - } - overwrites.update( - {member: cls.member_overwrite for member in initial_members} - ) - try: - channel = await guild.create_voice_channel( - name="{}'s private channel".format(owner.name), - overwrites=overwrites, - category=category - ) - channelid = channel.id - except discord.HTTPException: - guild_settings.event_log.log( - description="Failed to create a private room for {}!".format(owner.mention), - colour=discord.Colour.red() - ) - raise SafeCancellation("Couldn't create the private channel! Please try again later.") - - # Add the new room to data - cls._table.create_row( - channelid=channelid, - guildid=guildid, - ownerid=ownerid - ) - - # Add the members to data, if any - if initial_members: - rented_members.insert_many( - *((channelid, member.id) for member in initial_members) - ) - - # Log the creation - guild_settings.event_log.log( - title="New private study room!", - description="Created a private study room for {} with:\n{}".format( - owner.mention, - ', '.join(member.mention for member in initial_members) - ) - ) - - # Create the room, schedule its expiry, and return - room = cls(channelid) - room.schedule() - return room - - @classmethod - def fetch(cls, guildid, userid): - """ - Fetch a Room owned by a given member. - """ - return cls._rooms.get((guildid, userid), None) - - @property - def data(self): - return self._table.fetch(self.key) - - @property - def owner(self): - """ - The Member owning the room. - May be `None` if the member is no longer in the guild, or is otherwise not visible. - """ - guild = client.get_guild(self.data.guildid) - if guild: - return guild.get_member(self.data.ownerid) - - @property - def channel(self): - """ - The Channel corresponding to this rented room. - May be `None` if the channel was already deleted. - """ - guild = client.get_guild(self.data.guildid) - if guild: - return guild.get_channel(self.key) - - @property - def memberids(self): - """ - The list of memberids in the channel. - """ - return [row['userid'] for row in rented_members.select_where(channelid=self.key)] - - @property - def timestamp(self): - """ - True unix timestamp for the room expiry time. - """ - return int(self.data.expires_at.replace(tzinfo=datetime.timezone.utc).timestamp()) - - def delete(self): - """ - Delete the room in an idempotent way. - """ - if self._task and not self._task.done(): - self._task.cancel() - self._rooms.pop(self.map_key, None) - self._table.delete_where(channelid=self.key) - - def schedule(self): - """ - Schedule this room to be expired. - """ - asyncio.create_task(self._schedule()) - self._rooms[self.map_key] = self - - async def _schedule(self): - """ - Expire the room after a sleep period. - """ - # Calculate time left - remaining = (self.data.expires_at - datetime.datetime.utcnow()).total_seconds() - - # Create the waiting task and wait for it, accepting cancellation - self._task = asyncio.create_task(asyncio.sleep(remaining)) - try: - await self._task - except asyncio.CancelledError: - return - await self._execute() - - async def _execute(self): - """ - Expire the room. - """ - guild_settings = GuildSettings(self.data.guildid) - - if self.channel: - # Delete the discord channel - try: - await self.channel.delete() - except discord.HTTPException: - pass - - guild_settings.event_log.log( - title="Private study room expired!", - description="<@{}>'s private study room expired.".format(self.data.ownerid) - ) - - # Delete the room from data (cascades to member deletion) - self.delete() - - async def add_members(self, *members): - guild_settings = GuildSettings(self.data.guildid) - - # Update overwrites - overwrites = self.channel.overwrites - overwrites.update({member: self.member_overwrite for member in members}) - try: - await self.channel.edit(overwrites=overwrites) - except discord.HTTPException: - guild_settings.event_log.log( - title="Failed to update study room permissions!", - description="An error occurred while adding the following users to the private room {}.\n{}".format( - self.channel.mention, - ', '.join(member.mention for member in members) - ), - colour=discord.Colour.red() - ) - raise SafeCancellation("Sorry, something went wrong while adding the members!") - - # Update data - rented_members.insert_many( - *((self.key, member.id) for member in members) - ) - - # Log - guild_settings.event_log.log( - title="New members added to private study room", - description="The following were added to {}.\n{}".format( - self.channel.mention, - ', '.join(member.mention for member in members) - ) - ) - - async def remove_members(self, *members): - guild_settings = GuildSettings(self.data.guildid) - - if self.channel: - # Update overwrites - try: - await asyncio.gather( - *(self.channel.set_permissions( - member, - overwrite=None, - reason="Removing members from private channel.") for member in members) - ) - except discord.HTTPException: - 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( - self.channel.mention, - ', '.join(member.mention for member in members) - ), - colour=discord.Colour.red() - ) - raise SafeCancellation("Sorry, something went wrong while removing those members!") - - # Disconnect members if possible: - to_disconnect = set(self.channel.members).intersection(members) - try: - await asyncio.gather( - *(member.edit(voice_channel=None) for member in to_disconnect) - ) - except discord.HTTPException: - pass - - # Update data - rented_members.delete_where(channelid=self.key, userid=[member.id for member in members]) - - # Log - guild_settings.event_log.log( - title="Members removed from a private study room", - description="The following were removed from {}.\n{}".format( - self.channel.mention if self.channel else "`{}`".format(self.key), - ', '.join(member.mention for member in members) - ) - ) - - -@module.launch_task -async def load_rented_rooms(client): - rows = rented.fetch_rows_where(guildid=THIS_SHARD) - for row in rows: - Room(row.channelid).schedule() - client.log( - "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/src/modules/pending-rewrite/stats/__init__.py b/src/modules/pending-rewrite/stats/__init__.py deleted file mode 100644 index d835a785..00000000 --- a/src/modules/pending-rewrite/stats/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# flake8: noqa -from .module import module - -from . import data -from . import profile -from . import setprofile -from . import top_cmd -from . import goals -from . import achievements diff --git a/src/modules/pending-rewrite/stats/achievements.py b/src/modules/pending-rewrite/stats/achievements.py deleted file mode 100644 index a4c15fbb..00000000 --- a/src/modules/pending-rewrite/stats/achievements.py +++ /dev/null @@ -1,485 +0,0 @@ -from typing import NamedTuple, Optional, Union -from datetime import timedelta - -import pytz -import discord - -from cmdClient.checks import in_guild -from LionContext import LionContext - -from meta import client, conf -from core import Lion -from data.conditions import NOTNULL, LEQ -from utils.lib import utc_now - -from modules.topgg.utils import topgg_upvote_link - -from .module import module - - -class AchievementLevel(NamedTuple): - name: str - threshold: Union[int, float] - emoji: discord.PartialEmoji - - -class Achievement: - """ - ABC for a member or user achievement. - """ - # Name of the achievement - name: str = None - - subtext: str = None - congrats_text: str = "Congratulations, you completed this challenge!" - - # List of levels for the achievement. Must always contain a 0 level! - levels: list[AchievementLevel] = None - - def __init__(self, guildid: int, userid: int): - self.guildid = guildid - self.userid = userid - - # Current status of the achievement. None until calculated by `update`. - self.value: int = None - - # Current level index in levels. None until calculated by `update`. - self.level_id: int = None - - @staticmethod - def progress_bar(value, minimum, maximum, width=10) -> str: - """ - Build a text progress bar representing `value` between `minimum` and `maximum`. - """ - emojis = conf.emojis - - proportion = (value - minimum) / (maximum - minimum) - sections = min(max(int(proportion * width), 0), width) - - bar = [] - # Starting segment - bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full)) - - # Full segments up to transition or end - if sections >= 2: - bar.append(str(emojis.progress_middle_full) * (sections - 2)) - - # Transition, if required - if 1 < sections < width: - bar.append(str(emojis.progress_middle_transition)) - - # Empty sections up to end - if sections < width: - bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1) - 1)) - - # End section - bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full)) - - # Join all the sections together and return - return ''.join(bar) - - @property - def progress_text(self) -> str: - """ - A brief textual description of the current progress. - Intended to be overridden by achievement implementations. - """ - return f"{int(self.value)}/{self.next_level.threshold if self.next_level else self.level.threshold}" - - def progress_field(self) -> tuple[str, str]: - """ - Builds the progress field for the achievement display. - """ - # TODO: Not adjusted for levels - # TODO: Add hint if progress is empty? - name = f"{self.levels[1].emoji} {self.name} ({self.progress_text})" - value = "**0** {progress_bar} **{threshold}**\n*{subtext}*".format( - subtext=(self.subtext if self.next_level else self.congrats_text) or '', - progress_bar=self.progress_bar(self.value, self.levels[0].threshold, self.levels[1].threshold), - threshold=self.levels[1].threshold - ) - return (name, value) - - @classmethod - async def fetch(cls, guildid: int, userid: int) -> 'Achievement': - """ - Fetch an Achievement status for the given member. - """ - return await cls(guildid, userid).update() - - @property - def level(self) -> AchievementLevel: - """ - The current `AchievementLevel` for this member achievement. - """ - if self.level_id is None: - raise ValueError("Cannot obtain level before first update!") - return self.levels[self.level_id] - - @property - def next_level(self) -> Optional[AchievementLevel]: - """ - The next `AchievementLevel` for this member achievement, - or `None` if it is at the maximum level. - """ - if self.level_id is None: - raise ValueError("Cannot obtain level before first update!") - - if self.level_id == len(self.levels) - 1: - return None - else: - return self.levels[self.level_id + 1] - - async def update(self) -> 'Achievement': - """ - Calculate and store the current member achievement status. - Returns `self` for easy chaining. - """ - # First fetch the value - self.value = await self._calculate_value() - - # Then determine the current level - # Using 0 as a fallback in case the value is negative - self.level_id = next( - (i for i, level in reversed(list(enumerate(self.levels))) if level.threshold <= self.value), - 0 - ) - - # And return `self` for chaining - return self - - async def _calculate_value(self) -> Union[int, float]: - """ - Calculate the current `value` of the member achievement. - Must be overridden by Achievement implementations. - """ - raise NotImplementedError - - -class Workout(Achievement): - sorting_index = 8 - emoji_index = 4 - name = "It's about Power" - subtext = "Workout 50 times" - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 50, conf.emojis.active_achievement_4), - ] - - async def _calculate_value(self) -> int: - """ - Returns the total number of workouts from this user. - """ - return client.data.workout_sessions.select_one_where( - userid=self.userid, - select_columns="COUNT(*)" - )[0] - - -class StudyHours(Achievement): - sorting_index = 1 - emoji_index = 1 - name = "Dream Big" - subtext = "Study a total of 1000 hours" - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_1), - ] - - async def _calculate_value(self) -> float: - """ - Returns the total number of hours this user has studied. - """ - past_session_total = client.data.session_history.select_one_where( - userid=self.userid, - select_columns="SUM(duration)" - )[0] or 0 - current_session_total = client.data.current_sessions.select_one_where( - userid=self.userid, - select_columns="SUM(EXTRACT(EPOCH FROM (NOW() - start_time)))" - )[0] or 0 - - session_total = past_session_total + current_session_total - hours = session_total / 3600 - return hours - - -class StudyStreak(Achievement): - sorting_index = 2 - emoji_index = 2 - name = "Consistency is Key" - subtext = "Reach a 100-day study streak" - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 100, conf.emojis.active_achievement_2) - ] - - async def _calculate_value(self) -> int: - """ - Return the user's maximum global study streak. - """ - lion = Lion.fetch(self.guildid, self.userid) - history = client.data.session_history.select_where( - userid=self.userid, - select_columns=( - "start_time", - "(start_time + duration * interval '1 second') AS end_time" - ), - _extra="ORDER BY start_time DESC" - ) - - # Streak statistics - streak = 0 - max_streak = 0 - - day_attended = True if 'sessions' in client.objects and lion.session else None - date = lion.day_start - daydiff = timedelta(days=1) - - periods = [(row['start_time'], row['end_time']) for row in history] - - i = 0 - while i < len(periods): - row = periods[i] - i += 1 - if row[1] > date: - # They attended this day - day_attended = True - continue - elif day_attended is None: - # Didn't attend today, but don't break streak - day_attended = False - date -= daydiff - i -= 1 - continue - elif not day_attended: - # Didn't attend the day, streak broken - date -= daydiff - i -= 1 - pass - else: - # Attended the day - streak += 1 - - # Move window to the previous day and try the row again - day_attended = False - prev_date = date - date -= daydiff - i -= 1 - - # Special case, when the last session started in the previous day - # Then the day is already attended - if i > 1 and date < periods[i-2][0] <= prev_date: - day_attended = True - - continue - - max_streak = max(max_streak, streak) - streak = 0 - - # Handle loop exit state, i.e. the last streak - if day_attended: - streak += 1 - max_streak = max(max_streak, streak) - - return max_streak - - -class Voting(Achievement): - sorting_index = 7 - emoji_index = 7 - name = "We're a Team" - subtext = "[Vote]({}) 100 times on top.gg".format(topgg_upvote_link) - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 100, conf.emojis.active_achievement_7) - ] - - async def _calculate_value(self) -> int: - """ - Returns the number of times the user has voted for the bot. - """ - return client.data.topgg.select_one_where( - userid=self.userid, - select_columns="COUNT(*)" - )[0] - - -class DaysStudying(Achievement): - sorting_index = 3 - emoji_index = 3 - name = "Aim For The Moon" - subtext = "Study on 90 different days" - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 90, conf.emojis.active_achievement_3) - ] - - async def _calculate_value(self) -> int: - """ - Returns the number of days the user has studied in total. - """ - lion = Lion.fetch(self.guildid, self.userid) - offset = int(lion.day_start.utcoffset().total_seconds()) - with client.data.session_history.conn as conn: - cursor = conn.cursor() - # TODO: Consider DST offset. - cursor.execute( - """ - SELECT - COUNT(DISTINCT(date_trunc('day', (time AT TIME ZONE 'utc') + interval '{} seconds'))) - FROM ( - (SELECT start_time AS time FROM session_history WHERE userid=%s) - UNION - (SELECT (start_time + duration * interval '1 second') AS time FROM session_history WHERE userid=%s) - ) AS times; - """.format(offset), - (self.userid, self.userid) - ) - data = cursor.fetchone() - return data[0] - - -class TasksComplete(Achievement): - sorting_index = 4 - emoji_index = 8 - name = "One Step at a Time" - subtext = "Complete 1000 tasks" - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_8) - ] - - async def _calculate_value(self) -> int: - """ - Returns the number of tasks the user has completed. - """ - return client.data.tasklist.select_one_where( - userid=self.userid, - completed_at=NOTNULL, - select_columns="COUNT(*)" - )[0] - - -class ScheduledSessions(Achievement): - sorting_index = 5 - emoji_index = 5 - name = "Be Accountable" - subtext = "Attend 500 scheduled sessions" - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 500, conf.emojis.active_achievement_5) - ] - - async def _calculate_value(self) -> int: - """ - Returns the number of scheduled sesions the user has attended. - """ - return client.data.accountability_member_info.select_one_where( - userid=self.userid, - start_at=LEQ(utc_now()), - select_columns="COUNT(*)", - _extra="AND (duration > 0 OR last_joined_at IS NOT NULL)" - )[0] - - -class MonthlyHours(Achievement): - sorting_index = 6 - emoji_index = 6 - name = "The 30 Days Challenge" - subtext = "Study 100 hours in 30 days" - - levels = [ - AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 100, conf.emojis.active_achievement_6) - ] - - async def _calculate_value(self) -> float: - """ - Returns the maximum number of hours the user has studied in a month. - """ - # Get the first session so we know how far back to look - first_session = client.data.session_history.select_one_where( - userid=self.userid, - select_columns="MIN(start_time)" - )[0] - - # Get the user's timezone - lion = Lion.fetch(self.guildid, self.userid) - - # If the first session doesn't exist, simulate an existing session (to avoid an extra lookup) - first_session = first_session or lion.day_start - timedelta(days=1) - - # Build the list of month start timestamps - month_start = lion.day_start.replace(day=1) - months = [month_start.astimezone(pytz.utc)] - - while month_start >= first_session: - month_start -= timedelta(days=1) - month_start = month_start.replace(day=1) - months.append(month_start.astimezone(pytz.utc)) - - # Query the study times - data = client.data.session_history.queries.study_times_since( - self.guildid, self.userid, *months - ) - cumulative_times = [row[0] or 0 for row in data] - times = [nxt - crt for nxt, crt in zip(cumulative_times[1:], cumulative_times[0:])] - max_time = max(cumulative_times[0], *times) if len(months) > 1 else cumulative_times[0] - - return max_time / 3600 - - -# Define the displayed achivement order -achievements = [ - Workout, - StudyHours, - StudyStreak, - Voting, - DaysStudying, - TasksComplete, - ScheduledSessions, - MonthlyHours -] - - -async def get_achievements_for(member, panel_sort=False): - status = [ - await ach.fetch(member.guild.id, member.id) - for ach in sorted(achievements, key=lambda cls: (cls.sorting_index if panel_sort else cls.emoji_index)) - ] - return status - - -@module.cmd( - name="achievements", - desc="View your progress towards the achievements!", - group="Statistics", -) -@in_guild() -async def cmd_achievements(ctx: LionContext): - """ - Usage``: - {prefix}achievements - Description: - View your progress towards attaining the achievement badges shown on your `profile`. - """ - status = await get_achievements_for(ctx.author, panel_sort=True) - - embed = discord.Embed( - title="Achievements", - colour=discord.Colour.orange() - ) - for achievement in status: - name, value = achievement.progress_field() - embed.add_field( - name=name, value=value, inline=False - ) - await ctx.reply(embed=embed) diff --git a/src/modules/pending-rewrite/stats/data.py b/src/modules/pending-rewrite/stats/data.py deleted file mode 100644 index 234b226c..00000000 --- a/src/modules/pending-rewrite/stats/data.py +++ /dev/null @@ -1,39 +0,0 @@ -from cachetools import TTLCache - -from data import Table, RowTable - - -profile_tags = Table('member_profile_tags', attach_as='profile_tags') - - -@profile_tags.save_query -def get_tags_for(guildid, userid): - rows = profile_tags.select_where( - guildid=guildid, userid=userid, - _extra="ORDER BY tagid ASC" - ) - return [row['tag'] for row in rows] - - -weekly_goals = RowTable( - 'member_weekly_goals', - ('guildid', 'userid', 'weekid', 'study_goal', 'task_goal'), - ('guildid', 'userid', 'weekid'), - cache=TTLCache(5000, 60 * 60 * 24), - attach_as='weekly_goals' -) - - -# NOTE: Not using a RowTable here since these will almost always be mass-selected -weekly_tasks = Table('member_weekly_goal_tasks') - - -monthly_goals = RowTable( - 'member_monthly_goals', - ('guildid', 'userid', 'monthid', 'study_goal', 'task_goal'), - ('guildid', 'userid', 'monthid'), - cache=TTLCache(5000, 60 * 60 * 24), - attach_as='monthly_goals' -) - -monthly_tasks = Table('member_monthly_goal_tasks') diff --git a/src/modules/pending-rewrite/stats/goals.py b/src/modules/pending-rewrite/stats/goals.py deleted file mode 100644 index cec8f911..00000000 --- a/src/modules/pending-rewrite/stats/goals.py +++ /dev/null @@ -1,332 +0,0 @@ -""" -Weekly and Monthly goal display and edit interface. -""" -from enum import Enum -import discord - -from cmdClient.checks import in_guild -from cmdClient.lib import SafeCancellation - -from utils.lib import parse_ranges - -from .module import module -from .data import weekly_goals, weekly_tasks, monthly_goals, monthly_tasks - - -MAX_LENGTH = 200 -MAX_TASKS = 10 - - -class GoalType(Enum): - WEEKLY = 0 - MONTHLY = 1 - - -def index_range_parser(userstr, max): - try: - indexes = parse_ranges(userstr) - except SafeCancellation: - raise SafeCancellation( - "Couldn't parse the provided task ids! " - "Please list the task numbers or ranges separated by a comma, e.g. `0, 2-4`." - ) from None - - return [index for index in indexes if index <= max] - - -@module.cmd( - "weeklygoals", - group="Statistics", - desc="Set your weekly goals and view your progress!", - aliases=('weeklygoal',), - flags=('study=', 'tasks=') -) -@in_guild() -async def cmd_weeklygoals(ctx, flags): - """ - Usage``: - {prefix}weeklygoals [--study ] [--tasks ] - {prefix}weeklygoals add - {prefix}weeklygoals edit - {prefix}weeklygoals check - {prefix}weeklygoals remove - Description: - Set yourself up to `10` goals for this week and keep yourself accountable! - Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. - You can also add multiple tasks at once by writing them on multiple lines. - - You can also track your progress towards a number of hours studied with `--study`, \ - and aim for a number of tasks completed with `--tasks`. - - Run the command with no arguments or check your profile to see your progress! - Examples``: - {prefix}weeklygoals add Read chapters 1 to 10. - {prefix}weeklygoals check 1 - {prefix}weeklygoals --study 48h --tasks 60 - """ - await goals_command(ctx, flags, GoalType.WEEKLY) - - -@module.cmd( - "monthlygoals", - group="Statistics", - desc="Set your monthly goals and view your progress!", - aliases=('monthlygoal',), - flags=('study=', 'tasks=') -) -@in_guild() -async def cmd_monthlygoals(ctx, flags): - """ - Usage``: - {prefix}monthlygoals [--study ] [--tasks ] - {prefix}monthlygoals add - {prefix}monthlygoals edit - {prefix}monthlygoals check - {prefix}monthlygoals uncheck - {prefix}monthlygoals remove - Description: - Set yourself up to `10` goals for this month and keep yourself accountable! - Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. - You can also add multiple tasks at once by writing them on multiple lines. - - You can also track your progress towards a number of hours studied with `--study`, \ - and aim for a number of tasks completed with `--tasks`. - - Run the command with no arguments or check your profile to see your progress! - Examples``: - {prefix}monthlygoals add Read chapters 1 to 10. - {prefix}monthlygoals check 1 - {prefix}monthlygoals --study 180h --tasks 60 - """ - await goals_command(ctx, flags, GoalType.MONTHLY) - - -async def goals_command(ctx, flags, goal_type): - prefix = ctx.best_prefix - if goal_type == GoalType.WEEKLY: - name = 'week' - goal_table = weekly_goals - task_table = weekly_tasks - rowkey = 'weekid' - rowid = ctx.alion.week_timestamp - - tasklist = task_table.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - weekid=rowid, - _extra="ORDER BY taskid ASC" - ) - - max_time = 7 * 16 - else: - name = 'month' - goal_table = monthly_goals - task_table = monthly_tasks - rowid = ctx.alion.month_timestamp - rowkey = 'monthid' - - tasklist = task_table.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - monthid=rowid, - _extra="ORDER BY taskid ASC" - ) - - max_time = 31 * 16 - - # We ensured the `lion` existed with `ctx.alion` above - # This also ensures a new tasklist can reference the period member goal key - # TODO: Should creation copy the previous existing week? - goal_row = goal_table.fetch_or_create((ctx.guild.id, ctx.author.id, rowid)) - - if flags['study']: - # Set study hour goal - time = flags['study'].lower().strip('h ') - if not time or not time.isdigit(): - return await ctx.error_reply( - f"Please provide your {name}ly study goal in hours!\n" - f"For example, `{prefix}{ctx.alias} --study 48h`" - ) - hours = int(time) - if hours > max_time: - return await ctx.error_reply( - "You can't set your goal this high! Please rest and keep a healthy lifestyle." - ) - - goal_row.study_goal = hours - - if flags['tasks']: - # Set tasks completed goal - count = flags['tasks'] - if not count or not count.isdigit(): - return await ctx.error_reply( - f"Please provide the number of tasks you want to complete this {name}!\n" - f"For example, `{prefix}{ctx.alias} --tasks 300`" - ) - if int(count) > 2048: - return await ctx.error_reply( - "Your task goal is too high!" - ) - goal_row.task_goal = int(count) - - if ctx.args: - # If there are arguments, assume task/goal management - # Extract the command if it exists, assume add operation if it doesn't - splits = ctx.args.split(maxsplit=1) - cmd = splits[0].lower().strip() - args = splits[1].strip() if len(splits) > 1 else '' - - if cmd in ('check', 'done', 'complete'): - if not args: - # Show subcommand usage - return await ctx.error_reply( - f"**Usage:**`{prefix}{ctx.alias} check `\n" - f"**Example:**`{prefix}{ctx.alias} check 0, 2-4`" - ) - if (indexes := index_range_parser(args, len(tasklist) - 1)): - # Check the given indexes - # If there are no valid indexes given, just do nothing and fall out to showing the goals - task_table.update_where( - {'completed': True}, - taskid=[tasklist[index]['taskid'] for index in indexes] - ) - elif cmd in ('uncheck', 'undone', 'uncomplete'): - if not args: - # Show subcommand usage - return await ctx.error_reply( - f"**Usage:**`{prefix}{ctx.alias} uncheck `\n" - f"**Example:**`{prefix}{ctx.alias} uncheck 0, 2-4`" - ) - if (indexes := index_range_parser(args, len(tasklist) - 1)): - # Check the given indexes - # If there are no valid indexes given, just do nothing and fall out to showing the goals - task_table.update_where( - {'completed': False}, - taskid=[tasklist[index]['taskid'] for index in indexes] - ) - elif cmd in ('remove', 'delete', '-', 'rm'): - if not args: - # Show subcommand usage - return await ctx.error_reply( - f"**Usage:**`{prefix}{ctx.alias} remove `\n" - f"**Example:**`{prefix}{ctx.alias} remove 0, 2-4`" - ) - if (indexes := index_range_parser(args, len(tasklist) - 1)): - # Delete the given indexes - # If there are no valid indexes given, just do nothing and fall out to showing the goals - task_table.delete_where( - taskid=[tasklist[index]['taskid'] for index in indexes] - ) - elif cmd == 'edit': - if not args or len(splits := args.split(maxsplit=1)) < 2 or not splits[0].isdigit(): - # Show subcommand usage - return await ctx.error_reply( - f"**Usage:**`{prefix}{ctx.alias} edit `\n" - f"**Example:**`{prefix}{ctx.alias} edit 2 Fix the scond task`" - ) - index = int(splits[0]) - new_content = splits[1].strip() - - if index >= len(tasklist): - return await ctx.error_reply( - f"Task `{index}` doesn't exist to edit!" - ) - - if len(new_content) > MAX_LENGTH: - return await ctx.error_reply( - f"Please keep your goals under `{MAX_LENGTH}` characters long." - ) - - # Passed all checks, edit task - task_table.update_where( - {'content': new_content}, - taskid=tasklist[index]['taskid'] - ) - else: - # Extract the tasks to add - if cmd in ('add', '+'): - if not args: - # Show subcommand usage - return await ctx.error_reply( - f"**Usage:**`{prefix}{ctx.alias} [add] `\n" - f"**Example:**`{prefix}{ctx.alias} add Read the Studylion help pages.`" - ) - else: - args = ctx.args - tasks = args.splitlines() - - # Check count - if len(tasklist) + len(tasks) > MAX_TASKS: - return await ctx.error_reply( - f"You can have at most **{MAX_TASKS}** {name}ly goals!" - ) - - # Check length - if any(len(task) > MAX_LENGTH for task in tasks): - return await ctx.error_reply( - f"Please keep your goals under `{MAX_LENGTH}` characters long." - ) - - # We passed the checks, add the tasks - to_insert = [ - (ctx.guild.id, ctx.author.id, rowid, task) - for task in tasks - ] - task_table.insert_many( - *to_insert, - insert_keys=('guildid', 'userid', rowkey, 'content') - ) - elif not any((goal_row.study_goal, goal_row.task_goal, tasklist)): - # The user hasn't set any goals for this time period - # Prompt them with information about how to set a goal - embed = discord.Embed( - colour=discord.Colour.orange(), - title=f"**You haven't set any goals for this {name} yet! Try the following:**\n" - ) - embed.add_field( - name="Aim for a number of study hours with", - value=f"`{prefix}{ctx.alias} --study 48h`" - ) - embed.add_field( - name="Aim for a number of tasks completed with", - value=f"`{prefix}{ctx.alias} --tasks 300`", - inline=False - ) - embed.add_field( - name=f"Set up to 10 custom goals for the {name}!", - value=( - f"`{prefix}{ctx.alias} add Write a 200 page thesis.`\n" - f"`{prefix}{ctx.alias} edit 1 Write 2 pages of the 200 page thesis.`\n" - f"`{prefix}{ctx.alias} done 0, 1, 3-4`\n" - f"`{prefix}{ctx.alias} delete 2-4`" - ), - inline=False - ) - return await ctx.reply(embed=embed) - - # Show the goals - if goal_type == GoalType.WEEKLY: - await display_weekly_goals_for(ctx) - else: - await display_monthly_goals_for(ctx) - - -async def display_weekly_goals_for(ctx): - """ - Display the user's weekly goal summary and progress towards them - TODO: Currently a stub, since the system is overidden by the GUI plugin - """ - # Collect data - lion = ctx.alion - rowid = lion.week_timestamp - goals = weekly_goals.fetch_or_create((ctx.guild.id, ctx.author.id, rowid)) - tasklist = weekly_tasks.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - weekid=rowid - ) - ... - - -async def display_monthly_goals_for(ctx): - ... diff --git a/src/modules/pending-rewrite/stats/module.py b/src/modules/pending-rewrite/stats/module.py deleted file mode 100644 index d820c4de..00000000 --- a/src/modules/pending-rewrite/stats/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from LionModule import LionModule - - -module = LionModule("Statistics") diff --git a/src/modules/pending-rewrite/stats/profile.py b/src/modules/pending-rewrite/stats/profile.py deleted file mode 100644 index 09239ad8..00000000 --- a/src/modules/pending-rewrite/stats/profile.py +++ /dev/null @@ -1,266 +0,0 @@ -from datetime import datetime, timedelta -import discord -from cmdClient.checks import in_guild - -from utils.lib import prop_tabulate, utc_now -from data import tables -from data.conditions import LEQ -from core import Lion - -from modules.study.tracking.data import session_history - -from .module import module - - -@module.cmd( - "stats", - group="Statistics", - desc="View your personal server study statistics!", - aliases=('profile',), - allow_before_ready=True -) -@in_guild() -async def cmd_stats(ctx): - """ - Usage``: - {prefix}stats - {prefix}stats - Description: - View the study statistics for yourself or the mentioned user. - """ - # Identify the target - if ctx.args: - if not ctx.msg.mentions: - return await ctx.error_reply("Please mention a user to view their statistics!") - target = ctx.msg.mentions[0] - else: - target = ctx.author - - # System sync - Lion.sync() - - # Fetch the required data - lion = Lion.fetch(ctx.guild.id, target.id) - - history = session_history.select_where( - guildid=ctx.guild.id, - userid=target.id, - select_columns=( - "start_time", - "(start_time + duration * interval '1 second') AS end_time" - ), - _extra="ORDER BY start_time DESC" - ) - - # Current economy balance (accounting for current session) - coins = lion.coins - season_time = lion.time - workout_total = lion.data.workout_count - - # Leaderboard ranks - exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.user_blacklist()) - exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) - if target.id in exclude: - time_rank = None - coin_rank = None - else: - time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0])) - - # Study time - # First get the all/month/week/day timestamps - day_start = lion.day_start - period_timestamps = ( - datetime(1970, 1, 1), - day_start.replace(day=1), - day_start - timedelta(days=day_start.weekday()), - day_start - ) - study_times = [0, 0, 0, 0] - for i, timestamp in enumerate(period_timestamps): - study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp) - if not study_time: - # So we don't make unecessary database calls - break - study_times[i] = study_time - - # Streak statistics - streak = 0 - current_streak = None - max_streak = 0 - - day_attended = True if 'sessions' in ctx.client.objects and lion.session else None - date = day_start - daydiff = timedelta(days=1) - - periods = [(row['start_time'], row['end_time']) for row in history] - - i = 0 - while i < len(periods): - row = periods[i] - i += 1 - if row[1] > date: - # They attended this day - day_attended = True - continue - elif day_attended is None: - # Didn't attend today, but don't break streak - day_attended = False - date -= daydiff - i -= 1 - continue - elif not day_attended: - # Didn't attend the day, streak broken - date -= daydiff - i -= 1 - pass - else: - # Attended the day - streak += 1 - - # Move window to the previous day and try the row again - day_attended = False - prev_date = date - date -= daydiff - i -= 1 - - # Special case, when the last session started in the previous day - # Then the day is already attended - if i > 1 and date < periods[i-2][0] <= prev_date: - day_attended = True - - continue - - max_streak = max(max_streak, streak) - if current_streak is None: - current_streak = streak - streak = 0 - - # Handle loop exit state, i.e. the last streak - if day_attended: - streak += 1 - max_streak = max(max_streak, streak) - if current_streak is None: - current_streak = streak - - # Accountability stats - accountability = tables.accountability_member_info.select_where( - userid=target.id, - start_at=LEQ(utc_now()), - select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"), - _extra="ORDER BY start_at DESC" - ) - if len(accountability): - acc_duration = sum(row['duration'] for row in accountability) - - acc_attended = sum(row['attended'] for row in accountability) - acc_total = len(accountability) - acc_rate = (acc_attended * 100) / acc_total - else: - acc_duration = 0 - acc_rate = 0 - - # Study League - guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id) - if lion.data.last_study_badgeid: - current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid) - else: - current_badge = None - - next_badge = min( - (badge for badge in guild_badges - if badge.required_time > (current_badge.required_time if current_badge else 0)), - key=lambda badge: badge.required_time, - default=None - ) - - # We have all the data - # Now start building the embed - embed = discord.Embed( - colour=discord.Colour.orange(), - title="Study Profile for {}".format(str(target)) - ) - embed.set_thumbnail(url=target.avatar_url) - - # Add studying since if they have studied - if history: - embed.set_footer(text="Studying Since") - embed.timestamp = history[-1]['start_time'] - - # Set the description based on season time and server rank - if season_time: - time_str = "**{}:{:02}**".format( - season_time // 3600, - (season_time // 60) % 60 - ) - if time_rank is None: - rank_str = None - elif time_rank == 1: - rank_str = "1st" - elif time_rank == 2: - rank_str = "2nd" - elif time_rank == 3: - rank_str = "3rd" - else: - rank_str = "{}th".format(time_rank) - - embed.description = "{} has studied for **{}**{}{}".format( - target.mention, - time_str, - " this season" if study_times[0] - season_time > 60 else "", - ", and is ranked **{}** in the server!".format(rank_str) if rank_str else "." - ) - else: - embed.description = "{} hasn't studied in this server yet!".format(target.mention) - - # Build the stats table - stats = {} - - stats['Coins Earned'] = "**{}** LC".format( - coins, - # "Rank `{}`".format(coin_rank) if coins and coin_rank else "Unranked" - ) - if workout_total: - stats['Workouts'] = "**{}** sessions".format(workout_total) - if acc_duration: - stats['Accountability'] = "**{}** hours (`{:.0f}%` attended)".format( - acc_duration // 3600, - acc_rate - ) - stats['Study Streak'] = "**{}** days{}".format( - current_streak, - " (longest **{}** days)".format(max_streak) if max_streak else '' - ) - - stats_table = prop_tabulate(*zip(*stats.items())) - - # Build the time table - time_table = prop_tabulate( - ('Daily', 'Weekly', 'Monthly', 'All Time'), - ["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)] - ) - - # Populate the embed - embed.add_field(name="Study Time", value=time_table) - embed.add_field(name="Statistics", value=stats_table) - - # Add the study league field - if current_badge or next_badge: - current_str = ( - "You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "No league yet!" - ) - if next_badge: - needed = max(next_badge.required_time - season_time, 0) - next_str = "Study for **{:02}:{:02}** more to achieve <@&{}>.".format( - needed // 3600, - (needed // 60) % 60, - next_badge.roleid - ) - else: - next_str = "You have reached the highest league! Congratulations!" - embed.add_field( - name="Study League", - value="{}\n{}".format(current_str, next_str), - inline=False - ) - await ctx.reply(embed=embed) diff --git a/src/modules/pending-rewrite/stats/setprofile.py b/src/modules/pending-rewrite/stats/setprofile.py deleted file mode 100644 index 550d793a..00000000 --- a/src/modules/pending-rewrite/stats/setprofile.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Provides a command to update a member's profile badges. -""" -import string -import discord - -from cmdClient.lib import SafeCancellation -from cmdClient.checks import in_guild -from wards import guild_moderator - -from .data import profile_tags -from .module import module - - -MAX_TAGS = 10 -MAX_LENGTH = 30 - - -@module.cmd( - "setprofile", - group="Personal Settings", - desc="Set or update your study profile tags.", - aliases=('editprofile', 'mytags'), - flags=('clear', 'for') -) -@in_guild() -async def cmd_setprofile(ctx, flags): - """ - Usage``: - {prefix}setprofile , , , ... - {prefix}setprofile - {prefix}setprofile --clear [--for @user] - Description: - Set or update the tags appearing in your study server profile. - - Moderators can clear a user's tags with `--clear --for @user`. - Examples``: - {prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe - {prefix}setprofile 2 Biology - {prefix}setprofile --clear - """ - if flags['clear']: - if flags['for']: - # Moderator-clearing a user's tags - # First check moderator permissions - if not await guild_moderator.run(ctx): - return await ctx.error_reply( - "You need to be a server moderator to use this!" - ) - - # Check input and extract users to clear for - if not (users := ctx.msg.mentions): - # Show moderator usage - return await ctx.error_reply( - f"**Usage:** `{ctx.best_prefix}setprofile --clear --for @user`\n" - f"**Example:** {ctx.best_prefix}setprofile --clear --for {ctx.author.mention}" - ) - - # Clear the tags - profile_tags.delete_where( - guildid=ctx.guild.id, - userid=[user.id for user in users] - ) - - # Ack the moderator - await ctx.embed_reply( - "Profile tags cleared!" - ) - else: - # The author wants to clear their own tags - - # First delete the tags, save the rows for reporting - rows = profile_tags.delete_where( - guildid=ctx.guild.id, - userid=ctx.author.id - ) - - # Ack the user - if not rows: - await ctx.embed_reply( - "You don't have any profile tags to clear!" - ) - else: - embed = discord.Embed( - colour=discord.Colour.green(), - description="Successfully cleared your profile!" - ) - embed.add_field( - name="Removed tags", - value='\n'.join(row['tag'].upper() for row in rows) - ) - await ctx.reply(embed=embed) - elif ctx.args: - if len(splits := ctx.args.split(maxsplit=1)) > 1 and splits[0].isdigit(): - # Assume we are editing the provided id - tagid = int(splits[0]) - if tagid > MAX_TAGS: - return await ctx.error_reply( - f"Sorry, you can have a maximum of `{MAX_TAGS}` tags!" - ) - if tagid == 0: - return await ctx.error_reply("Tags start at `1`!") - - # Retrieve the user's current taglist - rows = profile_tags.select_where( - guildid=ctx.guild.id, - userid=ctx.author.id, - _extra="ORDER BY tagid ASC" - ) - - # Parse and validate provided new content - content = splits[1].strip().upper() - validate_tag(content) - - if tagid > len(rows): - # Trying to edit a tag that doesn't exist yet - # Just create it instead - profile_tags.insert( - guildid=ctx.guild.id, - userid=ctx.author.id, - tag=content - ) - - # Ack user - await ctx.reply( - embed=discord.Embed(title="Tag created!", colour=discord.Colour.green()) - ) - else: - # Get the row id to update - to_edit = rows[tagid - 1]['tagid'] - - # Update the tag - profile_tags.update_where( - {'tag': content}, - tagid=to_edit - ) - - # Ack user - embed = discord.Embed( - colour=discord.Colour.green(), - title="Tag updated!" - ) - await ctx.reply(embed=embed) - else: - # Assume the arguments are a comma separated list of badges - # Parse and validate - to_add = [split.strip().upper() for line in ctx.args.splitlines() for split in line.split(',')] - to_add = [split.replace('<3', '❤️') for split in to_add if split] - if not to_add: - return await ctx.error_reply("No valid tags given, nothing to do!") - - validate_tag(*to_add) - - if len(to_add) > MAX_TAGS: - return await ctx.error_reply(f"You can have a maximum of {MAX_TAGS} tags!") - - # Remove the existing badges - deleted_rows = profile_tags.delete_where( - guildid=ctx.guild.id, - userid=ctx.author.id - ) - - # Insert the new tags - profile_tags.insert_many( - *((ctx.guild.id, ctx.author.id, tag) for tag in to_add), - insert_keys=('guildid', 'userid', 'tag') - ) - - # Ack with user - embed = discord.Embed( - colour=discord.Colour.green(), - title="Profile tags updated!" - ) - embed.add_field( - name="New tags", - value='\n'.join(to_add) - ) - if deleted_rows: - embed.add_field( - name="Replaced tags", - value='\n'.join(row['tag'].upper() for row in deleted_rows), - inline=False - ) - if len(to_add) == 1: - embed.set_footer( - text=f"TIP: Add multiple tags with {ctx.best_prefix}setprofile tag1, tag2, ..." - ) - await ctx.reply(embed=embed) - else: - # No input was provided - # Show usage and exit - embed = discord.Embed( - colour=discord.Colour.red(), - description=( - "Edit your study profile " - "tags so other people can see what you do!" - ) - ) - embed.add_field( - name="Usage", - value=( - f"`{ctx.best_prefix}setprofile , , , ...`\n" - f"`{ctx.best_prefix}setprofile `" - ) - ) - embed.add_field( - name="Examples", - value=( - f"`{ctx.best_prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe`\n" - f"`{ctx.best_prefix}setprofile 2 Biology`" - ), - inline=False - ) - await ctx.reply(embed=embed) - - -def validate_tag(*content): - for content in content: - if not set(content.replace('❤️', '')).issubset(string.printable): - raise SafeCancellation( - f"Invalid tag `{content}`!\n" - "Tags may only contain alphanumeric and punctuation characters." - ) - if len(content) > MAX_LENGTH: - raise SafeCancellation( - f"Provided tag is too long! Please keep your tags shorter than {MAX_LENGTH} characters." - ) diff --git a/src/modules/pending-rewrite/stats/top_cmd.py b/src/modules/pending-rewrite/stats/top_cmd.py deleted file mode 100644 index 79564c1f..00000000 --- a/src/modules/pending-rewrite/stats/top_cmd.py +++ /dev/null @@ -1,119 +0,0 @@ -from cmdClient.checks import in_guild - -import data -from core import Lion -from data import tables -from utils import interactive # noqa - -from .module import module - - -first_emoji = "🥇" -second_emoji = "🥈" -third_emoji = "🥉" - - -@module.cmd( - "top", - desc="View the Study Time leaderboard.", - group="Statistics", - aliases=('ttop', 'toptime', 'top100'), - help_aliases={'top100': "View the Study Time top 100."} -) -@in_guild() -async def cmd_top(ctx): - """ - Usage``: - {prefix}top - {prefix}top100 - Description: - Display the study time leaderboard, or the top 100. - - Use the paging reactions or send `p` to switch pages (e.g. `p11` to switch to page 11). - """ - # Handle args - if ctx.args and not ctx.args == "100": - return await ctx.error_reply( - "**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix) - ) - top100 = (ctx.args == "100" or ctx.alias == "top100") - - # Fetch the leaderboard - exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.user_blacklist()) - exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) - - args = { - 'guildid': ctx.guild.id, - 'select_columns': ('userid', 'total_tracked_time::INTEGER'), - '_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "") - } - if exclude: - args['userid'] = data.NOT(list(exclude)) - - user_data = tables.members_totals.select_where(**args) - - # Quit early if the leaderboard is empty - if not user_data: - return await ctx.reply("No leaderboard entries yet!") - - # Extract entries - author_index = None - entries = [] - for i, (userid, time) in enumerate(user_data): - member = ctx.guild.get_member(userid) - name = member.display_name if member else str(userid) - name = name.replace('*', ' ').replace('_', ' ') - - num_str = "{}.".format(i+1) - - hours = time // 3600 - minutes = time // 60 % 60 - seconds = time % 60 - - time_str = "{}:{:02}:{:02}".format( - hours, - minutes, - seconds - ) - - if ctx.author.id == userid: - author_index = i - - entries.append((num_str, name, time_str)) - - # Extract blocks - blocks = [entries[i:i+20] for i in range(0, len(entries), 20)] - block_count = len(blocks) - - # Build strings - header = "Study Time Top 100" if top100 else "Study Time Leaderboard" - if block_count > 1: - header += " (Page {{page}}/{})".format(block_count) - - # Build pages - pages = [] - for i, block in enumerate(blocks): - max_num_l, max_name_l, max_time_l = [max(len(e[i]) for e in block) for i in (0, 1, 2)] - body = '\n'.join( - "{:>{}} {:<{}} \t {:>{}} {} {}".format( - entry[0], max_num_l, - entry[1], max_name_l + 2, - entry[2], max_time_l + 1, - first_emoji if i == 0 and j == 0 else ( - second_emoji if i == 0 and j == 1 else ( - third_emoji if i == 0 and j == 2 else '' - ) - ), - "⮜" if author_index is not None and author_index == i * 20 + j else "" - ) - for j, entry in enumerate(block) - ) - title = header.format(page=i+1) - line = '='*len(title) - pages.append( - "```md\n{}\n{}\n{}```".format(title, line, body) - ) - - # Finally, page the results - await ctx.pager(pages, start_at=(author_index or 0)//20 if not top100 else 0) diff --git a/src/modules/pending-rewrite/study/__init__.py b/src/modules/pending-rewrite/study/__init__.py deleted file mode 100644 index 8e7830b0..00000000 --- a/src/modules/pending-rewrite/study/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .module import module - -from . import badges -from . import timers -from . import tracking diff --git a/src/modules/pending-rewrite/study/badges/__init__.py b/src/modules/pending-rewrite/study/badges/__init__.py deleted file mode 100644 index 8db92c34..00000000 --- a/src/modules/pending-rewrite/study/badges/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import badge_tracker -from . import studybadge_cmd diff --git a/src/modules/pending-rewrite/study/badges/badge_tracker.py b/src/modules/pending-rewrite/study/badges/badge_tracker.py deleted file mode 100644 index 307cadf8..00000000 --- a/src/modules/pending-rewrite/study/badges/badge_tracker.py +++ /dev/null @@ -1,349 +0,0 @@ -import datetime -import traceback -import logging -import asyncio -import contextlib - -import discord - -from meta import client, sharding -from data.conditions import GEQ, THIS_SHARD -from core.data import lions -from utils.lib import strfdur -from settings import GuildSettings - -from ..module import module -from .data import new_study_badges, study_badges - - -guild_locks = {} # guildid -> Lock - - -@contextlib.asynccontextmanager -async def guild_lock(guildid): - """ - Per-guild lock held while the study badges are being updated. - This should not be used to lock the data modifications, as those are synchronous. - Primarily for reporting and so that the member information (e.g. roles) stays consistent - through reading and manipulation. - """ - # Create the lock if it hasn't been registered already - if guildid in guild_locks: - lock = guild_locks[guildid] - else: - lock = guild_locks[guildid] = asyncio.Lock() - - await lock.acquire() - try: - yield lock - finally: - lock.release() - - -async def update_study_badges(full=False): - while not client.is_ready(): - await asyncio.sleep(1) - - client.log( - "Running global study badge update.".format( - ), - context="STUDY_BADGE_UPDATE", - level=logging.DEBUG - ) - # TODO: Consider db procedure for doing the update and returning rows - - # Retrieve member rows with out of date study badges - if not full and client.appdata.last_study_badge_scan is not None: - # TODO: _extra here is a hack to cover for inflexible conditionals - update_rows = new_study_badges.select_where( - guildid=THIS_SHARD, - _timestamp=GEQ(client.appdata.last_study_badge_scan or 0), - _extra="OR session_start IS NOT NULL AND (guildid >> 22) %% {} = {}".format( - sharding.shard_count, sharding.shard_number - ) - ) - else: - update_rows = new_study_badges.select_where(guildid=THIS_SHARD) - - if not update_rows: - client.appdata.last_study_badge_scan = datetime.datetime.utcnow() - return - - # Batch and fire guild updates - current_guildid = None - current_guild = None - guild_buffer = [] - updated_guilds = set() - for row in update_rows: - if row['guildid'] != current_guildid: - if current_guild: - # Fire guild updater - asyncio.create_task(_update_guild_badges(current_guild, guild_buffer)) - updated_guilds.add(current_guild.id) - - guild_buffer = [] - current_guildid = row['guildid'] - current_guild = client.get_guild(row['guildid']) - - if current_guild: - guild_buffer.append(row) - - if current_guild: - # Fire guild updater - asyncio.create_task(_update_guild_badges(current_guild, guild_buffer)) - updated_guilds.add(current_guild.id) - - # Update the member study badges in data - lions.update_many( - *((row['current_study_badgeid'], row['guildid'], row['userid']) - for row in update_rows if row['guildid'] in updated_guilds), - set_keys=('last_study_badgeid',), - where_keys=('guildid', 'userid'), - cast_row='(NULL::int, NULL::int, NULL::int)' - ) - - # Update the app scan time - client.appdata.last_study_badge_scan = datetime.datetime.utcnow() - - -async def _update_guild_badges(guild, member_rows, notify=True, log=True): - """ - Notify, update, and log role changes for a single guild. - Expects a valid `guild` and a list of Rows of `new_study_badges`. - """ - async with guild_lock(guild.id): - client.log( - "Running guild badge update for guild '{guild.name}' (gid:{guild.id}) " - "with `{count}` rows to update.".format( - guild=guild, - count=len(member_rows) - ), - context="STUDY_BADGE_UPDATE", - level=logging.DEBUG, - post=False - ) - - # Set of study role ids in this guild, usually from cache - guild_roles = { - roleid: guild.get_role(roleid) - for roleid in study_badges.queries.for_guild(guild.id) - } - - log_lines = [] - flags_used = set() - tasks = [] - for row in member_rows: - # Fetch member - # TODO: Potential verification issue - member = guild.get_member(row['userid']) - - if member: - tasks.append( - asyncio.create_task( - _update_member_roles(row, member, guild_roles, log_lines, flags_used, notify) - ) - ) - - # Post to the event log, in multiple pages if required - event_log = GuildSettings(guild.id).event_log.value - if tasks: - task_blocks = (tasks[i:i+20] for i in range(0, len(tasks), 20)) - for task_block in task_blocks: - # Execute the tasks - await asyncio.gather(*task_block) - - # Post to the log if needed - if log and event_log: - desc = "\n".join(log_lines) - embed = discord.Embed( - title="Study badge{} earned!".format('s' if len(log_lines) > 1 else ''), - description=desc, - colour=discord.Colour.orange(), - timestamp=datetime.datetime.utcnow() - ) - if flags_used: - flag_desc = { - '!': "`!` Could not add/remove badge role. **Check permissions!**", - '*': "`*` Could not message member.", - 'x': "`x` Couldn't find role to add/remove!" - } - flag_lines = '\n'.join(desc for flag, desc in flag_desc.items() if flag in flags_used) - embed.add_field( - name="Legend", - value=flag_lines - ) - try: - await event_log.send(embed=embed) - except discord.HTTPException: - # Nothing we can really do - pass - - # Flush the log collection pointers - log_lines.clear() - flags_used.clear() - - # Wait so we don't get ratelimited - await asyncio.sleep(0.5) - - # Debug log completion - client.log( - "Completed guild badge update for guild '{guild.name}' (gid:{guild.id})".format( - guild=guild, - ), - context="STUDY_BADGE_UPDATE", - level=logging.DEBUG, - post=False - ) - - -async def _update_member_roles(row, member, guild_roles, log_lines, flags_used, notify): - guild = member.guild - - # Logging flag chars - flags = [] - - # Add new study role - # First fetch the roleid using the current_study_badgeid - new_row = study_badges.fetch(row['current_study_badgeid']) if row['current_study_badgeid'] else None - - # Fetch actual role from the precomputed guild roles - to_add = guild_roles.get(new_row.roleid, None) if new_row else None - if to_add: - # Actually add the role - try: - await member.add_roles( - to_add, - atomic=True, - reason="Updating study badge." - ) - except discord.HTTPException: - flags.append('!') - elif new_row: - flags.append('x') - - # Remove other roles, start by trying the last badge role - old_row = study_badges.fetch(row['last_study_badgeid']) if row['last_study_badgeid'] else None - - member_roleids = set(role.id for role in member.roles) - if old_row and old_row.roleid in member_roleids: - # The last level role exists, try to remove it - try: - await member.remove_roles( - guild_roles.get(old_row.roleid), - atomic=True - ) - except discord.HTTPException: - # Couldn't remove the role - flags.append('!') - else: - # The last level role doesn't exist or the member doesn't have it - # Remove all leveled roles they have - current_roles = ( - role for roleid, role in guild_roles.items() - if roleid in member_roleids and (not to_add or roleid != to_add.id) - ) - if current_roles: - try: - await member.remove_roles( - *current_roles, - atomic=True, - reason="Updating study badge." - ) - except discord.HTTPException: - # Couldn't remove one or more of the leveled roles - flags.append('!') - - # Send notification to member - # TODO: Config customisation - if notify and new_row and (old_row is None or new_row.required_time > old_row.required_time): - req = new_row.required_time - if req < 3600: - timestr = "{} minutes".format(int(req // 60)) - elif req == 3600: - timestr = "1 hour" - elif req % 3600: - timestr = "{:.1f} hours".format(req / 3600) - else: - timestr = "{} hours".format(int(req // 3600)) - embed = discord.Embed( - title="New Study Badge!", - description="Congratulations! You have earned {} for studying **{}**!".format( - "**{}**".format(to_add.name) if to_add else "a new study badge!", - timestr - ), - timestamp=datetime.datetime.utcnow(), - colour=discord.Colour.orange() - ).set_footer(text=guild.name, icon_url=guild.icon_url) - try: - await member.send(embed=embed) - except discord.HTTPException: - flags.append('*') - - # Add to event log message - if new_row: - new_role_str = "earned <@&{}> **({})**".format(new_row.roleid, strfdur(new_row.required_time)) - else: - new_role_str = "lost their study badge!" - log_lines.append( - "<@{}> {} {}".format( - row['userid'], - new_role_str, - "`[{}]`".format(''.join(flags)) if flags else "", - ) - ) - if flags: - flags_used.update(flags) - - -async def study_badge_tracker(): - """ - Runloop for the study badge updater. - """ - while True: - try: - await update_study_badges() - except Exception: - # Unknown exception. Catch it so the loop doesn't die. - client.log( - "Error while updating study badges! " - "Exception traceback follows.\n{}".format( - traceback.format_exc() - ), - context="STUDY_BADGE_TRACKER", - level=logging.ERROR - ) - # Long delay since this is primarily needed for external modifications - # or badge updates while studying - await asyncio.sleep(60) - - -async def update_member_studybadge(member): - """ - Checks and (if required) updates the study badge for a single member. - """ - update_rows = new_study_badges.select_where( - guildid=member.guild.id, - userid=member.id - ) - if update_rows: - # Debug log the update - client.log( - "Updating study badge for user '{member.name}' (uid:{member.id}) " - "in guild '{member.guild.name}' (gid:{member.guild.id}).".format( - member=member - ), - context="STUDY_BADGE_UPDATE", - level=logging.DEBUG - ) - - # Update the data first - lions.update_where({'last_study_badgeid': update_rows[0]['current_study_badgeid']}, - guildid=member.guild.id, userid=member.id) - - # Run the update task - await _update_guild_badges(member.guild, update_rows) - - -@module.launch_task -async def launch_study_badge_tracker(client): - asyncio.create_task(study_badge_tracker()) diff --git a/src/modules/pending-rewrite/study/badges/data.py b/src/modules/pending-rewrite/study/badges/data.py deleted file mode 100644 index eca5f220..00000000 --- a/src/modules/pending-rewrite/study/badges/data.py +++ /dev/null @@ -1,24 +0,0 @@ -from cachetools import cached - -from data import Table, RowTable - -study_badges = RowTable( - 'study_badges', - ('badgeid', 'guildid', 'roleid', 'required_time'), - 'badgeid' -) - -current_study_badges = Table('current_study_badges') - -new_study_badges = Table('new_study_badges') - - -# Cache of study role ids attached to each guild. Not automatically updated. -guild_role_cache = {} # guildid -> set(roleids) - - -@study_badges.save_query -@cached(guild_role_cache) -def for_guild(guildid): - rows = study_badges.fetch_rows_where(guildid=guildid) - return set(row.roleid for row in rows) diff --git a/src/modules/pending-rewrite/study/badges/studybadge_cmd.py b/src/modules/pending-rewrite/study/badges/studybadge_cmd.py deleted file mode 100644 index 1d9aea9c..00000000 --- a/src/modules/pending-rewrite/study/badges/studybadge_cmd.py +++ /dev/null @@ -1,462 +0,0 @@ -import re -import asyncio -import discord -import datetime - -from cmdClient.checks import in_guild -from cmdClient.lib import SafeCancellation - -from data import NULL -from utils.lib import parse_dur, strfdur, parse_ranges -from wards import is_guild_admin -from core.data import lions -from settings import GuildSettings - -from ..module import module -from .data import study_badges, guild_role_cache, new_study_badges -from .badge_tracker import _update_guild_badges - - -_multiselect_regex = re.compile( - r"^([0-9, -]+)$", - re.DOTALL | re.IGNORECASE | re.VERBOSE -) - - -@module.cmd( - "studybadges", - group="Guild Configuration", - desc="View or configure the server study badges.", - aliases=('studyroles', 'studylevels'), - flags=('add', 'remove', 'clear', 'refresh') -) -@in_guild() -async def cmd_studybadges(ctx, flags): - """ - Usage``: - {prefix}studybadges - {prefix}studybadges [--add] , - {prefix}studybadges --remove - {prefix}studybadges --remove - {prefix}studybadges --remove - {prefix}studybadges --clear - {prefix}studybadges --refresh - Description: - View or modify the study badges in this guild. - - *Modification requires administrator permissions.* - Flags:: - add: Add new studybadges (each line is added as a separate badge). - remove: Remove badges. With no arguments, opens a selection menu. - clear: Remove all study badges. - refresh: Make sure everyone's study badges are up to date. - Examples``: - {prefix}studybadges Lion Cub, 100h - {prefix}studybadges --remove Lion Cub - """ - if flags['refresh']: - await ensure_admin(ctx) - - # Count members who need updating. - # Note that we don't get the rows here in order to avoid clashing with the auto-updater - update_count = new_study_badges.select_one_where( - guildid=ctx.guild.id, - select_columns=('COUNT(*)',) - )[0] - - if not update_count: - # No-one needs updating - await ctx.reply("All study badges are up to date!") - return - else: - out_msg = await ctx.reply("Updating `{}` members (this may take a while)...".format(update_count)) - - # Fetch actual update rows - update_rows = new_study_badges.select_where( - guildid=ctx.guild.id - ) - - # Update data first - lions.update_many( - *((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows), - set_keys=('last_study_badgeid',), - where_keys=('guildid', 'userid') - ) - - # Then apply the role updates and send notifications as usual - await _update_guild_badges(ctx.guild, update_rows) - - await out_msg.edit("Refresh complete! All study badges are up to date.") - elif flags['clear'] or flags['remove']: - # Make sure that the author is an admin before modifying the roles - await ensure_admin(ctx) - - # Pre-fetch the list of roles - guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC") - - if not guild_roles: - return await ctx.error_reply("There are no studybadges to remove!") - - # Input handling, parse or get the list of rows to delete - to_delete = [] - if flags['remove']: - if ctx.args: - if ctx.args.isdigit() and 0 < int(ctx.args) <= len(guild_roles): - # Assume it is a badge index - row = guild_roles[int(ctx.args) - 1] - else: - # Assume the input is a role string - # Get the collection of roles to search - roleids = (row.roleid for row in guild_roles) - roles = (ctx.guild.get_role(roleid) for roleid in roleids) - roles = [role for role in roles if role is not None] - role = await ctx.find_role(ctx.args, interactive=True, collection=roles, allow_notfound=False) - index = roles.index(role) - row = guild_roles[index] - - # We now have a row to delete - to_delete = [row] - else: - # Multi-select the badges to remove - out_msg = await show_badge_list( - ctx, - desc="Please select the badge(s) to delete, or type `c` to cancel.", - guild_roles=guild_roles - ) - - def check(msg): - valid = msg.channel == ctx.ch and msg.author == ctx.author - valid = valid and (re.search(_multiselect_regex, msg.content) or msg.content.lower() == 'c') - return valid - - try: - message = await ctx.client.wait_for('message', check=check, timeout=60) - except asyncio.TimeoutError: - await out_msg.delete() - await ctx.error_reply("Session timed out. No study badges were deleted.") - return - - try: - await out_msg.delete() - await message.delete() - except discord.HTTPException: - pass - - if message.content.lower() == 'c': - return - - to_delete = [ - guild_roles[index-1] - for index in parse_ranges(message.content) if index <= len(guild_roles) - ] - elif flags['clear']: - if not await ctx.ask("Are you sure you want to delete **all** study badges in this server?"): - return - to_delete = guild_roles - - # In some cases we may come out with no valid rows, in this case cancel. - if not to_delete: - return await ctx.error_reply("No matching badges, nothing to do!") - - # Count the affected users - affected_count = lions.select_one_where( - guildid=ctx.guild.id, - last_study_badgeid=[row.badgeid for row in to_delete], - select_columns=('COUNT(*)',) - )[0] - - # Delete the rows - study_badges.delete_where(badgeid=[row.badgeid for row in to_delete]) - - # Also update the cached guild roles - guild_role_cache.pop((ctx.guild.id, ), None) - study_badges.queries.for_guild(ctx.guild.id) - - # Immediately refresh the member data, only for members with NULL badgeid - update_rows = new_study_badges.select_where( - guildid=ctx.guild.id, - last_study_badgeid=NULL - ) - - if update_rows: - lions.update_many( - *((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows), - set_keys=('last_study_badgeid',), - where_keys=('guildid', 'userid') - ) - - # Launch the update task for these members, so that they get the correct new roles - asyncio.create_task(_update_guild_badges(ctx.guild, update_rows, notify=False, log=False)) - - # Ack the deletion - count = len(to_delete) - roles = [ctx.guild.get_role(row.roleid) for row in to_delete] - if count == len(guild_roles): - await ctx.embed_reply("All study badges deleted.") - log_embed = discord.Embed( - title="Study badges cleared!", - description="{} cleared the guild study badges. `{}` members affected.".format( - ctx.author.mention, - affected_count - ) - ) - elif count == 1: - badge_name = roles[0].name if roles[0] else strfdur(to_delete[0].required_time) - await show_badge_list( - ctx, - desc="✅ Removed the **{}** badge.".format(badge_name) - ) - log_embed = discord.Embed( - title="Study badge removed!", - description="{} removed the badge **{}**. `{}` members affected.".format( - ctx.author.mention, - badge_name, - affected_count - ) - ) - else: - await show_badge_list( - ctx, - desc="✅ `{}` badges removed.".format(count) - ) - log_embed = discord.Embed( - title="Study badges removed!", - description="{} removed `{}` badges. `{}` members affected.".format( - ctx.author.mention, - count, - affected_count - ) - ) - - # Post to the event log - event_log = GuildSettings(ctx.guild.id).event_log.value - if event_log: - # TODO Error handling? Or improve the post method? - log_embed.timestamp = datetime.datetime.utcnow() - log_embed.colour = discord.Colour.orange() - await event_log.send(embed=log_embed) - - # Delete the roles (after asking first) - roles = [role for role in roles if role is not None] - if roles: - if await ctx.ask("Do you also want to remove the associated guild roles?"): - tasks = [ - asyncio.create_task(role.delete()) for role in roles - ] - results = await asyncio.gather( - *tasks, - return_exceptions=True - ) - bad_roles = [role for role, task in zip(roles, tasks) if task.exception()] - if bad_roles: - await ctx.embed_reply( - "Couldn't delete the following roles:\n{}".format( - '\n'.join(bad_role.mention for bad_role in bad_roles) - ) - ) - else: - await ctx.embed_reply("Deleted `{}` roles.".format(len(roles))) - elif ctx.args: - # Ensure admin perms for modification - await ensure_admin(ctx) - - guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC") - - # Parse the input - lines = ctx.args.splitlines() - results = [await parse_level(ctx, line) for line in lines] - # Check for duplicates - _set = set() - duplicate = next((time for time, _ in results if time in _set or _set.add(time)), None) - if duplicate: - return await ctx.error_reply( - "Level `{}` provided twice!".format(strfdur(duplicate, short=False)) - ) - current_times = set(row.required_time for row in guild_roles) - - # Split up the provided lines into levels to add and levels to edit - to_add = [result for result in results if result[0] not in current_times] - to_edit = [result for result in results if result[0] in current_times] - - # Apply changes to database - if to_add: - study_badges.insert_many( - *((ctx.guild.id, time, role.id) for time, role in to_add), - insert_keys=('guildid', 'required_time', 'roleid') - ) - if to_edit: - study_badges.update_many( - *((role.id, ctx.guild.id, time) for time, role in to_edit), - set_keys=('roleid',), - where_keys=('guildid', 'required_time') - ) - - # Also update the cached guild roles - guild_role_cache.pop((ctx.guild.id, ), None) - study_badges.queries.for_guild(ctx.guild.id) - - # Ack changes - if to_add and to_edit: - desc = "{tick} `{num_add}` badges added and `{num_edit}` updated." - elif to_add: - desc = "{tick} `{num_add}` badges added." - elif to_edit: - desc = "{tick} `{num_edit}` badges updated." - - desc = desc.format( - tick='✅', - num_add=len(to_add), - num_edit=len(to_edit) - ) - - await show_badge_list(ctx, desc) - - # Count members who need new study badges - # Note that we don't get the rows here in order to avoid clashing with the auto-updater - update_count = new_study_badges.select_one_where( - guildid=ctx.guild.id, - select_columns=('COUNT(*)',) - )[0] - - if not update_count: - # No-one needs updating - return - - if update_count > 20: - # Confirm whether we want to update now - resp = await ctx.ask( - "`{}` members need their study badge roles updated, " - "which will occur automatically for each member when they next study.\n" - "Do you want to refresh the roles immediately instead? This may take a while!" - ) - if not resp: - return - - # Fetch actual update rows - update_rows = new_study_badges.select_where( - guildid=ctx.guild.id - ) - - # Update data first - lions.update_many( - *((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows), - set_keys=('last_study_badgeid',), - where_keys=('guildid', 'userid') - ) - - # Then apply the role updates and send notifications as usual - await _update_guild_badges(ctx.guild, update_rows) - - # TODO: Progress bar? Probably not needed since we have the event log - # TODO: Ask about notifications? - else: - guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC") - - # Just view the current study levels - if not guild_roles: - return await ctx.reply("There are no study badges set up!") - - # TODO: You are at... this much to next level.. - await show_badge_list(ctx, guild_roles=guild_roles) - - -async def parse_level(ctx, line): - line = line.strip() - - if ',' in line: - splits = [split.strip() for split in line.split(',', maxsplit=1)] - elif line.startswith('"') and '"' in line[1:]: - splits = [split.strip() for split in line[1:].split('"', maxsplit=1)] - else: - splits = [split.strip() for split in line.split(maxsplit=1)] - - if not line or len(splits) != 2 or not splits[1][0].isdigit(): - raise SafeCancellation( - "**Level Syntax:** `, `, for example `Lion Cub, 200h`." - ) - - if splits[1].isdigit(): - # No units! Assume hours - time = int(splits[1]) * 3600 - else: - time = parse_dur(splits[1]) - - role_str = splits[0] - # TODO maybe add Y.. yes to all - role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False) - return time, role - - -async def ensure_admin(ctx): - if not is_guild_admin(ctx.author): - raise SafeCancellation("Only guild admins can modify the server study badges!") - - -async def show_badge_list(ctx, desc=None, guild_roles=None): - if guild_roles is None: - guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC") - - # Generate the time range strings - time_strings = [] - first_time = guild_roles[0].required_time - if first_time == 0: - prev_time_str = '0' - prev_time_hour = False - else: - prev_time_str = strfdur(guild_roles[0].required_time) - prev_time_hour = not (guild_roles[0].required_time % 3600) - for row in guild_roles[1:]: - time = row.required_time - time_str = strfdur(time) - time_hour = not (time % 3600) - if time_hour and prev_time_hour: - time_strings.append( - "{} - {}".format(prev_time_str[:-1], time_str) - ) - else: - time_strings.append( - "{} - {}".format(prev_time_str, time_str) - ) - prev_time_str = time_str - prev_time_hour = time_hour - time_strings.append( - "≥ {}".format(prev_time_str) - ) - - # Pair the time strings with their roles - pairs = [ - (time_string, row.roleid) - for time_string, row in zip(time_strings, guild_roles) - ] - - # pairs = [ - # (strfdur(row.required_time), row.study_role) - # for row in guild_roles - # ] - - # Split the pairs into blocks - pair_blocks = [pairs[i:i+10] for i in range(0, len(pairs), 10)] - - # Format the blocks into strings - blocks = [] - for i, pair_block in enumerate(pair_blocks): - dig_len = (i * 10 + len(pair_block)) // 10 + 1 - blocks.append('\n'.join( - "`[{:<{}}]` | <@&{}> **({})**".format( - i * 10 + j + 1, - dig_len, - role, - time_string, - ) for j, (time_string, role) in enumerate(pair_block) - )) - - # Compile the strings into pages - pages = [ - discord.Embed( - title="Study Badges in {}! \nStudy more to rank up!".format(ctx.guild.name), - description="{}\n\n{}".format(desc, block) if desc else block - ) for block in blocks - ] - - # Output and page the pages - return await ctx.pager(pages) diff --git a/src/modules/pending-rewrite/study/module.py b/src/modules/pending-rewrite/study/module.py deleted file mode 100644 index 38f5340a..00000000 --- a/src/modules/pending-rewrite/study/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from LionModule import LionModule - - -module = LionModule("Study_Tracking") diff --git a/src/modules/pending-rewrite/study/timers/Timer.py b/src/modules/pending-rewrite/study/timers/Timer.py deleted file mode 100644 index aff69729..00000000 --- a/src/modules/pending-rewrite/study/timers/Timer.py +++ /dev/null @@ -1,444 +0,0 @@ -import math -import asyncio -import discord -from collections import namedtuple -from datetime import timedelta - -from utils.lib import utc_now -from utils.interactive import discord_shield -from meta import client -from settings import GuildSettings -from data.conditions import THIS_SHARD - - -from ..module import module - -from .data import timers as timer_table - - -Stage = namedtuple('Stage', ['name', 'start', 'duration', 'end']) - - -class Timer: - timers = {} # channelid -> Timer - - def __init__(self, channelid): - self.channelid = channelid - self.last_seen = { - } # Memberid -> timestamps - - self.reaction_message = None - - self._state = None - self._last_voice_update = None - - self._voice_update_task = None - self._run_task = None - self._runloop_task = None - - @classmethod - def create(cls, channel, focus_length, break_length, **kwargs): - timer_table.create_row( - channelid=channel.id, - guildid=channel.guild.id, - focus_length=focus_length, - break_length=break_length, - last_started=kwargs.pop('last_started', utc_now()), - **kwargs - ) - return cls(channel.id) - - @classmethod - def fetch_timer(cls, channelid): - return cls.timers.get(channelid, None) - - @classmethod - def fetch_guild_timers(cls, guildid): - timers = [] - guild = client.get_guild(guildid) - if guild: - for channel in guild.voice_channels: - if (timer := cls.timers.get(channel.id, None)): - timers.append(timer) - - return timers - - @property - def data(self): - return timer_table.fetch(self.channelid) - - @property - def focus_length(self): - return self.data.focus_length - - @property - def break_length(self): - return self.data.break_length - - @property - def inactivity_threshold(self): - return self.data.inactivity_threshold or 3 - - @property - def current_stage(self): - if (last_start := self.data.last_started) is None: - # Timer hasn't been started - return None - now = utc_now() - diff = (now - last_start).total_seconds() - diff %= (self.focus_length + self.break_length) - if diff > self.focus_length: - return Stage( - 'BREAK', - now - timedelta(seconds=(diff - self.focus_length)), - self.break_length, - now + timedelta(seconds=(- diff + self.focus_length + self.break_length)) - ) - else: - return Stage( - 'FOCUS', - now - timedelta(seconds=diff), - self.focus_length, - now + timedelta(seconds=(self.focus_length - diff)) - ) - - @property - def guild(self): - return client.get_guild(self.data.guildid) - - @property - def channel(self): - return client.get_channel(self.channelid) - - @property - def text_channel(self): - if (channelid := self.data.text_channelid) and (channel := self.guild.get_channel(channelid)): - return channel - else: - return GuildSettings(self.data.guildid).pomodoro_channel.value - - @property - def members(self): - if (channel := self.channel): - return [member for member in channel.members if not member.bot] - else: - return [] - - @property - def channel_name(self): - """ - Current name for the voice channel - """ - stage = self.current_stage - name_format = self.data.channel_name or "{remaining} {stage} -- {name}" - name = name_format.replace( - '{remaining}', "{}m".format( - int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)), - ) - ).replace( - '{stage}', stage.name.lower() - ).replace( - '{members}', str(len(self.channel.members)) - ).replace( - '{name}', self.data.pretty_name or "WORK ROOM" - ).replace( - '{pattern}', - "{}/{}".format( - int(self.focus_length // 60), int(self.break_length // 60) - ) - ) - return name[:100] - - async def notify_change_stage(self, old_stage, new_stage): - # Update channel name - asyncio.create_task(self._update_channel_name()) - - # Kick people if they need kicking - to_warn = [] - to_kick = [] - warn_threshold = (self.inactivity_threshold - 1) * (self.break_length + self.focus_length) - kick_threshold = self.inactivity_threshold * (self.break_length + self.focus_length) - for member in self.members: - if member.id in self.last_seen: - diff = (utc_now() - self.last_seen[member.id]).total_seconds() - if diff >= kick_threshold: - to_kick.append(member) - elif diff > warn_threshold: - to_warn.append(member) - else: - # Shouldn't really happen, but - self.last_seen[member.id] = utc_now() - - content = [] - - if to_kick: - # Do kick - await asyncio.gather( - *(member.edit(voice_channel=None) for member in to_kick), - return_exceptions=True - ) - kick_string = ( - "**Kicked due to inactivity:** {}".format(', '.join(member.mention for member in to_kick)) - ) - content.append(kick_string) - - if to_warn: - warn_string = ( - "**Please react to avoid being kicked:** {}".format( - ', '.join(member.mention for member in to_warn) - ) - ) - content.append(warn_string) - - # Send a new status/reaction message - if self.text_channel and self.members: - old_reaction_message = self.reaction_message - - # Send status image, add reaction - args = await self.status() - if status_content := args.pop('content', None): - content.append(status_content) - self.reaction_message = await self.text_channel.send( - content='\n'.join(content), - **args - ) - await self.reaction_message.add_reaction('✅') - - if old_reaction_message: - asyncio.create_task(discord_shield(old_reaction_message.delete())) - - # Ping people - members = self.members - blocks = [ - ''.join(member.mention for member in members[i:i+90]) - for i in range(0, len(members), 90) - ] - await asyncio.gather( - *(self.text_channel.send(block, delete_after=0.5) for block in blocks), - return_exceptions=True - ) - elif not self.members: - await self.update_last_status() - # TODO: DM task if anyone has notifications on - - # Mute or unmute everyone in the channel as needed - # Not possible, due to Discord restrictions - # overwrite = self.channel.overwrites_for(self.channel.guild.default_role) - # overwrite.speak = (new_stage.name == 'BREAK') - # try: - # await self.channel.set_permissions( - # self.channel.guild.default_role, - # overwrite=overwrite - # ) - # except discord.HTTPException: - # pass - - # Run the notify hook - await self.notify_hook(old_stage, new_stage) - - async def notify_hook(self, old_stage, new_stage): - """ - May be overridden to provide custom actions during notification. - For example, for voice alerts. - """ - ... - - async def _update_channel_name(self): - # Attempt to update the voice channel name - # Ensures that only one update is pending at any time - # Attempts to wait until the next viable channel update - if self._voice_update_task: - self._voice_update_task.cancel() - - if not self.channel: - return - - if self.channel.name == self.channel_name: - return - - if not self.channel.permissions_for(self.channel.guild.me).manage_channels: - return - - if self._last_voice_update: - to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds() - if to_wait > 0: - self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait)) - try: - await self._voice_update_task - except asyncio.CancelledError: - return - self._last_voice_update = utc_now() - await asyncio.create_task( - self.channel.edit(name=self.channel_name) - ) - - async def status(self): - """ - Returns argument dictionary compatible with `discord.Channel.send`. - """ - # Generate status message - stage = self.current_stage - stage_str = "**{}** minutes focus with **{}** minutes break".format( - self.focus_length // 60, - self.break_length // 60 - ) - remaining = (stage.end - utc_now()).total_seconds() - - memberstr = ', '.join(member.mention for member in self.members[:20]) - if len(self.members) > 20: - memberstr += '...' - - description = ( - ("{}: {}\n" - "Currently in `{}`, with `{:02}:{:02}` remaining.\n" - "{}").format( - self.channel.mention, - stage_str, - stage.name, - int(remaining // 3600), - int((remaining // 60) % 60), - memberstr - ) - ) - embed = discord.Embed( - colour=discord.Colour.orange(), - description=description - ) - return {'embed': embed} - - async def update_last_status(self): - """ - Update the last posted status message, if it exists. - """ - args = await self.status() - repost = True - if self.reaction_message: - try: - await self.reaction_message.edit(**args) - except discord.HTTPException: - pass - else: - repost = False - - if repost and self.text_channel: - try: - self.reaction_message = await self.text_channel.send(**args) - await self.reaction_message.add_reaction('✅') - except discord.HTTPException: - pass - - return - - async def destroy(self): - """ - Remove the timer. - """ - # Remove timer from cache - self.timers.pop(self.channelid, None) - - # Cancel the loop - if self._run_task: - self._run_task.cancel() - - # Delete the reaction message - if self.reaction_message: - try: - await self.reaction_message.delete() - except discord.HTTPException: - pass - - # Remove the timer from data - timer_table.delete_where(channelid=self.channelid) - - async def run(self): - """ - Runloop - """ - timer = self.timers.pop(self.channelid, None) - if timer and timer._run_task: - timer._run_task.cancel() - self.timers[self.channelid] = self - - if not self.data.last_started: - self.data.last_started = utc_now() - asyncio.create_task(self.notify_change_stage(None, self.current_stage)) - - while True: - stage = self._state = self.current_stage - to_next_stage = (stage.end - utc_now()).total_seconds() - - # Allow updating with 10 seconds of drift to stage change - if to_next_stage > 10 * 60 - 10: - time_to_sleep = 5 * 60 - else: - time_to_sleep = to_next_stage - - self._run_task = asyncio.create_task(asyncio.sleep(time_to_sleep)) - try: - await self._run_task - except asyncio.CancelledError: - break - - # Destroy the timer if our voice channel no longer exists - if not self.channel: - await self.destroy() - break - - if self._state.end < utc_now(): - asyncio.create_task(self.notify_change_stage(self._state, self.current_stage)) - elif self.members: - asyncio.create_task(self._update_channel_name()) - asyncio.create_task(self.update_last_status()) - - def runloop(self): - self._runloop_task = asyncio.create_task(self.run()) - - -# Loading logic -@module.launch_task -async def load_timers(client): - timer_rows = timer_table.fetch_rows_where( - guildid=THIS_SHARD - ) - count = 0 - for row in timer_rows: - if client.get_channel(row.channelid): - # Channel exists - # Create the timer - timer = Timer(row.channelid) - - # Populate the members - timer.last_seen = { - member.id: utc_now() - for member in timer.members - } - - # Start the timer - timer.runloop() - count += 1 - - client.log( - "Loaded and start '{}' timers!".format(count), - context="TIMERS" - ) - - -# Hooks -@client.add_after_event('raw_reaction_add') -async def reaction_tracker(client, payload): - if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice: - if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)): - if timer.reaction_message and payload.message_id == timer.reaction_message.id: - timer.last_seen[payload.member.id] = utc_now() - - -@client.add_after_event('voice_state_update') -async def touch_member(client, member, before, after): - if not member.bot and after.channel != before.channel: - if after.channel and (timer := Timer.fetch_timer(after.channel.id)): - timer.last_seen[member.id] = utc_now() - await timer.update_last_status() - - if before.channel and (timer := Timer.fetch_timer(before.channel.id)): - timer.last_seen.pop(member.id, None) - await timer.update_last_status() diff --git a/src/modules/pending-rewrite/study/timers/__init__.py b/src/modules/pending-rewrite/study/timers/__init__.py deleted file mode 100644 index dd146ae3..00000000 --- a/src/modules/pending-rewrite/study/timers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .Timer import Timer -from . import commands -from . import settings diff --git a/src/modules/pending-rewrite/study/timers/commands.py b/src/modules/pending-rewrite/study/timers/commands.py deleted file mode 100644 index 5a0daad0..00000000 --- a/src/modules/pending-rewrite/study/timers/commands.py +++ /dev/null @@ -1,460 +0,0 @@ -import asyncio -import discord -from cmdClient.checks import in_guild -from cmdClient.lib import SafeCancellation - -from LionContext import LionContext as Context - -from wards import guild_admin -from utils.lib import utc_now, tick, prop_tabulate - -from ..module import module - -from .Timer import Timer - - -config_flags = ('name==', 'threshold=', 'channelname==', 'text==') -MAX_TIMERS_PER_GUILD = 10 - -options = { - "--name": "The timer name (as shown in alerts and `{prefix}timer`).", - "--channelname": "The name of the voice channel, see below for substitutions.", - "--threshold": "How many focus+break cycles before a member is kicked.", - "--text": "Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`)." -} -options_str = prop_tabulate(*zip(*options.items())) - - -@module.cmd( - "timer", - group="🆕 Pomodoro", - desc="View your study room timer.", - flags=config_flags, - aliases=('timers',) -) -@in_guild() -async def cmd_timer(ctx: Context, flags): - """ - Usage``: - {prefix}timer - {prefix}timers - Description: - Display your current study room timer status. - If you aren't in a study room, instead shows a list of timers you can join. - Use `{prefix}timers` to always show the list of timers instead. - """ - channel = ctx.author.voice.channel if ctx.author.voice and ctx.alias.lower() != 'timers' else None - if ctx.args: - if len(ctx.args.split()) > 1: - # Multiple arguments provided - # Assume configuration attempt - return await _pomo_admin(ctx, flags) - else: - # Single argument provided, assume channel reference - channel = await ctx.find_channel( - ctx.args, - interactive=True, - chan_type=discord.ChannelType.voice, - ) - if channel is None: - return - if channel is None: - # Author is not in a voice channel, and they did not select a channel - # Display the server timers they can see - timers = Timer.fetch_guild_timers(ctx.guild.id) - timers = [ - timer for timer in timers - if timer.channel and timer.channel.permissions_for(ctx.author).view_channel - ] - if not timers: - if await guild_admin.run(ctx): - return await ctx.error_reply( - "No timers are running yet!\n" - f"Start a timer by joining a voice channel and running e.g. `{ctx.best_prefix}pomodoro 50, 10`.\n" - f"See `{ctx.best_prefix}help pomodoro for detailed usage." - ) - else: - return await ctx.error_reply( - "No timers are running!\n" - f"You can ask an admin to start one using `{ctx.best_prefix}pomodoro`." - ) - # Build a summary list - timer_strings = [] - for timer in timers: - stage = timer.current_stage - stage_str = "(**`{}m`** focus, **`{}m`** break)".format( - int(timer.focus_length // 60), int(timer.break_length // 60) - ) - if len(timer.members) > 1: - member_str = "**{}** members are ".format(len(timer.members)) - elif len(timer.members) == 1: - member_str = "{} is ".format(timer.members[0].mention) - else: - member_str = "" - remaining = (stage.end - utc_now()).total_seconds() - - timer_strings.append( - ("{} {}\n" - "{}urrently **{}** with `{:02}:{:02}` left.").format( - timer.channel.mention, - stage_str, - member_str + 'c' if member_str else 'C', - "focusing" if stage.name == "FOCUS" else "resting", - int(remaining // 3600), - int((remaining // 60) % 60), - ) - ) - - blocks = [ - '\n\n'.join(timer_strings[i:i+10]) - for i in range(0, len(timer_strings), 10) - ] - embeds = [ - discord.Embed( - title="Study Timers", - description=block, - colour=discord.Colour.orange() - ) - for block in blocks - ] - await ctx.pager(embeds) - else: - # We have a channel - # Get the associated timer - timer = Timer.fetch_timer(channel.id) - if timer is None: - # No timer in this channel - return await ctx.error_reply( - f"{channel.mention} doesn't have a timer running!" - ) - else: - # We have a timer - # Show the timer status - await ctx.reply(**await timer.status()) - - -@module.cmd( - "pomodoro", - group="Pomodoro", - desc="Add and configure timers for your study rooms.", - flags=config_flags -) -async def cmd_pomodoro(ctx, flags): - """ - Usage``: - {prefix}pomodoro [channelid] , [channel name] - {prefix}pomodoro [channelid] [options] - {prefix}pomodoro [channelid] delete - Description: - Get started by joining a study voice channel and writing e.g. `{prefix}pomodoro 50, 10`. - The timer will start automatically and continue forever. - See the options and examples below for configuration. - Options:: - --name: The timer name (as shown in alerts and `{prefix}timer`). - --channelname: The name of the voice channel, see below for substitutions. - --threshold: How many focus+break cycles before a member is kicked. - --text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`). - Channel name substitutions:: - {{remaining}}: The time left in the current focus or break session, e.g. `10m`. - {{stage}}: The name of the current stage (`focus` or `break`). - {{name}}: The configured timer name. - {{pattern}}: The timer pattern in the form `focus/break` (e.g. `50/10`). - Examples: - Add a timer to your study room with `50` minutes focus, `10` minutes break. - > `{prefix}pomodoro 50, 10` - Add a timer with a custom updating channel name - > `{prefix}pomodoro 50, 10 {{remaining}} {{stage}} -- {{pattern}} room` - Change the name on the `{prefix}timer` status - > `{prefix}pomodoro --name 50/10 study room` - Change the updating channel name - > `{prefix}pomodoro --channelname {{remaining}} left -- {{name}}` - """ - await _pomo_admin(ctx, flags) - - -async def _pomo_admin(ctx, flags): - # Extract target channel - if ctx.author.voice: - channel = ctx.author.voice.channel - else: - channel = None - - args = ctx.args - if ctx.args: - splits = ctx.args.split(maxsplit=1) - assume_channel = not (',' in splits[0]) - assume_channel = assume_channel and not (channel and len(splits[0]) < 5) - assume_channel = assume_channel or (splits[0].strip('#<>').isdigit() and len(splits[0]) > 10) - if assume_channel: - # Assume first argument is a channel specifier - channel = await ctx.find_channel( - splits[0], interactive=True, chan_type=discord.ChannelType.voice - ) - if not channel: - # Invalid channel provided - # find_channel already gave a message, just return silently - return - args = splits[1] if len(splits) > 1 else "" - - if not args and not any(flags.values()): - # No arguments given to the `pomodoro` command. - # TODO: If we have a channel, replace this with timer setting information - return await ctx.error_reply( - f"See `{ctx.best_prefix}help pomodoro` for usage and examples." - ) - - if not channel: - return await ctx.error_reply( - f"No channel specified!\n" - "Please join a voice channel or pass the channel id as the first argument.\n" - f"See `{ctx.best_prefix}help pomodoro` for usage and examples." - ) - - # Now we have a channel and configuration arguments - # Next check the user has authority to modify the timer - if not await guild_admin.run(ctx): - # TODO: The channel is a room they own? - return await ctx.error_reply( - "You need to be a guild admin to set up the pomodoro timers!" - ) - - # Get the associated timer, if it exists - timer = Timer.fetch_timer(channel.id) - - # Parse required action - if args.lower() == 'delete': - if timer: - await timer.destroy() - await ctx.embed_reply( - "Destroyed the timer in {}.".format(channel.mention) - ) - else: - await ctx.error_reply( - "{} doesn't have a timer to delete!".format(channel.mention) - ) - elif args or timer: - if args: - # Any provided arguments should be for setting up a new timer pattern - # Check the pomodoro channel exists - if not (timer and timer.text_channel) and not ctx.guild_settings.pomodoro_channel.value: - return await ctx.error_reply( - "Please set the pomodoro alerts channel first, " - f"with `{ctx.best_prefix}config pomodoro_channel `.\n" - f"For example: {ctx.best_prefix}config pomodoro_channel {ctx.ch.mention}" - ) - # First validate input - try: - # Ensure no trailing commas - args = args.strip(',') - if ',' not in args: - raise SafeCancellation("Couldn't parse work and break times!") - - timesplits = args.split(',', maxsplit=1) - if not timesplits[0].isdigit() or len(timesplits[0]) > 3: - raise SafeCancellation(f"Couldn't parse the provided work period length `{timesplits[0]}`.") - - breaksplits = timesplits[1].split(maxsplit=1) - if not breaksplits[0].isdigit() or len(breaksplits[0]) > 3: - raise SafeCancellation(f"Couldn't parse the provided break period length `{breaksplits[0]}`.") - except SafeCancellation as e: - usage = discord.Embed( - title="Couldn't understand arguments!", - colour=discord.Colour.red() - ) - usage.add_field( - name="Usage", - value=( - f"`{ctx.best_prefix}{ctx.alias} [channelid] , [channel name template]" - ) - ) - usage.add_field( - name="Examples", - value=( - f"`{ctx.best_prefix}{ctx.alias} 50, 10`\n" - f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10`\n" - f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10 {{remaining}} - {channel.name}`\n" - ), - inline=False - ) - usage.set_footer( - text=f"For detailed usage and examples see {ctx.best_prefix}help pomodoro" - ) - if e.msg: - usage.description = e.msg - return await ctx.reply(embed=usage) - - # Input validation complete, assign values - focus_length = int(timesplits[0]) - break_length = int(breaksplits[0]) - channelname = breaksplits[1].strip() if len(breaksplits) > 1 else None - - # Check the stages aren't too short - if focus_length < 5: - return await ctx.error_reply("The focus duration must be at least 5 minutes!") - if break_length < 5: - return await ctx.error_reply("The break duration must be at least 5 minutes!") - - # Create or update the timer - if not timer: - # Create timer - # First check number of timers - timers = Timer.fetch_guild_timers(ctx.guild.id) - if len(timers) >= MAX_TIMERS_PER_GUILD: - return await ctx.error_reply( - "Cannot create another timer!\n" - "This server already has the maximum of `{}` timers.".format(MAX_TIMERS_PER_GUILD) - ) - # First check permissions - if not channel.permissions_for(ctx.guild.me).send_messages: - embed = discord.Embed( - title="Could not create timer!", - description=f"I do not have sufficient guild permissions to join {channel.mention}!", - colour=discord.Colour.red() - ) - return await ctx.reply(embed=embed) - - # Create timer - timer = Timer.create( - channel, - focus_length * 60, - break_length * 60, - channel_name=channelname or None, - pretty_name=channel.name - ) - timer.last_seen = { - member.id: utc_now() - for member in timer.members - } - timer.runloop() - - # Post a new status message - await timer.update_last_status() - else: - # Update timer and restart - stage = timer.current_stage - - timer.last_seen = { - member.id: utc_now() - for member in timer.members - } - - with timer.data.batch_update(): - timer.data.focus_length = focus_length * 60 - timer.data.break_length = break_length * 60 - timer.data.last_started = utc_now() - if channelname: - timer.data.channel_name = channelname - - await timer.notify_change_stage(stage, timer.current_stage) - timer.runloop() - - # Ack timer creation - embed = discord.Embed( - colour=discord.Colour.orange(), - title="Timer Started!", - description=( - f"Started a timer in {channel.mention} with **{focus_length}** " - f"minutes focus and **{break_length}** minutes break." - ) - ) - embed.add_field( - name="Further configuration", - value=( - "Use `{prefix}{ctx.alias} --setting value` to configure your new timer.\n" - "*Replace `--setting` with one of the below settings, " - "please see `{prefix}help pomodoro` for examples.*\n" - f"{options_str.format(prefix=ctx.best_prefix)}" - ).format(prefix=ctx.best_prefix, ctx=ctx, channel=channel) - ) - await ctx.reply(embed=embed) - - to_set = [] - if flags['name']: - # Handle name update - to_set.append(( - 'pretty_name', - flags['name'], - f"The timer will now appear as `{flags['name']}` in the status." - )) - if flags['threshold']: - # Handle threshold update - if not flags['threshold'].isdigit(): - return await ctx.error_reply( - "The provided threshold must be a number!" - ) - to_set.append(( - 'inactivity_threshold', - int(flags['threshold']), - "Members will be unsubscribed after being inactive for more than `{}` focus+break stages.".format( - flags['threshold'] - ) - )) - if flags['channelname']: - # Handle channel name update - to_set.append(( - 'channel_name', - flags['channelname'], - f"The voice channel name template is now `{flags['channelname']}`." - )) - if flags['text']: - # Handle text channel update - flag = flags['text'] - if flag.lower() == 'none': - # Check if there is a default channel - channel = ctx.guild_settings.pomodoro_channel.value - if channel: - # Unset the channel to the default - msg = f"The custom text channel has been unset! (Alerts will be sent to {channel.mention})" - to_set.append(( - 'text_channelid', - None, - msg - )) - # Remove the last reaction message and send a new one - timer.reaction_message = None - # Ensure this happens after the data update - asyncio.create_task(timer.update_last_status()) - else: - return await ctx.error_reply( - "The text channel cannot be unset because there is no `pomodoro_channel` set up!\n" - f"See `{ctx.best_prefix}config pomodoro_channel` for setting a default pomodoro channel." - ) - else: - # Attempt to parse the provided channel - channel = await ctx.find_channel(flag, interactive=True, chan_type=discord.ChannelType.text) - if channel: - if not channel.permissions_for(ctx.guild.me).send_messages: - return await ctx.error_reply( - f"Cannot send pomodoro alerts to {channel.mention}! " - "I don't have permission to send messages there." - ) - to_set.append(( - 'text_channelid', - channel.id, - f"Timer alerts and updates will now be sent to {channel.mention}." - )) - # Remove the last reaction message and send a new one - timer.reaction_message = None - # Ensure this happens after the data update - asyncio.create_task(timer.update_last_status()) - else: - # Ack has already been sent, just ignore - return - - if to_set: - to_update = {item[0]: item[1] for item in to_set} - timer.data.update(**to_update) - desc = '\n'.join(f"{tick} {item[2]}" for item in to_set) - embed = discord.Embed( - title=f"Timer option{'s' if len(to_update) > 1 else ''} updated!", - description=desc, - colour=discord.Colour.green() - ) - await ctx.reply(embed=embed) - else: - # Flags were provided, but there is no timer, and no timer was created - await ctx.error_reply( - f"No timer exists in {channel.mention} to set up!\n" - f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```" - f"See `{ctx.best_prefix}help pomodoro` for more examples and usage." - ) diff --git a/src/modules/pending-rewrite/study/timers/data.py b/src/modules/pending-rewrite/study/timers/data.py deleted file mode 100644 index 3103610c..00000000 --- a/src/modules/pending-rewrite/study/timers/data.py +++ /dev/null @@ -1,15 +0,0 @@ -from data import RowTable - - -timers = RowTable( - 'timers', - ('channelid', 'guildid', - 'text_channelid', - 'focus_length', 'break_length', - 'inactivity_threshold', - 'last_started', - 'text_channelid', - 'channel_name', 'pretty_name'), - 'channelid', - cache={} -) diff --git a/src/modules/pending-rewrite/study/timers/settings.py b/src/modules/pending-rewrite/study/timers/settings.py deleted file mode 100644 index 8c3029e0..00000000 --- a/src/modules/pending-rewrite/study/timers/settings.py +++ /dev/null @@ -1,47 +0,0 @@ -import asyncio - -from settings import GuildSettings, GuildSetting -import settings - -from . import Timer - - -@GuildSettings.attach_setting -class pomodoro_channel(settings.TextChannel, GuildSetting): - category = "Study Tracking" - - attr_name = "pomodoro_channel" - _data_column = "pomodoro_channel" - - display_name = "pomodoro_channel" - desc = "Channel to send pomodoro timer status updates and alerts." - - _default = None - - long_desc = ( - "Channel to send pomodoro status updates to.\n" - "Members studying in rooms with an attached timer will need to be able to see " - "this channel to get notifications and react to the status messages." - ) - _accepts = "Any text channel I can write to, or `None` to unset." - - @property - def success_response(self): - timers = Timer.fetch_guild_timers(self.id) - if self.value: - for timer in timers: - if timer.reaction_message and timer.reaction_message.channel != self.value: - timer.reaction_message = None - asyncio.create_task(timer.update_last_status()) - return f"The pomodoro alerts and updates will now be sent to {self.value.mention}" - else: - deleted = 0 - for timer in timers: - if not timer.text_channel: - deleted += 1 - asyncio.create_task(timer.destroy()) - - msg = "The pomodoro alert channel has been unset." - if deleted: - msg += f" `{deleted}` timers were subsequently deactivated." - return msg diff --git a/src/modules/pending-rewrite/study/tracking/__init__.py b/src/modules/pending-rewrite/study/tracking/__init__.py deleted file mode 100644 index c52b4662..00000000 --- a/src/modules/pending-rewrite/study/tracking/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import data -from . import settings -from . import session_tracker -from . import commands diff --git a/src/modules/pending-rewrite/study/tracking/commands.py b/src/modules/pending-rewrite/study/tracking/commands.py deleted file mode 100644 index bdef7059..00000000 --- a/src/modules/pending-rewrite/study/tracking/commands.py +++ /dev/null @@ -1,167 +0,0 @@ -from cmdClient.checks import in_guild -from LionContext import LionContext as Context - -from core import Lion -from wards import is_guild_admin - -from ..module import module - - -MAX_TAG_LENGTH = 10 - - -@module.cmd( - "now", - group="🆕 Pomodoro", - desc="What are you working on?", - aliases=('studying', 'workingon'), - flags=('clear', 'new') -) -@in_guild() -async def cmd_now(ctx: Context, flags): - """ - Usage``: - {prefix}now [tag] - {prefix}now @mention - {prefix}now --clear - Description: - Describe the subject or goal you are working on this session with, for example, `{prefix}now Maths`. - Mention someone else to view what they are working on! - Flags:: - clear: Remove your current tag. - Examples: - > {prefix}now Biology - > {prefix}now {ctx.author.mention} - """ - if flags['clear']: - if ctx.msg.mentions and is_guild_admin(ctx.author): - # Assume an admin is trying to clear another user's tag - for target in ctx.msg.mentions: - lion = Lion.fetch(ctx.guild.id, target.id) - if lion.session: - lion.session.data.tag = None - - if len(ctx.msg.mentions) == 1: - await ctx.embed_reply( - f"Cleared session tags for {ctx.msg.mentions[0].mention}." - ) - else: - await ctx.embed_reply( - f"Cleared session tags for:\n{', '.join(target.mention for target in ctx.msg.mentions)}." - ) - else: - # Assume the user is clearing their own session tag - if (session := ctx.alion.session): - session.data.tag = None - await ctx.embed_reply( - "Removed your session study tag!" - ) - else: - await ctx.embed_reply( - "You aren't studying right now, so there is nothing to clear!" - ) - elif ctx.args: - if ctx.msg.mentions: - # Assume peeking at user's current session - - # Smoll easter egg - target = ctx.msg.mentions[0] - if target == ctx.guild.me: - student_count, guild_count = ctx.client.data.current_sessions.select_one_where( - select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(guildid)) AS guild_count"), - ) - if ctx.alion.session: - if (tag := ctx.alion.session.data.tag): - tail = f"Good luck with your **{tag}**!" - else: - tail = "Good luck with your study, I believe in you!" - else: - tail = "Do you want to join? Hop in a study channel and let's get to work!" - return await ctx.embed_reply( - "Thanks for asking!\n" - f"I'm just helping out the **{student_count}** " - f"dedicated people currently working across **{guild_count}** fun communities!\n" - f"{tail}" - ) - - lion = Lion.fetch(ctx.guild.id, target.id) - if not lion.session: - await ctx.embed_reply( - f"{target.mention} isn't working right now!" - ) - else: - duration = lion.session.duration - if duration > 3600: - dur_str = "{}h {}m".format( - int(duration // 3600), - int((duration % 3600) // 60) - ) - else: - dur_str = "{} minutes".format(int((duration % 3600) // 60)) - - if not lion.session.data.tag: - await ctx.embed_reply( - f"{target.mention} has been working in <#{lion.session.data.channelid}> for **{dur_str}**!" - ) - else: - await ctx.embed_reply( - f"{target.mention} has been working on **{lion.session.data.tag}**" - f" in <#{lion.session.data.channelid}> for **{dur_str}**!" - ) - else: - # Assume setting tag - tag = ctx.args - - if not (session := ctx.alion.session): - return await ctx.error_reply( - "You aren't working right now! Join a study channel and try again!" - ) - - if len(tag) > MAX_TAG_LENGTH: - return await ctx.error_reply( - f"Please keep your tag under `{MAX_TAG_LENGTH}` characters long!" - ) - - old_tag = session.data.tag - session.data.tag = tag - if old_tag: - await ctx.embed_reply( - f"You have updated your session study tag. Good luck with **{tag}**!" - ) - else: - await ctx.embed_reply( - "You have set your session study tag!\nIt will be reset when you leave, or join another channel.\n" - f"Good luck with **{tag}**!" - ) - else: - # View current session, stats, and guide. - if (session := ctx.alion.session): - duration = session.duration - if duration > 3600: - dur_str = "{}h {}m".format( - int(duration // 3600), - int((duration % 3600) // 60) - ) - else: - dur_str = "{} minutes".format(int((duration % 3600) / 60)) - if not session.data.tag: - await ctx.embed_reply( - f"You have been working in <#{session.data.channelid}> for **{dur_str}**!\n" - f"Describe what you are working on with " - f"`{ctx.best_prefix}now `, e.g. `{ctx.best_prefix}now Maths`" - ) - else: - await ctx.embed_reply( - f"You have been working on **{session.data.tag}**" - f" in <#{session.data.channelid}> for **{dur_str}**!" - ) - else: - await ctx.embed_reply( - f"Join a study channel and describe what you are working on with e.g. `{ctx.best_prefix}now Maths`" - ) - - # TODO: Favourite tags listing - # Get tag history ranking top 5 - # If there are any, display top 5 - # Otherwise do nothing - ... diff --git a/src/modules/pending-rewrite/study/tracking/data.py b/src/modules/pending-rewrite/study/tracking/data.py deleted file mode 100644 index 549a7ca6..00000000 --- a/src/modules/pending-rewrite/study/tracking/data.py +++ /dev/null @@ -1,86 +0,0 @@ -from psycopg2.extras import execute_values - -from data import Table, RowTable, tables -from utils.lib import FieldEnum - - -untracked_channels = Table('untracked_channels') - - -class SessionChannelType(FieldEnum): - """ - The possible session channel types. - """ - # NOTE: "None" stands for Unknown, and the STANDARD description should be replaced with the channel name - STANDARD = 'STANDARD', "Standard" - ACCOUNTABILITY = 'ACCOUNTABILITY', "Accountability Room" - RENTED = 'RENTED', "Private Room" - EXTERNAL = 'EXTERNAL', "Unknown" - - -session_history = Table('session_history') -current_sessions = RowTable( - 'current_sessions', - ('guildid', 'userid', 'channelid', 'channel_type', - 'rating', 'tag', - 'start_time', - 'live_duration', 'live_start', - 'stream_duration', 'stream_start', - 'video_duration', 'video_start', - 'hourly_coins', 'hourly_live_coins'), - ('guildid', 'userid'), - cache={} # Keep all current sessions in cache -) - - -@current_sessions.save_query -def close_study_session(guildid, userid): - """ - Close a member's current session if it exists and update the member cache. - """ - # Execute the `close_study_session` database function - with current_sessions.conn as conn: - cursor = conn.cursor() - cursor.callproc('close_study_session', (guildid, userid)) - rows = cursor.fetchall() - # The row has been deleted, remove the from current sessions cache - current_sessions.row_cache.pop((guildid, userid), None) - # Use the function output to update the member cache - tables.lions._make_rows(*rows) - - -@session_history.save_query -def study_time_since(guildid, userid, timestamp): - """ - Retrieve the total member study time (in seconds) since the given timestamp. - Includes the current session, if it exists. - """ - with session_history.conn as conn: - cursor = conn.cursor() - cursor.callproc('study_time_since', (guildid, userid, timestamp)) - rows = cursor.fetchall() - return (rows[0][0] if rows else None) or 0 - - -@session_history.save_query -def study_times_since(guildid, userid, *timestamps): - """ - Retrieve the total member study time (in seconds) since the given timestamps. - Includes the current session, if it exists. - """ - with session_history.conn as conn: - cursor = conn.cursor() - data = execute_values( - cursor, - """ - SELECT study_time_since(t.guildid, t.userid, t.timestamp) - FROM (VALUES %s) - AS t (guildid, userid, timestamp) - """, - [(guildid, userid, timestamp) for timestamp in timestamps], - fetch=True - ) - return data - - -members_totals = Table('members_totals') diff --git a/src/modules/pending-rewrite/study/tracking/session_tracker.py b/src/modules/pending-rewrite/study/tracking/session_tracker.py deleted file mode 100644 index a1392fb2..00000000 --- a/src/modules/pending-rewrite/study/tracking/session_tracker.py +++ /dev/null @@ -1,504 +0,0 @@ -import asyncio -import discord -import logging -import traceback -from typing import Dict -from collections import defaultdict - -from utils.lib import utc_now -from data import tables -from data.conditions import THIS_SHARD -from core import Lion -from meta import client - -from ..module import module -from .data import current_sessions, SessionChannelType -from .settings import untracked_channels, hourly_reward, hourly_live_bonus - - -class Session: - """ - A `Session` describes an ongoing study session by a single guild member. - A member is counted as studying when they are in a tracked voice channel. - - This class acts as an opaque interface to the corresponding `sessions` data row. - """ - __slots__ = ( - 'guildid', - 'userid', - '_expiry_task' - ) - # Global cache of ongoing sessions - sessions: Dict[int, Dict[int, 'Session']] = defaultdict(dict) - - # Global cache of members pending session start (waiting for daily cap reset) - members_pending: Dict[int, Dict[int, asyncio.Task]] = defaultdict(dict) - - def __init__(self, guildid, userid): - self.guildid = guildid - self.userid = userid - - self._expiry_task: asyncio.Task = None - - @classmethod - def get(cls, guildid, userid): - """ - Fetch the current session for the provided member. - If there is no current session, returns `None`. - """ - return cls.sessions[guildid].get(userid, None) - - @classmethod - def start(cls, member: discord.Member, state: discord.VoiceState): - """ - Start a new study session for the provided member. - """ - guildid = member.guild.id - userid = member.id - now = utc_now() - - if userid in cls.sessions[guildid]: - raise ValueError("A session for this member already exists!") - - # If the user is study capped, schedule the session start for the next day - if (lion := Lion.fetch(guildid, userid)).remaining_study_today <= 10: - if pending := cls.members_pending[guildid].pop(userid, None): - pending.cancel() - task = asyncio.create_task(cls._delayed_start(guildid, userid, member, state)) - cls.members_pending[guildid][userid] = task - client.log( - "Member (uid:{}) in (gid:{}) is study capped, " - "delaying session start for {} seconds until start of next day.".format( - userid, guildid, lion.remaining_in_day - ), - context="SESSION_TRACKER", - level=logging.DEBUG - ) - return - - # TODO: More reliable channel type determination - if state.channel.id in tables.rented.row_cache: - channel_type = SessionChannelType.RENTED - elif state.channel.category and state.channel.category.id == lion.guild_settings.accountability_category.data: - channel_type = SessionChannelType.ACCOUNTABILITY - else: - channel_type = SessionChannelType.STANDARD - - current_sessions.create_row( - guildid=guildid, - userid=userid, - channelid=state.channel.id, - channel_type=channel_type, - start_time=now, - live_start=now if (state.self_video or state.self_stream) else None, - stream_start=now if state.self_stream else None, - video_start=now if state.self_video else None, - hourly_coins=hourly_reward.get(guildid).value, - hourly_live_coins=hourly_live_bonus.get(guildid).value - ) - session = cls(guildid, userid).activate() - client.log( - "Started session: {}".format(session.data), - context="SESSION_TRACKER", - level=logging.DEBUG, - ) - - @classmethod - async def _delayed_start(cls, guildid, userid, *args): - delay = Lion.fetch(guildid, userid).remaining_in_day - try: - await asyncio.sleep(delay) - except asyncio.CancelledError: - pass - else: - cls.start(*args) - - @property - def key(self): - """ - RowTable Session identification key. - """ - return (self.guildid, self.userid) - - @property - def lion(self): - """ - The Lion member object associated with this member. - """ - return Lion.fetch(self.guildid, self.userid) - - @property - def data(self): - """ - Row of the `current_sessions` table corresponding to this session. - """ - return current_sessions.fetch(self.key) - - @property - def duration(self): - """ - Current duration of the session. - """ - return (utc_now() - self.data.start_time).total_seconds() - - @property - def coins_earned(self): - """ - Number of coins earned so far. - """ - data = self.data - - coins = self.duration * data.hourly_coins - coins += data.live_duration * data.hourly_live_coins - if data.live_start: - coins += (utc_now() - data.live_start).total_seconds() * data.hourly_live_coins - return coins // 3600 - - def activate(self): - """ - Activate the study session. - This adds the session to the studying members cache, - and schedules the session expiry, based on the daily study cap. - """ - # Add to the active cache - self.sessions[self.guildid][self.userid] = self - - # Schedule the session expiry - self.schedule_expiry() - - # Return self for easy chaining - return self - - def schedule_expiry(self): - """ - Schedule session termination when the user reaches the maximum daily study time. - """ - asyncio.create_task(self._schedule_expiry()) - - async def _schedule_expiry(self): - # Cancel any existing expiry - if self._expiry_task and not self._expiry_task.done(): - self._expiry_task.cancel() - - # Wait for the maximum session length - self._expiry_task = asyncio.create_task(asyncio.sleep(self.lion.remaining_study_today)) - try: - await self._expiry_task - except asyncio.CancelledError: - pass - else: - if self.lion.remaining_study_today <= 10: - # End the session - # Note that the user will not automatically start a new session when the day starts - # TODO: Notify user? Disconnect them? - client.log( - "Session for (uid:{}) in (gid:{}) reached daily guild study cap.\n{}".format( - self.userid, self.guildid, self.data - ), - context="SESSION_TRACKER" - ) - self.finish() - else: - # It's possible the expiry time was pushed forwards while waiting - # If so, reschedule - self.schedule_expiry() - - def finish(self): - """ - Close the study session. - """ - # Note that save_live_status doesn't need to be called here - # The database saving procedure will account for the values. - current_sessions.queries.close_study_session(*self.key) - - # Remove session from active cache - self.sessions[self.guildid].pop(self.userid, None) - - # Cancel any existing expiry task - if self._expiry_task and not self._expiry_task.done(): - self._expiry_task.cancel() - - def save_live_status(self, state: discord.VoiceState): - """ - Update the saved live status of the member. - """ - has_video = state.self_video - has_stream = state.self_stream - is_live = has_video or has_stream - - now = utc_now() - data = self.data - - with data.batch_update(): - # Update video session stats - if data.video_start: - data.video_duration += (now - data.video_start).total_seconds() - data.video_start = now if has_video else None - - # Update stream session stats - if data.stream_start: - data.stream_duration += (now - data.stream_start).total_seconds() - data.stream_start = now if has_stream else None - - # Update overall live session stats - if data.live_start: - data.live_duration += (now - data.live_start).total_seconds() - data.live_start = now if is_live else None - - -async def session_voice_tracker(client, member, before, after): - """ - Voice update event dispatcher for study session tracking. - """ - if member.bot: - return - - guild = member.guild - Lion.fetch(guild.id, member.id).update_saved_data(member) - session = Session.get(guild.id, member.id) - - if before.channel == after.channel: - # Voice state change without moving channel - if session and ((before.self_video != after.self_video) or (before.self_stream != after.self_stream)): - # Live status has changed! - session.save_live_status(after) - else: - # Member changed channel - # End the current session and start a new one, if applicable - if session: - if (scid := session.data.channelid) and (not before.channel or scid != before.channel.id): - client.log( - "The previous voice state for " - "member {member.name} (uid:{member.id}) in {guild.name} (gid:{guild.id}) " - "does not match their current study session!\n" - "Session channel is (cid:{scid}), but the previous channel is {previous}.".format( - member=member, - guild=member.guild, - scid=scid, - previous="{0.name} (cid:{0.id})".format(before.channel) if before.channel else "None" - ), - context="SESSION_TRACKER", - level=logging.ERROR - ) - client.log( - "Ending study session for {member.name} (uid:{member.id}) " - "in {member.guild.id} (gid:{member.guild.id}) since they left the voice channel.\n{session}".format( - member=member, - session=session.data - ), - context="SESSION_TRACKER", - post=False - ) - # End the current session - session.finish() - elif pending := Session.members_pending[guild.id].pop(member.id, None): - client.log( - "Cancelling pending study session for {member.name} (uid:{member.id}) " - "in {member.guild.name} (gid:{member.guild.id}) since they left the voice channel.".format( - member=member - ), - context="SESSION_TRACKER", - post=False - ) - pending.cancel() - - if after.channel: - blacklist = client.user_blacklist() - guild_blacklist = client.objects['ignored_members'][guild.id] - untracked = untracked_channels.get(guild.id).data - start_session = ( - (after.channel.id not in untracked) - and (member.id not in blacklist) - and (member.id not in guild_blacklist) - ) - if start_session: - # Start a new session for the member - client.log( - "Starting a new voice channel study session for {member.name} (uid:{member.id}) " - "in {member.guild.name} (gid:{member.guild.id}).".format( - member=member, - ), - context="SESSION_TRACKER", - post=False - ) - session = Session.start(member, after) - - -async def leave_guild_sessions(client, guild): - """ - `guild_leave` hook. - Close all sessions in the guild when we leave. - """ - sessions = list(Session.sessions[guild.id].values()) - for session in sessions: - session.finish() - client.log( - "Left {} (gid:{}) and closed {} ongoing study sessions.".format(guild.name, guild.id, len(sessions)), - context="SESSION_TRACKER" - ) - - -async def join_guild_sessions(client, guild): - """ - `guild_join` hook. - Refresh all sessions for the guild when we rejoin. - """ - # Delete existing current sessions, which should have been closed when we left - # It is possible we were removed from the guild during an outage - current_sessions.delete_where(guildid=guild.id) - - untracked = untracked_channels.get(guild.id).data - members = [ - member - for channel in guild.voice_channels - for member in channel.members - if channel.members and channel.id not in untracked and not member.bot - ] - for member in members: - client.log( - "Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format( - member.name, - member.id, - member.voice.channel.name, - member.voice.channel.id, - member.guild.name, - member.guild.id - ), - context="SESSION_TRACKER", - level=logging.INFO, - post=False - ) - Session.start(member, member.voice) - - # Log newly started sessions - client.log( - "Joined {} (gid:{}) and started {} new study sessions from current voice channel members.".format( - guild.name, - guild.id, - len(members) - ), - context="SESSION_TRACKER", - ) - - -async def _init_session_tracker(client): - """ - Load ongoing saved study sessions into the session cache, - update them depending on the current voice states, - and attach the voice event handler. - """ - # Ensure the client caches are ready and guilds are chunked - await client.wait_until_ready() - - # Pre-cache the untracked channels - await untracked_channels.launch_task(client) - - # Log init start and define logging counters - client.log( - "Loading ongoing study sessions.", - context="SESSION_INIT", - level=logging.DEBUG - ) - resumed = 0 - ended = 0 - - # Grab all ongoing sessions from data - rows = current_sessions.fetch_rows_where(guildid=THIS_SHARD) - - # Iterate through, resume or end as needed - for row in rows: - if (guild := client.get_guild(row.guildid)) is not None and row.channelid is not None: - try: - # Load the Session - session = Session(row.guildid, row.userid) - - # Find the channel and member voice state - voice = None - if channel := guild.get_channel(row.channelid): - voice = next((member.voice for member in channel.members if member.id == row.userid), None) - - # Resume or end as required - if voice and voice.channel: - client.log( - "Resuming ongoing session: {}".format(row), - context="SESSION_INIT", - level=logging.DEBUG - ) - session.activate() - session.save_live_status(voice) - resumed += 1 - else: - client.log( - "Ending already completed session: {}".format(row), - context="SESSION_INIT", - level=logging.DEBUG - ) - session.finish() - ended += 1 - except Exception: - # Fatal error - client.log( - "Fatal error occurred initialising session: {}\n{}".format(row, traceback.format_exc()), - context="SESSION_INIT", - level=logging.CRITICAL - ) - module.ready = False - return - - # Log resumed sessions - client.log( - "Resumed {} ongoing study sessions, and ended {}.".format(resumed, ended), - context="SESSION_INIT", - level=logging.INFO - ) - - # Now iterate through members of all tracked voice channels - # Start sessions if they don't already exist - tracked_channels = [ - channel - for guild in client.guilds - for channel in guild.voice_channels - if channel.members and channel.id not in untracked_channels.get(guild.id).data - ] - new_members = [ - member - for channel in tracked_channels - for member in channel.members - if not member.bot and not Session.get(member.guild.id, member.id) - ] - for member in new_members: - client.log( - "Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format( - member.name, - member.id, - member.voice.channel.name, - member.voice.channel.id, - member.guild.name, - member.guild.id - ), - context="SESSION_INIT", - level=logging.DEBUG - ) - Session.start(member, member.voice) - - # Log newly started sessions - client.log( - "Started {} new study sessions from current voice channel members.".format(len(new_members)), - context="SESSION_INIT", - level=logging.INFO - ) - - # Now that we are in a valid initial state, attach the session event handler - client.add_after_event("voice_state_update", session_voice_tracker) - client.add_after_event("guild_remove", leave_guild_sessions) - client.add_after_event("guild_join", join_guild_sessions) - - -@module.launch_task -async def launch_session_tracker(client): - """ - Launch the study session initialiser. - Doesn't block on the client being ready. - """ - client.objects['sessions'] = Session.sessions - asyncio.create_task(_init_session_tracker(client)) diff --git a/src/modules/pending-rewrite/study/tracking/settings.py b/src/modules/pending-rewrite/study/tracking/settings.py deleted file mode 100644 index a96651fe..00000000 --- a/src/modules/pending-rewrite/study/tracking/settings.py +++ /dev/null @@ -1,143 +0,0 @@ -import settings -from settings import GuildSettings -from wards import guild_admin - -from .data import untracked_channels - - -@GuildSettings.attach_setting -class untracked_channels(settings.ChannelList, settings.ListData, settings.Setting): - category = "Study Tracking" - - attr_name = 'untracked_channels' - - _table_interface = untracked_channels - _setting = settings.VoiceChannel - - _id_column = 'guildid' - _data_column = 'channelid' - - write_ward = guild_admin - display_name = "untracked_channels" - desc = "Channels to ignore for study time tracking." - - _force_unique = True - - long_desc = ( - "Time spent in these voice channels won't add study time or lioncoins to the member." - ) - - # Flat cache, no need to expire objects - _cache = {} - - @property - def success_response(self): - if self.value: - return "The untracked channels have been updated:\n{}".format(self.formatted) - 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: - cache = {guildid: [] for guildid in active_guildids} - rows = cls._table_interface.select_where( - guildid=active_guildids - ) - 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): - category = "Study Tracking" - - attr_name = "hourly_reward" - _data_column = "study_hourly_reward" - - display_name = "hourly_reward" - desc = "Number of LionCoins given per hour of study." - - _default = 50 - _max = 32767 - - long_desc = ( - "Each spent in a voice channel will reward this number of LionCoins." - ) - _accepts = "An integer number of LionCoins to reward." - - @property - def success_response(self): - return "Members will be rewarded `{}` LionCoins per hour of study.".format(self.formatted) - - -@GuildSettings.attach_setting -class hourly_live_bonus(settings.Integer, settings.GuildSetting): - category = "Study Tracking" - - attr_name = "hourly_live_bonus" - _data_column = "study_hourly_live_bonus" - - display_name = "hourly_live_bonus" - desc = "Number of extra LionCoins given for a full hour of streaming (via go live or video)." - - _default = 150 - _max = 32767 - - long_desc = ( - "LionCoin bonus earnt for every hour a member streams in a voice channel, including video. " - "This is in addition to the standard `hourly_reward`." - ) - _accepts = "An integer number of LionCoins to reward." - - @property - def success_response(self): - return "Members will be rewarded an extra `{}` LionCoins per hour if they stream.".format(self.formatted) - - -@GuildSettings.attach_setting -class daily_study_cap(settings.Duration, settings.GuildSetting): - category = "Study Tracking" - - attr_name = "daily_study_cap" - _data_column = "daily_study_cap" - - display_name = "daily_study_cap" - desc = "Maximum amount of recorded study time per member per day." - - _default = 16 * 60 * 60 - _default_multiplier = 60 * 60 - - _max = 25 * 60 * 60 - - long_desc = ( - "The maximum amount of study time that can be recorded for a member per day, " - "intended to remove system encouragement for unhealthy or obsessive behaviour.\n" - "The member may study for longer, but their sessions will not be tracked. " - "The start and end of the day are determined by the member's configured timezone." - ) - - @property - def success_response(self): - # Refresh expiry for all sessions in the guild - [session.schedule_expiry() for session in self.client.objects['sessions'][self.id].values()] - - return "The maximum tracked daily study time is now {}.".format(self.formatted) diff --git a/src/modules/pending-rewrite/study/tracking/time_tracker.py b/src/modules/pending-rewrite/study/tracking/time_tracker.py deleted file mode 100644 index 96f102c3..00000000 --- a/src/modules/pending-rewrite/study/tracking/time_tracker.py +++ /dev/null @@ -1,109 +0,0 @@ -import itertools -import traceback -import logging -import asyncio -from time import time - -from meta import client -from core import Lion - -from ..module import module -from .settings import untracked_channels, hourly_reward, hourly_live_bonus - - -last_scan = {} # guildid -> timestamp - - -def _scan(guild): - """ - Scan the tracked voice channels and add time and coins to each user. - """ - # Current timestamp - now = time() - - # Get last scan timestamp - try: - last = last_scan[guild.id] - except KeyError: - return - finally: - last_scan[guild.id] = now - - # Calculate time since last scan - interval = now - last - - # Discard if it has been more than 20 minutes (discord outage?) - if interval > 60 * 20: - return - - untracked = untracked_channels.get(guild.id).data - guild_hourly_reward = hourly_reward.get(guild.id).data - guild_hourly_live_bonus = hourly_live_bonus.get(guild.id).data - - channel_members = ( - channel.members for channel in guild.voice_channels if channel.id not in untracked - ) - - members = itertools.chain(*channel_members) - # TODO filter out blacklisted users - - blacklist = client.user_blacklist() - 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 - lion.addTime(interval, flush=False) - - # Add coins - hour_reward = guild_hourly_reward - if member.voice.self_stream or member.voice.self_video: - hour_reward += guild_hourly_live_bonus - - lion.addCoins(hour_reward * interval / (3600), flush=False, bonus=True) - - -async def _study_tracker(): - """ - Scanner launch loop. - """ - while True: - while not client.is_ready(): - await asyncio.sleep(1) - - await asyncio.sleep(5) - - # Launch scanners on each guild - for guild in client.guilds: - # Short wait to pass control to other asyncio tasks if they need it - await asyncio.sleep(0) - try: - # Scan the guild - _scan(guild) - except Exception: - # Unknown exception. Catch it so the loop doesn't die. - client.log( - "Error while scanning guild '{}'(gid:{})! " - "Exception traceback follows.\n{}".format( - guild.name, - guild.id, - traceback.format_exc() - ), - context="VOICE_ACTIVITY_SCANNER", - level=logging.ERROR - ) - - -@module.launch_task -async def launch_study_tracker(client): - # First pre-load the untracked channels - await untracked_channels.launch_task(client) - asyncio.create_task(_study_tracker()) - - -# TODO: Logout handler, sync diff --git a/src/modules/pending-rewrite/sysadmin/__init__.py b/src/modules/pending-rewrite/sysadmin/__init__.py deleted file mode 100644 index 62591ad4..00000000 --- a/src/modules/pending-rewrite/sysadmin/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .module import module - -from . import exec_cmds -from . import guild_log -from . import status -from . import blacklist -from . import botconfig diff --git a/src/modules/pending-rewrite/sysadmin/blacklist.py b/src/modules/pending-rewrite/sysadmin/blacklist.py deleted file mode 100644 index 90202407..00000000 --- a/src/modules/pending-rewrite/sysadmin/blacklist.py +++ /dev/null @@ -1,319 +0,0 @@ -""" -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 meta.sharding import sharded - -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.guild_blacklist() - - 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 separated 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') - ) - - # Leave freshly blacklisted guilds, accounting for shards - to_leave = [] - for guildid in to_add: - guild = ctx.client.get_guild(guildid) - if not guild and sharded: - try: - guild = await ctx.client.fetch_guild(guildid) - except discord.HTTPException: - pass - if guild: - to_leave.append(guild) - - 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.guild_blacklist.cache_clear() - ctx.client.guild_blacklist() - 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.user_blacklist() - - 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.user_blacklist.cache_clear() - ctx.client.user_blacklist() - 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/src/modules/pending-rewrite/sysadmin/botconfig.py b/src/modules/pending-rewrite/sysadmin/botconfig.py deleted file mode 100644 index 3bccf050..00000000 --- a/src/modules/pending-rewrite/sysadmin/botconfig.py +++ /dev/null @@ -1,96 +0,0 @@ -import difflib -import discord -from cmdClient.checks import is_owner - -from settings import UserInputError - -from utils.lib import prop_tabulate - -from .module import module - - -@module.cmd("botconfig", - desc="Update global bot configuration.", - flags=('add', 'remove'), - group="Bot Admin") -@is_owner() -async def cmd_botconfig(ctx, flags): - """ - Usage`` - {prefix}botconfig - {prefix}botconfig info - {prefix}botconfig - {prefix}botconfig - Description: - Usage directly follows the `config` command for guild configuration. - """ - # Cache and map some info for faster access - setting_displaynames = {setting.display_name.lower(): setting for setting in ctx.client.settings.settings.values()} - appid = ctx.client.conf['data_appid'] - - if not ctx.args or ctx.args.lower() in ('info', 'help'): - # Fill the setting cats - cats = {} - for setting in ctx.client.settings.settings.values(): - cat = cats.get(setting.category, []) - cat.append(setting) - cats[setting.category] = cat - - # Format the cats - sections = {} - for catname, cat in cats.items(): - catprops = { - setting.display_name: setting.get(appid).summary if not ctx.args else setting.desc - for setting in cat - } - # TODO: Add cat description here - sections[catname] = prop_tabulate(*zip(*catprops.items())) - - # Build the cat page - embed = discord.Embed( - colour=discord.Colour.orange(), - title="App Configuration" - ) - for name, section in sections.items(): - embed.add_field(name=name, value=section, inline=False) - - await ctx.reply(embed=embed) - else: - # Some args were given - parts = ctx.args.split(maxsplit=1) - - name = parts[0] - setting = setting_displaynames.get(name.lower(), None) - if setting is None: - matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2) - match = "`{}`".format('` or `'.join(matches)) if matches else None - return await ctx.error_reply( - "Couldn't find a setting called `{}`!\n" - "{}" - "Use `{}botconfig info` to see all the available settings.".format( - name, - "Maybe you meant {}?\n".format(match) if match else "", - ctx.best_prefix - ) - ) - - if len(parts) == 1 and not ctx.msg.attachments: - # config - # View config embed for provided setting - await setting.get(appid).widget(ctx, flags=flags) - else: - # config - # Attempt to set config setting - try: - parsed = await setting.parse(appid, 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() - )) - else: - await ctx.reply(embed=discord.Embed( - description="{} {}".format('✅', setting.get(appid).success_response), - colour=discord.Colour.green() - )) diff --git a/src/modules/pending-rewrite/sysadmin/exec_cmds.py b/src/modules/pending-rewrite/sysadmin/exec_cmds.py deleted file mode 100644 index 40260f5b..00000000 --- a/src/modules/pending-rewrite/sysadmin/exec_cmds.py +++ /dev/null @@ -1,135 +0,0 @@ -import sys -from io import StringIO -import traceback -import asyncio - -from cmdClient import cmd, checks - -from core import Lion -from LionModule import LionModule - -""" -Exec level commands to manage the bot. - -Commands provided: - async: - Executes provided code in an async executor - eval: - Executes code and awaits it if required -""" - - -@cmd("shutdown", - desc="Sync data and shutdown.", - group="Bot Admin", - aliases=('restart', 'reboot')) -@checks.is_owner() -async def cmd_shutdown(ctx): - """ - Usage``: - reboot - Description: - Run unload tasks and shutdown/reboot. - """ - # Run module logout tasks - for module in ctx.client.modules: - if isinstance(module, LionModule): - await module.unload(ctx.client) - - # Reply and logout - await ctx.reply("All modules synced. Shutting down!") - await ctx.client.close() - - -@cmd("async", - desc="Execute arbitrary code with `async`.", - group="Bot Admin") -@checks.is_owner() -async def cmd_async(ctx): - """ - Usage: - {prefix}async - Description: - Runs as an asynchronous coroutine and prints the output or error. - """ - if ctx.arg_str == "": - await ctx.error_reply("You must give me something to run!") - return - output, error = await _async(ctx) - await ctx.reply( - "**Async input:**\ - \n```py\n{}\n```\ - \n**Output {}:** \ - \n```py\n{}\n```".format(ctx.arg_str, - "error" if error else "", - output)) - - -@cmd("eval", - desc="Execute arbitrary code with `eval`.", - group="Bot Admin") -@checks.is_owner() -async def cmd_eval(ctx): - """ - Usage: - {prefix}eval - Description: - Runs in current environment using eval() and prints the output or error. - """ - if ctx.arg_str == "": - await ctx.error_reply("You must give me something to run!") - return - output, error = await _eval(ctx) - await ctx.reply( - "**Eval input:**\ - \n```py\n{}\n```\ - \n**Output {}:** \ - \n```py\n{}\n```".format(ctx.arg_str, - "error" if error else "", - output) - ) - - -async def _eval(ctx): - output = None - try: - output = eval(ctx.arg_str) - except Exception: - return (str(traceback.format_exc()), 1) - if asyncio.iscoroutine(output): - output = await output - return (output, 0) - - -async def _async(ctx): - env = { - 'ctx': ctx, - 'client': ctx.client, - 'message': ctx.msg, - 'arg_str': ctx.arg_str - } - env.update(globals()) - old_stdout = sys.stdout - redirected_output = sys.stdout = StringIO() - result = None - exec_string = "async def _temp_exec():\n" - exec_string += '\n'.join(' ' * 4 + line for line in ctx.arg_str.split('\n')) - try: - exec(exec_string, env) - result = (redirected_output.getvalue(), 0) - except Exception: - result = (str(traceback.format_exc()), 1) - return result - _temp_exec = env['_temp_exec'] - try: - returnval = await _temp_exec() - value = redirected_output.getvalue() - if returnval is None: - result = (value, 0) - else: - result = (value + '\n' + str(returnval), 0) - except Exception: - result = (str(traceback.format_exc()), 1) - finally: - sys.stdout = old_stdout - return result diff --git a/src/modules/pending-rewrite/sysadmin/guild_log.py b/src/modules/pending-rewrite/sysadmin/guild_log.py deleted file mode 100644 index 91cb4850..00000000 --- a/src/modules/pending-rewrite/sysadmin/guild_log.py +++ /dev/null @@ -1,83 +0,0 @@ -import datetime -import discord - -from meta import client, conf -from utils.lib import mail - - -@client.add_after_event("guild_remove") -async def log_left_guild(client, guild): - # Build embed - embed = discord.Embed(title="`{0.name} (ID: {0.id})`".format(guild), - colour=discord.Colour.red(), - timestamp=datetime.datetime.utcnow()) - embed.set_author(name="Left guild!") - embed.set_thumbnail(url=guild.icon_url) - - # Add more specific information about the guild - embed.add_field(name="Owner", value="{0.name} (ID: {0.id})".format(guild.owner), inline=False) - embed.add_field(name="Members (cached)", value="{}".format(len(guild.members)), inline=False) - embed.add_field(name="Now studying in", value="{} guilds".format(len(client.guilds)), inline=False) - - # Retrieve the guild log channel and log the event - log_chid = conf.bot.get("guild_log_channel") - if log_chid: - await mail(client, log_chid, embed=embed) - - -@client.add_after_event("guild_join") -async def log_joined_guild(client, guild): - owner = guild.owner - icon = guild.icon_url - - bots = 0 - known = 0 - unknown = 0 - other_members = set(mem.id for mem in client.get_all_members() if mem.guild != guild) - - for member in guild.members: - if member.bot: - bots += 1 - elif member.id in other_members: - known += 1 - else: - unknown += 1 - - mem1 = "people I know" if known != 1 else "person I know" - mem2 = "new friends" if unknown != 1 else "new friend" - mem3 = "bots" if bots != 1 else "bot" - mem4 = "total members" - known = "`{}`".format(known) - unknown = "`{}`".format(unknown) - bots = "`{}`".format(bots) - total = "`{}`".format(guild.member_count) - mem_str = "{0:<5}\t{4},\n{1:<5}\t{5},\n{2:<5}\t{6}, and\n{3:<5}\t{7}.".format( - known, - unknown, - bots, - total, - mem1, - mem2, - mem3, - mem4 - ) - region = str(guild.region) - created = "".format(int(guild.created_at.timestamp())) - - embed = discord.Embed( - title="`{0.name} (ID: {0.id})`".format(guild), - colour=discord.Colour.green(), - timestamp=datetime.datetime.utcnow() - ) - embed.set_author(name="Joined guild!") - - embed.add_field(name="Owner", value="{0} (ID: {0.id})".format(owner), inline=False) - embed.add_field(name="Region", value=region, inline=False) - embed.add_field(name="Created at", value=created, inline=False) - embed.add_field(name="Members", value=mem_str, inline=False) - embed.add_field(name="Now studying in", value="{} guilds".format(len(client.guilds)), inline=False) - - # Retrieve the guild log channel and log the event - log_chid = conf.bot.get("guild_log_channel") - if log_chid: - await mail(client, log_chid, embed=embed) diff --git a/src/modules/pending-rewrite/sysadmin/module.py b/src/modules/pending-rewrite/sysadmin/module.py deleted file mode 100644 index 0e3c53cf..00000000 --- a/src/modules/pending-rewrite/sysadmin/module.py +++ /dev/null @@ -1,3 +0,0 @@ -from LionModule import LionModule - -module = LionModule("Bot_Admin") diff --git a/src/modules/pending-rewrite/sysadmin/status.py b/src/modules/pending-rewrite/sysadmin/status.py deleted file mode 100644 index 853f6410..00000000 --- a/src/modules/pending-rewrite/sysadmin/status.py +++ /dev/null @@ -1,53 +0,0 @@ -import time -import asyncio -import discord -from meta import client - -from .module import module - - -_last_update = 0 - - -async def update_status(): - # TODO: Make globally configurable and saveable - global _last_update - - if time.time() - _last_update < 60: - return - - _last_update = time.time() - - student_count, room_count = client.data.current_sessions.select_one_where( - select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(channelid)) AS channel_count"), - ) - status = "{} students in {} study rooms!".format(student_count, room_count) - - await client.change_presence( - activity=discord.Activity( - type=discord.ActivityType.watching, - name=status - ) - ) - - -@client.add_after_event("voice_state_update") -async def trigger_status_update(client, member, before, after): - if before.channel != after.channel: - await update_status() - - -async def _status_loop(): - while not client.is_ready(): - await asyncio.sleep(5) - while True: - try: - await update_status() - except discord.HTTPException: - pass - await asyncio.sleep(300) - - -@module.launch_task -async def launch_status_update(client): - asyncio.create_task(_status_loop()) diff --git a/src/modules/pending-rewrite/user_config/__init__.py b/src/modules/pending-rewrite/user_config/__init__.py deleted file mode 100644 index 963fc7ff..00000000 --- a/src/modules/pending-rewrite/user_config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .module import module - -from . import commands diff --git a/src/modules/pending-rewrite/user_config/commands.py b/src/modules/pending-rewrite/user_config/commands.py deleted file mode 100644 index e21fafd3..00000000 --- a/src/modules/pending-rewrite/user_config/commands.py +++ /dev/null @@ -1,28 +0,0 @@ -from .module import module - -from settings import UserSettings - - -@module.cmd( - "mytz", - group="Personal Settings", - desc=("Timezone used to display prompts. " - "(Currently {ctx.author_settings.timezone.formatted})"), -) -async def cmd_mytimezone(ctx): - """ - Usage``: - {prefix}mytz - {prefix}mytz - Setting Description: - {ctx.author_settings.settings.timezone.long_desc} - Accepted Values: - Timezone names must be from the "TZ Database Name" column of \ - [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). - For example, `Europe/London`, `Australia/Melbourne`, or `America/New_York`. - Partial names are also accepted. - Examples``: - {prefix}mytz Europe/London - {prefix}mytz London - """ - await UserSettings.settings.timezone.command(ctx, ctx.author.id) diff --git a/src/modules/pending-rewrite/user_config/module.py b/src/modules/pending-rewrite/user_config/module.py deleted file mode 100644 index 50024452..00000000 --- a/src/modules/pending-rewrite/user_config/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from LionModule import LionModule - - -module = LionModule("User_Config") diff --git a/src/pending-rewrite/LionContext.py b/src/pending-rewrite/LionContext.py deleted file mode 100644 index 73ac83a9..00000000 --- a/src/pending-rewrite/LionContext.py +++ /dev/null @@ -1,88 +0,0 @@ -import types - -from cmdClient import Context -from cmdClient.logger import log - - -class LionContext(Context): - """ - Subclass to allow easy attachment of custom hooks and structure to contexts. - """ - __slots__ = () - - @classmethod - def util(cls, util_func): - """ - Decorator to make a utility function available as a Context instance method. - Extends the default Context method to add logging and to return the utility function. - """ - super().util(util_func) - log(f"Attached context utility function: {util_func.__name__}") - return util_func - - @classmethod - def wrappable_util(cls, util_func): - """ - Decorator to add a Wrappable utility function as a Context instance method. - """ - wrappable = Wrappable(util_func) - super().util(wrappable) - log(f"Attached wrappable context utility function: {util_func.__name__}") - return wrappable - - -class Wrappable: - __slots__ = ('_func', 'wrappers') - - def __init__(self, func): - self._func = func - self.wrappers = None - - @property - def __name__(self): - return self._func.__name__ - - def add_wrapper(self, func, name=None): - self.wrappers = self.wrappers or {} - name = name or func.__name__ - self.wrappers[name] = func - log( - f"Added wrapper '{name}' to Wrappable '{self._func.__name__}'.", - context="Wrapping" - ) - - def remove_wrapper(self, name): - if not self.wrappers or name not in self.wrappers: - raise ValueError( - f"Cannot remove non-existent wrapper '{name}' from Wrappable '{self._func.__name__}'" - ) - self.wrappers.pop(name) - log( - f"Removed wrapper '{name}' from Wrappable '{self._func.__name__}'.", - context="Wrapping" - ) - - def __call__(self, *args, **kwargs): - if self.wrappers: - return self._wrapped(iter(self.wrappers.values()))(*args, **kwargs) - else: - return self._func(*args, **kwargs) - - def _wrapped(self, iter_wraps): - next_wrap = next(iter_wraps, None) - if next_wrap: - def _func(*args, **kwargs): - return next_wrap(self._wrapped(iter_wraps), *args, **kwargs) - else: - _func = self._func - return _func - - def __get__(self, instance, cls=None): - if instance is None: - return self - else: - return types.MethodType(self, instance) - - -# Override the original Context.reply with a wrappable utility -reply = LionContext.wrappable_util(Context.reply) diff --git a/src/pending-rewrite/LionModule.py b/src/pending-rewrite/LionModule.py deleted file mode 100644 index 99618f60..00000000 --- a/src/pending-rewrite/LionModule.py +++ /dev/null @@ -1,186 +0,0 @@ -import asyncio -import traceback -import logging -import discord - -from cmdClient import Command, Module, FailedCheck -from cmdClient.lib import SafeCancellation - -from meta import log - - -class LionCommand(Command): - """ - Subclass to allow easy attachment of custom hooks and structure to commands. - """ - allow_before_ready = False - - -class LionModule(Module): - """ - Custom module for Lion systems. - - Adds command wrappers and various event handlers. - """ - name = "Base Lion Module" - - def __init__(self, name, baseCommand=LionCommand): - super().__init__(name, baseCommand) - - self.unload_tasks = [] - - def unload_task(self, func): - """ - Decorator adding an unload task for deactivating the module. - Should sync unsaved transactions and finalise user interaction. - If possible, should also remove attached data and handlers. - """ - self.unload_tasks.append(func) - log("Adding unload task '{}'.".format(func.__name__), context=self.name) - return func - - async def unload(self, client): - """ - Run the unloading tasks. - """ - log("Unloading module.", context=self.name, post=False) - for task in self.unload_tasks: - log("Running unload task '{}'".format(task.__name__), - context=self.name, post=False) - await task(client) - - async def launch(self, client): - """ - Launch hook. - Executed in `client.on_ready`. - Must set `ready` to `True`, otherwise all commands will hang. - Overrides the parent launcher to not post the log as a discord message. - """ - if not self.ready: - log("Running launch tasks.", context=self.name, post=False) - - for task in self.launch_tasks: - log("Running launch task '{}'.".format(task.__name__), - context=self.name, post=False) - await task(client) - - self.ready = True - else: - log("Already launched, skipping launch.", context=self.name, post=False) - - async def pre_command(self, ctx): - """ - Lion pre-command hook. - """ - if not self.ready and not ctx.cmd.allow_before_ready: - try: - await ctx.embed_reply( - "I am currently restarting! Please try again in a couple of minutes." - ) - except discord.HTTPException: - pass - raise SafeCancellation(details="Module '{}' is not ready.".format(self.name)) - - # Check global user blacklist - if ctx.author.id in ctx.client.user_blacklist(): - raise SafeCancellation(details='User is blacklisted.') - - if ctx.guild: - # Check that the channel and guild still exists - if not ctx.client.get_guild(ctx.guild.id) or not ctx.guild.get_channel(ctx.ch.id): - raise SafeCancellation(details='Command channel is no longer reachable.') - - # Check global guild blacklist - if ctx.guild.id in ctx.client.guild_blacklist(): - raise SafeCancellation(details='Guild is blacklisted.') - - # Check guild's own member blacklist - if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]: - raise SafeCancellation(details='User is ignored in this guild.') - - # Check channel permissions are sane - if not ctx.ch.permissions_for(ctx.guild.me).send_messages: - raise SafeCancellation(details='I cannot send messages in this channel.') - if not ctx.ch.permissions_for(ctx.guild.me).embed_links: - await ctx.reply("I need permission to send embeds in this channel before I can run any commands!") - raise SafeCancellation(details='I cannot send embeds in this channel.') - - # Ensure Lion exists and cached data is up to date - ctx.alion.update_saved_data(ctx.author) - - # Start typing - await ctx.ch.trigger_typing() - - async def on_exception(self, ctx, exception): - try: - raise exception - except (FailedCheck, SafeCancellation): - # cmdClient generated and handled exceptions - raise exception - except (asyncio.CancelledError, asyncio.TimeoutError): - # Standard command and task exceptions, cmdClient will also handle these - raise exception - except discord.Forbidden: - # Unknown uncaught Forbidden - try: - # Attempt a general error reply - await ctx.reply("I don't have enough channel or server permissions to complete that command here!") - except discord.Forbidden: - # We can't send anything at all. Exit quietly, but log. - full_traceback = traceback.format_exc() - log(("Caught an unhandled 'Forbidden' while " - "executing command '{cmdname}' from module '{module}' " - "from user '{message.author}' (uid:{message.author.id}) " - "in guild '{message.guild}' (gid:{guildid}) " - "in channel '{message.channel}' (cid:{message.channel.id}).\n" - "Message Content:\n" - "{content}\n" - "{traceback}\n\n" - "{flat_ctx}").format( - cmdname=ctx.cmd.name, - module=ctx.cmd.module.name, - message=ctx.msg, - guildid=ctx.guild.id if ctx.guild else None, - content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()), - traceback=full_traceback, - flat_ctx=ctx.flatten() - ), - context="mid:{}".format(ctx.msg.id), - level=logging.WARNING) - except Exception as e: - # Unknown exception! - full_traceback = traceback.format_exc() - only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only()) - - log(("Caught an unhandled exception while " - "executing command '{cmdname}' from module '{module}' " - "from user '{message.author}' (uid:{message.author.id}) " - "in guild '{message.guild}' (gid:{guildid}) " - "in channel '{message.channel}' (cid:{message.channel.id}).\n" - "Message Content:\n" - "{content}\n" - "{traceback}\n\n" - "{flat_ctx}").format( - cmdname=ctx.cmd.name, - module=ctx.cmd.module.name, - message=ctx.msg, - guildid=ctx.guild.id if ctx.guild else None, - content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()), - traceback=full_traceback, - flat_ctx=ctx.flatten() - ), - context="mid:{}".format(ctx.msg.id), - level=logging.ERROR) - - error_embed = discord.Embed(title="Something went wrong!") - error_embed.description = ( - "An unexpected error occurred while processing your command!\n" - "Our development team has been notified, and the issue should be fixed soon.\n" - ) - if logging.getLogger().getEffectiveLevel() < logging.INFO: - error_embed.add_field( - name="Exception", - value="`{}`".format(only_error) - ) - - await ctx.reply(embed=error_embed) diff --git a/src/pending-rewrite/core/__init__.py b/src/pending-rewrite/core/__init__.py deleted file mode 100644 index 9be4f2bd..00000000 --- a/src/pending-rewrite/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from . import data # noqa - -from .module import module -from .lion import Lion -from . import blacklists diff --git a/src/pending-rewrite/core/blacklists.py b/src/pending-rewrite/core/blacklists.py deleted file mode 100644 index 1ca5bd9c..00000000 --- a/src/pending-rewrite/core/blacklists.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Guild, user, and member blacklists. -""" -from collections import defaultdict -import cachetools.func - -from data import tables -from meta import client - -from .module import module - - -@cachetools.func.ttl_cache(ttl=300) -def guild_blacklist(): - """ - Get the guild blacklist - """ - rows = tables.global_guild_blacklist.select_where() - return set(row['guildid'] for row in rows) - - -@cachetools.func.ttl_cache(ttl=300) -def user_blacklist(): - """ - Get the global user blacklist. - """ - rows = tables.global_user_blacklist.select_where() - return set(row['userid'] for row in rows) - - -@module.init_task -def load_ignored_members(client): - """ - Load the ignored members. - """ - ignored = defaultdict(set) - rows = tables.ignored_members.select_where() - - for row in rows: - ignored[row['guildid']].add(row['userid']) - - client.objects['ignored_members'] = ignored - - if rows: - client.log( - "Loaded {} ignored members across {} guilds.".format( - len(rows), - len(ignored) - ), - context="MEMBER_BLACKLIST" - ) - - -@module.init_task -def attach_client_blacklists(client): - client.guild_blacklist = guild_blacklist - client.user_blacklist = user_blacklist - - -@module.launch_task -async def leave_blacklisted_guilds(client): - """ - Launch task to leave any blacklisted guilds we are in. - """ - to_leave = [ - guild for guild in client.guilds - if guild.id in guild_blacklist() - ] - - for guild in to_leave: - await guild.leave() - - if to_leave: - client.log( - "Left {} blacklisted guilds!".format(len(to_leave)), - context="GUILD_BLACKLIST" - ) - - -@client.add_after_event('guild_join') -async def check_guild_blacklist(client, guild): - """ - Guild join event handler to check whether the guild is blacklisted. - If so, leaves the guild. - """ - # First refresh the blacklist cache - if guild.id in guild_blacklist(): - await guild.leave() - client.log( - "Automatically left blacklisted guild '{}' (gid:{}) upon join.".format(guild.name, guild.id), - context="GUILD_BLACKLIST" - ) diff --git a/src/pending-rewrite/core/data.py b/src/pending-rewrite/core/data.py deleted file mode 100644 index a74c754c..00000000 --- a/src/pending-rewrite/core/data.py +++ /dev/null @@ -1,128 +0,0 @@ -from psycopg2.extras import execute_values - -from cachetools import TTLCache -from data import RowTable, Table - - -meta = RowTable( - 'AppData', - ('appid', 'last_study_badge_scan'), - 'appid', - attach_as='meta', -) - -# TODO: Consider converting to RowTable for per-shard config caching -app_config = Table('AppConfig') - - -user_config = RowTable( - 'user_config', - ('userid', 'timezone', 'topgg_vote_reminder', 'avatar_hash', 'gems'), - 'userid', - cache=TTLCache(5000, ttl=60*5) -) - - -guild_config = RowTable( - 'guild_config', - ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'mod_log_channel', 'alert_channel', - 'studyban_role', 'max_study_bans', - 'min_workout_length', 'workout_reward', - 'max_tasks', 'task_reward', 'task_reward_limit', - 'study_hourly_reward', 'study_hourly_live_bonus', 'daily_study_cap', - 'renting_price', 'renting_category', 'renting_cap', 'renting_role', 'renting_sync_perms', - 'accountability_category', 'accountability_lobby', 'accountability_bonus', - 'accountability_reward', 'accountability_price', - 'video_studyban', 'video_grace_period', - 'greeting_channel', 'greeting_message', 'returning_message', - 'starting_funds', 'persist_roles', - 'pomodoro_channel', - 'name'), - 'guildid', - cache=TTLCache(2500, ttl=60*5) -) - -unranked_roles = Table('unranked_roles') - -donator_roles = Table('donator_roles') - - -lions = RowTable( - 'members', - ('guildid', 'userid', - 'tracked_time', 'coins', - 'workout_count', 'last_workout_start', - 'revision_mute_count', - 'last_study_badgeid', - 'video_warned', - 'display_name', - '_timestamp' - ), - ('guildid', 'userid'), - cache=TTLCache(5000, ttl=60*5), - attach_as='lions' -) - - -@lions.save_query -def add_pending(pending): - """ - pending: - List of tuples of the form `(guildid, userid, pending_coins)`. - """ - with lions.conn: - cursor = lions.conn.cursor() - data = execute_values( - cursor, - """ - UPDATE members - SET - coins = LEAST(coins + t.coin_diff, 2147483647) - FROM - (VALUES %s) - AS - t (guildid, userid, coin_diff) - WHERE - members.guildid = t.guildid - AND - members.userid = t.userid - RETURNING * - """, - pending, - fetch=True - ) - return lions._make_rows(*data) - - -lion_ranks = Table('member_ranks', attach_as='lion_ranks') - - -@lions.save_query -def get_member_rank(guildid, userid, untracked): - """ - Get the time and coin ranking for the given member, ignoring the provided untracked members. - """ - with lions.conn as conn: - with conn.cursor() as curs: - curs.execute( - """ - SELECT - time_rank, coin_rank - FROM ( - SELECT - userid, - row_number() OVER (ORDER BY total_tracked_time DESC, userid ASC) AS time_rank, - row_number() OVER (ORDER BY total_coins DESC, userid ASC) AS coin_rank - FROM members_totals - WHERE - guildid=%s AND userid NOT IN %s - ) AS guild_ranks WHERE userid=%s - """, - (guildid, tuple(untracked), userid) - ) - return curs.fetchone() or (None, None) - - -global_guild_blacklist = Table('global_guild_blacklist') -global_user_blacklist = Table('global_user_blacklist') -ignored_members = Table('ignored_members') diff --git a/src/pending-rewrite/core/lion.py b/src/pending-rewrite/core/lion.py deleted file mode 100644 index b63b56bb..00000000 --- a/src/pending-rewrite/core/lion.py +++ /dev/null @@ -1,347 +0,0 @@ -import pytz -import discord -from functools import reduce -from datetime import datetime, timedelta - -from meta import client -from data import tables as tb -from settings import UserSettings, GuildSettings - -from LionContext import LionContext - - -class Lion: - """ - Class representing a guild Member. - Mostly acts as a transparent interface to the corresponding Row, - but also adds some transaction caching logic to `coins` and `tracked_time`. - """ - __slots__ = ('guildid', 'userid', '_pending_coins', '_member') - - # Members with pending transactions - _pending = {} # userid -> User - - # Lion cache. Currently lions don't expire - _lions = {} # (guildid, userid) -> Lion - - # Extra methods supplying an economy bonus - _economy_bonuses = [] - - def __init__(self, guildid, userid): - self.guildid = guildid - self.userid = userid - - self._pending_coins = 0 - - self._member = None - - self._lions[self.key] = self - - @classmethod - def fetch(cls, guildid, userid): - """ - Fetch a Lion with the given member. - If they don't exist, creates them. - If possible, retrieves the user from the user cache. - """ - key = (guildid, userid) - if key in cls._lions: - return cls._lions[key] - else: - # TODO: Debug log - lion = tb.lions.fetch(key) - if not lion: - tb.user_config.fetch_or_create(userid) - tb.guild_config.fetch_or_create(guildid) - tb.lions.create_row( - guildid=guildid, - userid=userid, - coins=GuildSettings(guildid).starting_funds.value - ) - return cls(guildid, userid) - - @property - def key(self): - return (self.guildid, self.userid) - - @property - def guild(self) -> discord.Guild: - return client.get_guild(self.guildid) - - @property - def member(self) -> discord.Member: - """ - The discord `Member` corresponding to this user. - May be `None` if the member is no longer in the guild or the caches aren't populated. - Not guaranteed to be `None` if the member is not in the guild. - """ - if self._member is None: - guild = client.get_guild(self.guildid) - if guild: - self._member = guild.get_member(self.userid) - return self._member - - @property - def data(self): - """ - The Row corresponding to this member. - """ - return tb.lions.fetch(self.key) - - @property - def user_data(self): - """ - The Row corresponding to this user. - """ - return tb.user_config.fetch_or_create(self.userid) - - @property - def guild_data(self): - """ - The Row corresponding to this guild. - """ - return tb.guild_config.fetch_or_create(self.guildid) - - @property - def settings(self): - """ - The UserSettings interface for this member. - """ - return UserSettings(self.userid) - - @property - def guild_settings(self): - """ - The GuildSettings interface for this member. - """ - return GuildSettings(self.guildid) - - @property - def ctx(self) -> LionContext: - """ - Manufacture a `LionContext` with the lion member as an author. - Useful for accessing member context utilities. - Be aware that `author` may be `None` if the member was not cached. - """ - return LionContext(client, guild=self.guild, author=self.member) - - @property - def time(self): - """ - Amount of time the user has spent studying, accounting for a current session. - """ - # Base time from cached member data - time = self.data.tracked_time - - # Add current session time if it exists - if session := self.session: - time += session.duration - - return int(time) - - @property - def coins(self): - """ - Number of coins the user has, accounting for the pending value and current session. - """ - # Base coin amount from cached member data - coins = self.data.coins - - # Add pending coin amount - coins += self._pending_coins - - # Add current session coins if applicable - if session := self.session: - coins += session.coins_earned - - return int(coins) - - @property - def economy_bonus(self): - """ - Economy multiplier - """ - return reduce( - lambda x, y: x * y, - [func(self) for func in self._economy_bonuses] - ) - - @classmethod - def register_economy_bonus(cls, func): - cls._economy_bonuses.append(func) - - @classmethod - def unregister_economy_bonus(cls, func): - cls._economy_bonuses.remove(func) - - @property - def session(self): - """ - The current study session the user is in, if any. - """ - if 'sessions' not in client.objects: - raise ValueError("Cannot retrieve session before Study module is initialised!") - return client.objects['sessions'][self.guildid].get(self.userid, None) - - @property - def timezone(self): - """ - The user's configured timezone. - Shortcut to `Lion.settings.timezone.value`. - """ - return self.settings.timezone.value - - @property - def day_start(self): - """ - A timezone aware datetime representing the start of the user's day (in their configured timezone). - NOTE: This might not be accurate over DST boundaries. - """ - now = datetime.now(tz=self.timezone) - return now.replace(hour=0, minute=0, second=0, microsecond=0) - - @property - def day_timestamp(self): - """ - EPOCH timestamp representing the current day for the user. - NOTE: This is the timestamp of the start of the current UTC day with the same date as the user's day. - This is *not* the start of the current user's day, either in UTC or their own timezone. - This may also not be the start of the current day in UTC (consider 23:00 for a user in UTC-2). - """ - now = datetime.now(tz=self.timezone) - day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - return int(day_start.replace(tzinfo=pytz.utc).timestamp()) - - @property - def week_timestamp(self): - """ - EPOCH timestamp representing the current week for the user. - """ - now = datetime.now(tz=self.timezone) - day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - week_start = day_start - timedelta(days=day_start.weekday()) - return int(week_start.replace(tzinfo=pytz.utc).timestamp()) - - @property - def month_timestamp(self): - """ - EPOCH timestamp representing the current month for the user. - """ - now = datetime.now(tz=self.timezone) - month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - return int(month_start.replace(tzinfo=pytz.utc).timestamp()) - - @property - def remaining_in_day(self): - return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds() - - @property - def studied_today(self): - """ - The amount of time, in seconds, that the member has studied today. - Extracted from the session history. - """ - return tb.session_history.queries.study_time_since(self.guildid, self.userid, self.day_start) - - @property - def remaining_study_today(self): - """ - Maximum remaining time (in seconds) this member can study today. - - May not account for DST boundaries and leap seconds. - """ - studied_today = self.studied_today - study_cap = self.guild_settings.daily_study_cap.value - - remaining_in_day = self.remaining_in_day - if remaining_in_day >= (study_cap - studied_today): - remaining = study_cap - studied_today - else: - remaining = remaining_in_day + study_cap - - return remaining - - @property - def profile_tags(self): - """ - Returns a list of profile tags, or the default tags. - """ - tags = tb.profile_tags.queries.get_tags_for(self.guildid, self.userid) - prefix = self.ctx.best_prefix - return tags or [ - f"Use {prefix}setprofile", - "and add your tags", - "to this section", - f"See {prefix}help setprofile for more" - ] - - @property - def name(self): - """ - Returns the best local name possible. - """ - if self.member: - name = self.member.display_name - elif self.data.display_name: - name = self.data.display_name - else: - name = str(self.userid) - - return name - - def update_saved_data(self, member: discord.Member): - """ - Update the stored discord data from the givem member. - Intended to be used when we get member data from events that may not be available in cache. - """ - if self.guild_data.name != member.guild.name: - self.guild_data.name = member.guild.name - if self.user_data.avatar_hash != member.avatar: - self.user_data.avatar_hash = member.avatar - if self.data.display_name != member.display_name: - self.data.display_name = member.display_name - - def localize(self, naive_utc_dt): - """ - Localise the provided naive UTC datetime into the user's timezone. - """ - timezone = self.settings.timezone.value - return naive_utc_dt.replace(tzinfo=pytz.UTC).astimezone(timezone) - - def addCoins(self, amount, flush=True, bonus=False): - """ - Add coins to the user, optionally store the transaction in pending. - """ - self._pending_coins += amount * (self.economy_bonus if bonus else 1) - self._pending[self.key] = self - if flush: - self.flush() - - def flush(self): - """ - Flush any pending transactions to the database. - """ - self.sync(self) - - @classmethod - def sync(cls, *lions): - """ - Flush pending transactions to the database. - Also refreshes the Row cache for updated lions. - """ - lions = lions or list(cls._pending.values()) - - if lions: - # Build userid to pending coin map - pending = [ - (lion.guildid, lion.userid, int(lion._pending_coins)) - for lion in lions - ] - - # Write to database - tb.lions.queries.add_pending(pending) - - # Cleanup pending users - for lion in lions: - lion._pending_coins -= int(lion._pending_coins) - cls._pending.pop(lion.key, None) diff --git a/src/pending-rewrite/core/module.py b/src/pending-rewrite/core/module.py deleted file mode 100644 index 2f51408a..00000000 --- a/src/pending-rewrite/core/module.py +++ /dev/null @@ -1,80 +0,0 @@ -import logging -import asyncio - -from meta import client, conf -from settings import GuildSettings, UserSettings - -from LionModule import LionModule - -from .lion import Lion - - -module = LionModule("Core") - - -async def _lion_sync_loop(): - while True: - while not client.is_ready(): - await asyncio.sleep(1) - - client.log( - "Running lion data sync.", - context="CORE", - level=logging.DEBUG, - post=False - ) - - Lion.sync() - await asyncio.sleep(conf.bot.getint("lion_sync_period")) - - -@module.init_task -def setting_initialisation(client): - """ - Execute all Setting initialisation tasks from GuildSettings and UserSettings. - """ - for setting in GuildSettings.settings.values(): - setting.init_task(client) - - for setting in UserSettings.settings.values(): - 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] - if guildids: - 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)) - if userids: - users = client.data.user_config.fetch_rows_where(userid=userids) - members = client.data.lions.fetch_rows_where(userid=userids) - client.log( - "Preloaded data for {} user with {} members.".format(len(users), len(members)), - context="CORE_LOADING" - ) - - -# Removing the sync loop in favour of the studybadge sync. -# @module.launch_task -# async def launch_lion_sync_loop(client): -# asyncio.create_task(_lion_sync_loop()) - - -@module.unload_task -async def final_lion_sync(client): - Lion.sync() diff --git a/src/pending-rewrite/dev_main.py b/src/pending-rewrite/dev_main.py deleted file mode 100644 index e1c8fefe..00000000 --- a/src/pending-rewrite/dev_main.py +++ /dev/null @@ -1,9 +0,0 @@ -import logging -import meta - -meta.logger.setLevel(logging.DEBUG) -logging.getLogger("discord").setLevel(logging.INFO) - -from utils import interactive # noqa - -import main # noqa diff --git a/src/pending-rewrite/settings/__init__.py b/src/pending-rewrite/settings/__init__.py deleted file mode 100644 index fdd6d18d..00000000 --- a/src/pending-rewrite/settings/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base import * # noqa -from .setting_types import * # noqa - -from .user_settings import UserSettings, UserSetting # noqa -from .guild_settings import GuildSettings, GuildSetting # noqa -from .app_settings import AppSettings diff --git a/src/pending-rewrite/settings/app_settings.py b/src/pending-rewrite/settings/app_settings.py deleted file mode 100644 index 637c4b51..00000000 --- a/src/pending-rewrite/settings/app_settings.py +++ /dev/null @@ -1,5 +0,0 @@ -import settings -from utils.lib import DotDict - -class AppSettings(settings.ObjectSettings): - settings = DotDict() diff --git a/src/pending-rewrite/settings/base.py b/src/pending-rewrite/settings/base.py deleted file mode 100644 index a2750796..00000000 --- a/src/pending-rewrite/settings/base.py +++ /dev/null @@ -1,514 +0,0 @@ -import json -import discord -from cmdClient.cmdClient import cmdClient -from cmdClient.lib import SafeCancellation -from cmdClient.Check import Check - -from utils.lib import prop_tabulate, DotDict - -from LionContext import LionContext as Context - -from meta import client -from data import Table, RowTable - - -class Setting: - """ - Abstract base class describing a stored configuration setting. - A setting consists of logic to load the setting from storage, - present it in a readable form, understand user entered values, - and write it again in storage. - Additionally, the setting has attributes attached describing - the setting in a user-friendly manner for display purposes. - """ - attr_name: str = None # Internal attribute name for the setting - _default: ... = None # Default data value for the setting.. this may be None if the setting overrides 'default'. - - write_ward: Check = None # Check that must be passed to write the setting. Not implemented internally. - - # Configuration interface descriptions - display_name: str = None # User readable name of the setting - desc: str = None # User readable brief description of the setting - long_desc: str = None # User readable long description of the setting - accepts: str = None # User readable description of the acceptable values - - def __init__(self, id, data: ..., **kwargs): - self.client: cmdClient = client - self.id = id - self._data = data - - # Configuration embeds - @property - def embed(self): - """ - Discord Embed showing an information summary about the setting. - """ - embed = discord.Embed( - title="Configuration options for `{}`".format(self.display_name), - ) - fields = ("Current value", "Default value", "Accepted input") - values = (self.formatted or "Not Set", - self._format_data(self.id, self.default) or "None", - self.accepts) - table = prop_tabulate(fields, values) - 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): - """ - Formatted summary of the data. - May be implemented in `_format_data(..., summary=True, ...)` or overidden. - """ - return self._format_data(self.id, self.data, summary=True) - - @property - def success_response(self): - """ - Response message sent when the setting has successfully been updated. - """ - return "Setting Updated!" - - # Instance generation - @classmethod - def get(cls, id: int, **kwargs): - """ - Return a setting instance initialised from the stored value. - """ - data = cls._reader(id, **kwargs) - return cls(id, data, **kwargs) - - @classmethod - async def parse(cls, id: int, ctx: Context, userstr: str, **kwargs): - """ - Return a setting instance initialised from a parsed user string. - """ - data = await cls._parse_userstr(ctx, id, userstr, **kwargs) - return cls(id, data, **kwargs) - - # Main interface - @property - def data(self): - """ - Retrieves the current internal setting data if it is set, otherwise the default data - """ - return self._data if self._data is not None else self.default - - @data.setter - def data(self, new_data): - """ - Sets the internal setting data and writes the changes. - """ - self._data = new_data - self.write() - - @property - def default(self): - """ - Retrieves the default value for this setting. - Settings should override this if the default depends on the object id. - """ - return self._default - - @property - def value(self): - """ - Discord-aware object or objects associated with the setting. - """ - return self._data_to_value(self.id, self.data) - - @value.setter - def value(self, new_value): - """ - Setter which reads the discord-aware object, converts it to data, and writes it. - """ - self._data = self._data_from_value(self.id, new_value) - self.write() - - @property - def formatted(self): - """ - User-readable form of the setting. - """ - return self._format_data(self.id, self.data) - - def write(self, **kwargs): - """ - Write value to the database. - For settings which override this, - ensure you handle deletion of values when internal data is None. - """ - self._writer(self.id, self._data, **kwargs) - - # Raw converters - @classmethod - def _data_from_value(cls, id: int, value, **kwargs): - """ - Convert a high-level setting value to internal data. - Must be overriden by the setting. - Be aware of None values, these should always pass through as None - to provide an unsetting interface. - """ - raise NotImplementedError - - @classmethod - def _data_to_value(cls, id: int, data: ..., **kwargs): - """ - Convert internal data to high-level setting value. - Must be overriden by the setting. - """ - raise NotImplementedError - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Parse user provided input into internal data. - Must be overriden by the setting if the setting is user-configurable. - """ - raise NotImplementedError - - @classmethod - def _format_data(cls, id: int, data: ..., **kwargs): - """ - Convert internal data into a formatted user-readable string. - Must be overriden by the setting if the setting is user-viewable. - """ - raise NotImplementedError - - # Database access classmethods - @classmethod - def _reader(cls, id: int, **kwargs): - """ - Read a setting from storage and return setting data or None. - Must be overriden by the setting. - """ - raise NotImplementedError - - @classmethod - def _writer(cls, id: int, data: ..., **kwargs): - """ - Write provided setting data to storage. - Must be overriden by the setting unless the `write` method is overidden. - If the data is None, the setting is empty and should be unset. - """ - raise NotImplementedError - - @classmethod - async def command(cls, ctx, id, flags=()): - """ - Standardised command viewing/setting interface for the setting. - """ - if not ctx.args and not ctx.msg.attachments: - # View config embed for provided cls - await cls.get(id).widget(ctx, flags=flags) - else: - # Check the write ward - if cls.write_ward and not await cls.write_ward.run(ctx): - await ctx.error_reply(cls.write_ward.msg) - else: - # Attempt to set config cls - try: - cls = await cls.parse(id, ctx, ctx.args) - except UserInputError as e: - await ctx.reply(embed=discord.Embed( - description="{} {}".format('❌', e.msg), - Colour=discord.Colour.red() - )) - else: - cls.write() - await ctx.reply(embed=discord.Embed( - description="{} {}".format('✅', cls.success_response), - Colour=discord.Colour.green() - )) - - @classmethod - def init_task(self, client): - """ - Initialisation task to be excuted during client initialisation. - May be used for e.g. populating a cache or required client setup. - - Main application must execute the initialisation task before the setting is used. - Further, the task must always be executable, if the setting is loaded. - Conditional initalisation should go in the relevant module's init tasks. - """ - return None - - -class ObjectSettings: - """ - Abstract class representing a linked collection of settings for a single object. - Initialised settings are provided as instance attributes in the form of properties. - """ - __slots__ = ('id', 'params') - - settings: DotDict = None - - def __init__(self, id, **kwargs): - self.id = id - self.params = tuple(kwargs.items()) - - @classmethod - def _setting_property(cls, setting): - def wrapped_setting(self): - return setting.get(self.id, **dict(self.params)) - return wrapped_setting - - @classmethod - def attach_setting(cls, setting: Setting): - name = setting.attr_name or setting.__name__ - setattr(cls, name, property(cls._setting_property(setting))) - cls.settings[name] = setting - return setting - - def tabulated(self): - """ - Convenience method to provide a complete setting property-table. - """ - formatted = { - setting.display_name: setting.get(self.id, **dict(self.params)).formatted - for name, setting in self.settings.items() - } - return prop_tabulate(*zip(*formatted.items())) - - -class ColumnData: - """ - Mixin for settings stored in a single row and column of a Table. - Intended to be used with tables where the only primary key is the object id. - """ - # Table storing the desired data - _table_interface: Table = None - - # Name of the column storing the setting object id - _id_column: str = None - - # Name of the column with the desired data - _data_column: str = None - - # Whether to use create a row if not found (only applies to TableRow) - _create_row = False - - # Whether to upsert or update for updates - _upsert: bool = True - - # High level data cache to use, set to None to disable cache. - _cache = None # Map[id -> value] - - @classmethod - def _reader(cls, id: int, use_cache=True, **kwargs): - """ - Read in the requested entry associated to the id. - Supports reading cached values from a `RowTable`. - """ - if cls._cache is not None and id in cls._cache and use_cache: - return cls._cache[id] - - table = cls._table_interface - if isinstance(table, RowTable) and cls._id_column == table.id_col: - if cls._create_row: - row = table.fetch_or_create(id) - else: - row = table.fetch(id) - data = row.data[cls._data_column] if row else None - else: - params = { - "select_columns": (cls._data_column,), - cls._id_column: id - } - row = table.select_one_where(**params) - data = row[cls._data_column] if row else None - - if cls._cache is not None: - cls._cache[id] = data - - return data - - @classmethod - def _writer(cls, id: int, data: ..., **kwargs): - """ - Write the provided entry to the table, allowing replacements. - """ - table = cls._table_interface - params = { - cls._id_column: id - } - values = { - cls._data_column: data - } - - # Update data - if cls._upsert: - # Upsert data - table.upsert( - constraint=cls._id_column, - **params, - **values - ) - else: - # Update data - table.update_where(values, **params) - - if cls._cache is not None: - cls._cache[id] = data - - -class ListData: - """ - Mixin for list types implemented on a Table. - Implements a reader and writer. - This assumes the list is the only data stored in the table, - and removes list entries by deleting rows. - """ - # Table storing the setting data - _table_interface: Table = None - - # Name of the column storing the id - _id_column: str = None - - # Name of the column storing the data to read - _data_column: str = None - - # Name of column storing the order index to use, if any. Assumed to be Serial on writing. - _order_column: str = None - _order_type: str = "ASC" - - # High level data cache to use, set to None to disable cache. - _cache = None # Map[id -> value] - - @classmethod - def _reader(cls, id: int, use_cache=True, **kwargs): - """ - Read in all entries associated to the given id. - """ - if cls._cache is not None and id in cls._cache and use_cache: - return cls._cache[id] - - table = cls._table_interface # type: Table - params = { - "select_columns": [cls._data_column], - cls._id_column: id - } - if cls._order_column: - params['_extra'] = "ORDER BY {} {}".format(cls._order_column, cls._order_type) - - rows = table.select_where(**params) - data = [row[cls._data_column] for row in rows] - - if cls._cache is not None: - cls._cache[id] = data - - return data - - @classmethod - def _writer(cls, id: int, data: ..., add_only=False, remove_only=False, **kwargs): - """ - Write the provided list to storage. - """ - # TODO: Transaction lock on the table so this is atomic - # Or just use the connection context manager - - table = cls._table_interface # type: Table - - # Handle None input as an empty list - if data is None: - data = [] - - current = cls._reader(id, **kwargs) - if not cls._order_column and (add_only or remove_only): - to_insert = [item for item in data if item not in current] if not remove_only else [] - to_remove = data if remove_only else ( - [item for item in current if item not in data] if not add_only else [] - ) - - # Handle required deletions - if to_remove: - params = { - cls._id_column: id, - cls._data_column: to_remove - } - table.delete_where(**params) - - # Handle required insertions - if to_insert: - columns = (cls._id_column, cls._data_column) - values = [(id, value) for value in to_insert] - table.insert_many(*values, insert_keys=columns) - - if cls._cache is not None: - new_current = [item for item in current + to_insert if item not in to_remove] - cls._cache[id] = new_current - else: - # Remove all and add all to preserve order - # TODO: This really really should be atomic if anything else reads this - delete_params = {cls._id_column: id} - table.delete_where(**delete_params) - - if data: - columns = (cls._id_column, cls._data_column) - values = [(id, value) for value in data] - table.insert_many(*values, insert_keys=columns) - - if cls._cache is not None: - cls._cache[id] = data - - -class KeyValueData: - """ - Mixin for settings implemented in a Key-Value table. - The underlying table should have a Unique constraint on the `(_id_column, _key_column)` pair. - """ - _table_interface: Table = None - - _id_column: str = None - - _key_column: str = None - - _value_column: str = None - - _key: str = None - - @classmethod - def _reader(cls, id: ..., **kwargs): - params = { - "select_columns": (cls._value_column, ), - cls._id_column: id, - cls._key_column: cls._key - } - - row = cls._table_interface.select_one_where(**params) - data = row[cls._value_column] if row else None - - if data is not None: - data = json.loads(data) - - return data - - @classmethod - def _writer(cls, id: ..., data: ..., **kwargs): - params = { - cls._id_column: id, - cls._key_column: cls._key - } - if data is not None: - values = { - cls._value_column: json.dumps(data) - } - cls._table_interface.upsert( - constraint=f"{cls._id_column}, {cls._key_column}", - **params, - **values - ) - else: - cls._table_interface.delete_where(**params) - - -class UserInputError(SafeCancellation): - pass diff --git a/src/pending-rewrite/settings/guild_settings.py b/src/pending-rewrite/settings/guild_settings.py deleted file mode 100644 index 61106468..00000000 --- a/src/pending-rewrite/settings/guild_settings.py +++ /dev/null @@ -1,197 +0,0 @@ -import datetime -import asyncio -import discord - -import settings -from utils.lib import DotDict -from utils import seekers # noqa - -from wards import guild_admin -from data import tables as tb - - -class GuildSettings(settings.ObjectSettings): - settings = DotDict() - - -class GuildSetting(settings.ColumnData, settings.Setting): - _table_interface = tb.guild_config - _id_column = 'guildid' - _create_row = True - - category = None - - write_ward = guild_admin - - -@GuildSettings.attach_setting -class event_log(settings.Channel, GuildSetting): - category = "Meta" - - attr_name = 'event_log' - _data_column = 'event_log_channel' - - display_name = "event_log" - desc = "Bot event logging channel." - - long_desc = ( - "Channel to post 'events', such as workouts completing or members renting a room." - ) - - _chan_type = discord.ChannelType.text - - @property - def success_response(self): - if self.value: - return "The event log is now {}.".format(self.formatted) - else: - return "The event log has been unset." - - def log(self, description="", colour=discord.Color.orange(), **kwargs): - channel = self.value - if channel: - embed = discord.Embed( - description=description, - colour=colour, - timestamp=datetime.datetime.utcnow(), - **kwargs - ) - asyncio.create_task(channel.send(embed=embed)) - - -@GuildSettings.attach_setting -class admin_role(settings.Role, GuildSetting): - category = "Guild Roles" - - attr_name = 'admin_role' - _data_column = 'admin_role' - - display_name = "admin_role" - desc = "Server administrator role." - - long_desc = ( - "Server administrator role.\n" - "Allows usage of the administrative commands, such as `config`.\n" - "These commands may also be used by anyone with the discord adminitrator permission." - ) - # TODO Expand on what these are. - - @property - def success_response(self): - if self.value: - return "The administrator role is now {}.".format(self.formatted) - else: - return "The administrator role has been unset." - - -@GuildSettings.attach_setting -class mod_role(settings.Role, GuildSetting): - category = "Guild Roles" - - attr_name = 'mod_role' - _data_column = 'mod_role' - - display_name = "mod_role" - desc = "Server moderator role." - - long_desc = ( - "Server moderator role.\n" - "Allows usage of the modistrative commands." - ) - # TODO Expand on what these are. - - @property - def success_response(self): - if self.value: - return "The moderator role is now {}.".format(self.formatted) - else: - return "The moderator role has been unset." - - -@GuildSettings.attach_setting -class unranked_roles(settings.RoleList, settings.ListData, settings.Setting): - category = "Guild Roles" - - attr_name = 'unranked_roles' - - _table_interface = tb.unranked_roles - _id_column = 'guildid' - _data_column = 'roleid' - - write_ward = guild_admin - display_name = "unranked_roles" - desc = "Roles to exclude from the leaderboards." - - _force_unique = True - - long_desc = ( - "Roles to be excluded from the `top` and `topcoins` leaderboards." - ) - - # Flat cache, no need to expire objects - _cache = {} - - @property - def success_response(self): - if self.value: - return "The following roles will be excluded from the leaderboard:\n{}".format(self.formatted) - else: - return "The excluded roles have been removed." - - -@GuildSettings.attach_setting -class donator_roles(settings.RoleList, settings.ListData, settings.Setting): - category = "Hidden" - - attr_name = 'donator_roles' - - _table_interface = tb.donator_roles - _id_column = 'guildid' - _data_column = 'roleid' - - write_ward = guild_admin - display_name = "donator_roles" - desc = "Donator badge roles." - - _force_unique = True - - long_desc = ( - "Members with these roles will be considered donators and have access to premium features." - ) - - # Flat cache, no need to expire objects - _cache = {} - - @property - def success_response(self): - if self.value: - return "The donator badges are now:\n{}".format(self.formatted) - else: - return "The donator badges have been removed." - - -@GuildSettings.attach_setting -class alert_channel(settings.Channel, GuildSetting): - category = "Meta" - - attr_name = 'alert_channel' - _data_column = 'alert_channel' - - display_name = "alert_channel" - desc = "Channel to display global user alerts." - - long_desc = ( - "This channel will be used for group notifications, " - "for example group timers and anti-cheat messages, " - "as well as for critical alerts to users that have their direct messages disapbled.\n" - "It should be visible to all members." - ) - - _chan_type = discord.ChannelType.text - - @property - def success_response(self): - if self.value: - return "The alert channel is now {}.".format(self.formatted) - else: - return "The alert channel has been unset." diff --git a/src/pending-rewrite/settings/setting_types.py b/src/pending-rewrite/settings/setting_types.py deleted file mode 100644 index 8c2863ad..00000000 --- a/src/pending-rewrite/settings/setting_types.py +++ /dev/null @@ -1,1119 +0,0 @@ -import json -import asyncio -import itertools -import traceback -from io import StringIO -from enum import IntEnum -from typing import Any, Optional - -import pytz -import discord -from cmdClient.lib import SafeCancellation - -from meta import client -from utils.lib import parse_dur, strfdur, prop_tabulate, multiple_replace - -from LionContext import LionContext as Context - -from .base import UserInputError - - -preview_emoji = '🔍' - - -class SettingType: - """ - Abstract class representing a setting type. - Intended to be used as a mixin for a Setting, - with the provided methods implementing converter methods for the setting. - """ - accepts: str = None # User readable description of the acceptable values - - # Raw converters - @classmethod - def _data_from_value(cls, id: int, value, **kwargs): - """ - Convert a high-level setting value to internal data. - """ - raise NotImplementedError - - @classmethod - def _data_to_value(cls, id: int, data: Any, **kwargs): - """ - Convert internal data to high-level setting value. - """ - raise NotImplementedError - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Parse user provided input into internal data. - """ - raise NotImplementedError - - @classmethod - def _format_data(cls, id: int, data: Any, **kwargs): - """ - Convert internal data into a formatted user-readable string. - """ - raise NotImplementedError - - -class Boolean(SettingType): - """ - Boolean type, supporting truthy and falsey user input. - Configurable to change truthy and falsey values, and the output map. - - Types: - data: Optional[bool] - The stored boolean value. - value: Optional[bool] - The stored boolean value. - """ - accepts = "Yes/No, On/Off, True/False, Enabled/Disabled" - - # Values that are accepted as truthy and falsey by the parser - _truthy = {"yes", "true", "on", "enable", "enabled"} - _falsey = {"no", "false", "off", "disable", "disabled"} - - # The user-friendly output strings to use for each value - _outputs = {True: "On", False: "Off", None: "Not Set"} - - @classmethod - def _data_from_value(cls, id: int, value: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[bool]. - Directly return the provided value as data. - """ - return value - - @classmethod - def _data_to_value(cls, id: int, data: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[bool]. - Directly return the internal data as the value. - """ - return data - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Looks up the provided string in the truthy and falsey tables. - """ - _userstr = userstr.lower() - if not _userstr or _userstr == "none": - return None - if _userstr in cls._truthy: - return True - elif _userstr in cls._falsey: - return False - else: - raise UserInputError("Unknown boolean type `{}`".format(userstr)) - - @classmethod - def _format_data(cls, id: int, data: bool, **kwargs): - """ - Pass the provided value through the outputs map. - """ - return cls._outputs[data] - - -class Integer(SettingType): - """ - Integer type. Storing any integer. - - Types: - data: Optional[int] - The stored integer value. - value: Optional[int] - The stored integer value. - """ - accepts = "An integer." - - # Set limits on the possible integers - _min = -2147483647 - _max = 2147483647 - - @classmethod - def _data_from_value(cls, id: int, value: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[int]. - Directly return the provided value as data. - """ - return value - - @classmethod - def _data_to_value(cls, id: int, data: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[int]. - Directly return the internal data as the value. - """ - return data - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Relies on integer casting to convert the user string - """ - if not userstr or userstr.lower() == "none": - return None - - try: - num = int(userstr) - except Exception: - raise UserInputError("Couldn't parse provided integer.") from None - - if num > cls._max: - raise UserInputError("Provided integer was too large!") - elif num < cls._min: - raise UserInputError("Provided integer was too small!") - - return num - - @classmethod - def _format_data(cls, id: int, data: Optional[int], **kwargs): - """ - Return the string version of the data. - """ - if data is None: - return None - else: - return str(data) - - -class String(SettingType): - """ - String type, storing arbitrary text. - Configurable to limit text length and restrict input options. - - Types: - data: Optional[str] - The stored string. - value: Optional[str] - The stored string. - """ - accepts = "Any text" - - # Maximum length of string to accept - _maxlen: int = None - - # Set of input options to accept - _options: set = None - - # Whether to quote the string as code - _quote: bool = True - - @classmethod - def _data_from_value(cls, id: int, value: Optional[str], **kwargs): - """ - Return the provided value string as the data string. - """ - return value - - @classmethod - def _data_to_value(cls, id: int, data: Optional[str], **kwargs): - """ - Return the provided data string as the value string. - """ - return data - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Check that the user-entered string is of the correct length. - Accept "None" to unset. - """ - if not userstr or userstr.lower() == "none": - # Unsetting case - return None - elif cls._maxlen is not None and len(userstr) > cls._maxlen: - raise UserInputError("Provided string was too long! Maximum length is `{}`".format(cls._maxlen)) - elif cls._options is not None and not userstr.lower() in cls._options: - raise UserInputError("Invalid option! Valid options are `{}`".format("`, `".join(cls._options))) - else: - return userstr - - @classmethod - def _format_data(cls, id: int, data: str, **kwargs): - """ - Wrap the string in backtics for formatting. - Handle the special case where the string is empty. - """ - if data: - return "`{}`".format(data) if cls._quote else str(data) - else: - return None - - -class Channel(SettingType): - """ - Channel type, storing a single `discord.Channel`. - - Types: - data: Optional[int] - The id of the stored Channel. - value: Optional[discord.abc.GuildChannel] - The stored Channel. - """ - accepts = "Channel mention/id/name, or 'None' to unset" - - # Type of channel, if any - _chan_type: discord.ChannelType = None - - @classmethod - def _data_from_value(cls, id: int, value: Optional[discord.abc.GuildChannel], **kwargs): - """ - Returns the channel id. - """ - return value.id if value is not None else None - - @classmethod - def _data_to_value(cls, id: int, data: Optional[int], **kwargs): - """ - Uses the client to look up the channel id. - Returns the Channel if found, otherwise None. - """ - # Always passthrough None - if data is None: - return None - - return client.get_channel(data) - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Pass to the channel seeker utility to find the requested channel. - Handle `0` and variants of `None` to unset. - """ - if userstr.lower() in ('', '0', 'none'): - return None - else: - channel = await ctx.find_channel(userstr, interactive=True, chan_type=cls._chan_type) - if channel is None: - raise SafeCancellation - else: - return channel.id - - @classmethod - def _format_data(cls, id: int, data: Optional[int], **kwargs): - """ - Retrieve the channel mention, if the channel still exists. - If the channel no longer exists, or cannot be seen by the client, returns None. - """ - if data is None: - return None - else: - channel = client.get_channel(data) - if channel: - return channel.mention - else: - return None - - -class VoiceChannel(Channel): - _chan_type = discord.ChannelType.voice - - -class TextChannel(Channel): - _chan_type = discord.ChannelType.text - - -class Role(SettingType): - """ - Role type, storing a single `discord.Role`. - Configurably allows returning roles which don't exist or are not seen by the client - as `discord.Object`. - - Settings may override `get_guildid` if the setting object `id` is not the guildid. - - Types: - data: Optional[int] - The id of the stored Role. - value: Optional[Union[discord.Role, discord.Object]] - The stored Role, or, if the role wasn't found and `_strict` is not set, - a discord Object with the role id set. - """ - accepts = "Role mention/id/name, or 'None' to unset" - - # Whether to disallow returning roles which don't exist as `discord.Object`s - _strict = True - - _parse_create = False - - @classmethod - def _data_from_value(cls, id: int, value: Optional[discord.Role], **kwargs): - """ - Returns the role id. - """ - return value.id if value is not None else None - - @classmethod - def _data_to_value(cls, id: int, data: Optional[int], **kwargs): - """ - Uses the client to look up the guild and role id. - Returns the role if found, otherwise returns a `discord.Object` with the id set, - depending on the `_strict` setting. - """ - # Always passthrough None - if data is None: - return None - - # Fetch guildid - guildid = cls._get_guildid(id, **kwargs) - - # Search for the role - role = None - guild = client.get_guild(guildid) - if guild is not None: - role = guild.get_role(data) - - if role is not None: - return role - elif not cls._strict: - return discord.Object(id=data) - else: - return None - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Pass to the role seeker utility to find the requested role. - Handle `0` and variants of `None` to unset. - """ - if userstr.lower() in ('', '0', 'none'): - return None - else: - role = await ctx.find_role(userstr, create=cls._parse_create, interactive=True) - if role is None: - raise SafeCancellation - else: - return role.id - - @classmethod - def _format_data(cls, id: int, data: Optional[int], **kwargs): - """ - Retrieve the role mention if found, otherwise the role id or None depending on `_strict`. - """ - role = cls._data_to_value(id, data, **kwargs) - if role is None: - return "Not Set" - elif isinstance(role, discord.Role): - return role.mention - else: - return "`{}`".format(role.id) - - @classmethod - def _get_guildid(cls, id: int, **kwargs): - """ - Fetch the current guildid. - Assumes that the guilid is either passed as a kwarg or is the object id. - Should be overriden in other cases. - """ - return kwargs.get('guildid', id) - - -class Emoji(SettingType): - """ - Emoji type. Stores both custom and unicode emojis. - """ - accepts = "Emoji, either built in or custom. Use 'None' to unset." - - @staticmethod - def _parse_emoji(emojistr): - """ - Converts a provided string into a PartialEmoji. - If the string is badly formatted, returns None. - """ - if ":" in emojistr: - emojistr = emojistr.strip('<>') - splits = emojistr.split(":") - if len(splits) == 3: - animated, name, id = splits - animated = bool(animated) - return discord.PartialEmoji(name, animated=animated, id=int(id)) - else: - # TODO: Check whether this is a valid emoji - return discord.PartialEmoji(emojistr) - - @classmethod - def _data_from_value(cls, id: int, value: Optional[discord.PartialEmoji], **kwargs): - """ - Both data and value are of type Optional[discord.PartialEmoji]. - Directly return the provided value as data. - """ - return value - - @classmethod - def _data_to_value(cls, id: int, data: Optional[discord.PartialEmoji], **kwargs): - """ - Both data and value are of type Optional[discord.PartialEmoji]. - Directly return the internal data as the value. - """ - return data - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Pass to the emoji string parser to get the emoji. - Handle `0` and variants of `None` to unset. - """ - if userstr.lower() in ('', '0', 'none'): - return None - else: - return cls._parse_emoji(userstr) - - @classmethod - def _format_data(cls, id: int, data: Optional[discord.PartialEmoji], **kwargs): - """ - Return a string form of the partial emoji, which generally displays the emoji. - """ - if data is None: - return None - else: - return str(data) - - -class GuildID(SettingType): - """ - Integer type for storing Guild IDs. Stores any snowflake. - - Types: - data: Optional[int] - The stored integer value. - value: Optional[int] - The stored integer value. - """ - accepts = "Any snowflake id." - - @classmethod - def _data_from_value(cls, id: int, value: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[int]. - Directly return the provided value as data. - """ - return value - - @classmethod - def _data_to_value(cls, id: int, data: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[int]. - Directly return the internal data as the value. - """ - return data - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Relies on integer casting to convert the user string - """ - if not userstr or userstr.lower() == "none": - return None - - try: - num = int(userstr) - except Exception: - raise UserInputError("Couldn't parse provided guild id.") from None - - return num - - @classmethod - def _format_data(cls, id: int, data: Optional[int], **kwargs): - """ - Return the string version of the data. - """ - if data is None: - return None - elif (guild := client.get_guild(data)): - return f"`{data}` ({guild.name})" - elif (row := client.data.guild_config.fetch(data)): - return f"`{data}` ({row.name})" - else: - return f"`{data}`" - - -class Timezone(SettingType): - """ - Timezone type, storing a valid timezone string. - - Types: - data: Optional[str] - The string representing the timezone in POSIX format. - value: Optional[timezone] - The pytz timezone. - """ - accepts = ( - "A timezone name from [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) " - "(e.g. `Europe/London`)." - ) - - @classmethod - def _data_from_value(cls, id: int, value: Optional[str], **kwargs): - """ - Return the provided value string as the data string. - """ - if value is not None: - return str(value) - - @classmethod - def _data_to_value(cls, id: int, data: Optional[str], **kwargs): - """ - Return the provided data string as the value string. - """ - if data is not None: - return pytz.timezone(data) - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Check that the user-entered string is of the correct length. - Accept "None" to unset. - """ - if not userstr or userstr.lower() == "none": - # Unsetting case - return None - try: - timezone = pytz.timezone(userstr) - except pytz.exceptions.UnknownTimeZoneError: - timezones = [tz for tz in pytz.all_timezones if userstr.lower() in tz.lower()] - if len(timezones) == 1: - timezone = timezones[0] - elif timezones: - result = await ctx.selector( - "Multiple matching timezones found, please select one.", - timezones - ) - timezone = timezones[result] - else: - raise UserInputError( - "Unknown timezone `{}`. " - "Please provide a TZ name from " - "[this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)".format(userstr) - ) from None - - return str(timezone) - - @classmethod - def _format_data(cls, id: int, data: str, **kwargs): - """ - Wrap the string in backtics for formatting. - Handle the special case where the string is empty. - """ - if data: - return "`{}`".format(data) - else: - return 'Not Set' - - -class IntegerEnum(SettingType): - """ - Integer Enum type, accepting limited strings, storing an integer, and returning an IntEnum value - - Types: - data: Optional[int] - The stored integer. - value: Optional[Any] - The corresponding Enum member - """ - accepts = "A valid option." - - # Enum to use for mapping values - _enum: IntEnum = None - - # Custom map to format the value. If None, uses the enum names. - _output_map = None - - @classmethod - def _data_from_value(cls, id: int, value: ..., **kwargs): - """ - Return the value corresponding to the enum member - """ - if value is not None: - return value.value - - @classmethod - def _data_to_value(cls, id: int, data: ..., **kwargs): - """ - Return the enum member corresponding to the provided integer - """ - if data is not None: - return cls._enum(data) - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Find the corresponding enum member's value to the provided user input. - Accept "None" to unset. - """ - userstr = userstr.lower() - - options = {name.lower(): mem.value for name, mem in cls._enum.__members__.items()} - - if not userstr or userstr == "none": - # Unsetting case - return None - elif userstr not in options: - raise UserInputError("Invalid option!") - else: - return options[userstr] - - @classmethod - def _format_data(cls, id: int, data: int, **kwargs): - """ - Format the data using either the `_enum` or the provided output map. - """ - if data is not None: - value = cls._enum(data) - if cls._output_map: - return cls._output_map[value] - else: - return "`{}`".format(value.name) - - -class Duration(SettingType): - """ - Duration type, stores a time duration in seconds. - - Types: - data: Optional[int] - The stored number of seconds. - value: Optional[int] - The stored number of seconds. - """ - accepts = "A number of days, hours, minutes, and seconds, e.g. `2d 4h 10s`." - - # Set an upper limit on the duration - _max = 60 * 60 * 24 * 365 - _min = None - - # Default multiplier when the number is provided alone - # 1 for seconds, 60 from minutes, etc - _default_multiplier = None - - # Whether to allow empty durations - # This is particularly useful since the duration parser will return 0 for most non-duration strings - allow_zero = False - - # Whether to show days on the output - _show_days = False - - @classmethod - def _data_from_value(cls, id: int, value: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[int]. - Directly return the provided value as data. - """ - return value - - @classmethod - def _data_to_value(cls, id: int, data: Optional[bool], **kwargs): - """ - Both data and value are of type Optional[int]. - Directly return the internal data as the value. - """ - return data - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Parse the provided duration. - """ - if not userstr or userstr.lower() == "none": - return None - - if cls._default_multiplier and userstr.isdigit(): - num = int(userstr) * cls._default_multiplier - else: - num = parse_dur(userstr) - - if num == 0 and not cls.allow_zero: - raise UserInputError( - "The provided duration cannot be `0`! (Please enter in the format `1d 2h 3m 4s`, or `None` to unset.)" - ) - - if cls._max is not None and num > cls._max: - raise UserInputError( - "Duration cannot be longer than `{}`!".format( - strfdur(cls._max, short=False, show_days=cls._show_days) - ) - ) - if cls._min is not None and num < cls._min: - raise UserInputError( - "Duration cannot be shorter than `{}`!".format( - strfdur(cls._min, short=False, show_days=cls._show_days) - ) - ) - - return num - - @classmethod - def _format_data(cls, id: int, data: Optional[int], **kwargs): - """ - Return the string version of the data. - """ - if data is None: - return None - else: - 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 = { - } - - _cmd_str = '{prefix} config {setting}' - - @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) - if not isinstance(args, dict) or (not {'content', 'embed', 'embeds'}.intersection(args.keys())): - raise ValueError("At least one of the 'content', 'embed', or 'embeds' fields are required.") - if 'embed' in args: - discord.Embed.from_dict( - args['embed'] - ) - if 'embeds' in args: - for embed in args['embeds']: - discord.Embed.from_dict( - embed - ) - except Exception as e: - only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only()) - 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).\n" - "```{}```".format(only_error) - ) - if 'embed' in args and 'timestamp' in args['embed']: - args['embed'].pop('timestamp') - if 'embeds' in args: - [embed.pop('timestamp', None) for embed in args['embeds']] - 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 not {'embed', 'content', 'embeds'}.intersection(value.keys()): - return "Invalid" - if 'content' in value and 'embed' not in value and 'embeds' 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 value.get('content', None): - args['content'] = multiple_replace(value['content'], substitutions) - if value.get('embed', None): - args['embed'] = discord.Embed.from_dict( - json.loads(multiple_replace(json.dumps(value['embed']), substitutions)) - ) - if value.get('embeds', None): - args['embeds'] = [ - discord.Embed.from_dict( - json.loads(multiple_replace(json.dumps(embed), substitutions)) - ) - for embed in value['embeds'] - ] - return args - - async def widget(self, ctx, **kwargs): - value = self.value - args = self.args(ctx, **kwargs) - - if not value or not args: - return await ctx.reply(embed=self.embed) - - current_str = None - preview = None - file_content = None - if 'embed' in value or 'embeds' 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 `{cmd_str} `.\n" - "• To include an embed, build the message [here]({builder}) " - "and upload the json code as a file with the `{cmd_str}` command.\n" - "• To reset the message to the default, use `{cmd_str} None`." - ).format( - cmd_str=self._cmd_str, - builder="https://glitchii.github.io/embedbuilder/?editor=gui" - ).format( - prefix=ctx.best_prefix, - setting=self.display_name, - ), - 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. - - Types: - data: List[SettingType.data] - List of data types of the specified SettingType. - Some of the data may be None. - value: List[SettingType.value] - List of the value types of the specified SettingType. - Some of the values may be None. - """ - # Base setting type to make the list from - _setting = None # type: SettingType - - # Whether 'None' values are filtered out of the data when creating values - _allow_null_values = False # type: bool - - # Whether duplicate data values should be filtered out - _force_unique = False - - @classmethod - def _data_from_value(cls, id: int, values: ..., **kwargs): - """ - Returns the setting type data for each value in the value list - """ - if values is None: - # Special behaviour here, store an empty list instead of None - return [] - else: - return [cls._setting._data_from_value(id, value) for value in values] - - @classmethod - def _data_to_value(cls, id: int, data: ..., **kwargs): - """ - Returns the setting type value for each entry in the data list - """ - if data is None: - return [] - else: - values = [cls._setting._data_to_value(id, entry) for entry in data] - - # Filter out null values if required - if not cls._allow_null_values: - values = [value for value in values if value is not None] - return values - - @classmethod - async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs): - """ - Splits the user string across `,` to break up the list. - Handle `0` and variants of `None` to unset. - """ - if userstr.lower() in ('', '0', 'none'): - return [] - else: - data = [] - items = (item.strip() for item in userstr.split(',')) - items = (item for item in items if item) - data = [await cls._setting._parse_userstr(ctx, id, item, **kwargs) for item in items] - - if cls._force_unique: - data = list(set(data)) - return data - - @classmethod - def _format_data(cls, id: int, data: ..., **kwargs): - """ - Format the list by adding `,` between each formatted item - """ - if not data: - return None - else: - formatted_items = [] - for item in data: - formatted_item = cls._setting._format_data(id, item) - if formatted_item is not None: - formatted_items.append(formatted_item) - return ", ".join(formatted_items) - - -class ChannelList(SettingList): - """ - List of channels - """ - accepts = ( - "Comma separated list of channel mentions/ids/names. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove channels." - ) - _setting = Channel - - -class RoleList(SettingList): - """ - List of roles - """ - accepts = ( - "Comma separated list of role mentions/ids/names. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove roles." - ) - _setting = Role - - @property - def members(self): - roles = self.value - return list(set(itertools.chain(*(role.members for role in roles)))) - - -class StringList(SettingList): - """ - List of strings - """ - accepts = ( - "Comma separated list of strings. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove strings." - ) - _setting = String - - -class GuildIDList(SettingList): - """ - List of guildids. - """ - accepts = ( - "Comma separated list of guild ids. Use `None` to unset. " - "Write `--add` or `--remove` to add or remove ids. " - "The provided ids are not verified in any way." - ) - - _setting = GuildID diff --git a/src/pending-rewrite/settings/user_settings.py b/src/pending-rewrite/settings/user_settings.py deleted file mode 100644 index c9c49fc7..00000000 --- a/src/pending-rewrite/settings/user_settings.py +++ /dev/null @@ -1,42 +0,0 @@ -import datetime - -import settings -from utils.lib import DotDict - -from data import tables as tb - - -class UserSettings(settings.ObjectSettings): - settings = DotDict() - - -class UserSetting(settings.ColumnData, settings.Setting): - _table_interface = tb.user_config - _id_column = 'userid' - _create_row = True - - write_ward = None - - -@UserSettings.attach_setting -class timezone(settings.Timezone, UserSetting): - attr_name = 'timezone' - _data_column = 'timezone' - - _default = 'UTC' - - display_name = 'timezone' - desc = "Timezone to display prompts in." - long_desc = ( - "Timezone used for displaying certain prompts (e.g. selecting an accountability room)." - ) - - @property - def success_response(self): - if self.value: - return ( - "Your personal timezone is now {}.\n" - "Your current time is **{}**." - ).format(self.formatted, datetime.datetime.now(tz=self.value).strftime("%H:%M")) - else: - return "Your personal timezone has been unset." diff --git a/src/pending-rewrite/utils/ctx_addons.py b/src/pending-rewrite/utils/ctx_addons.py deleted file mode 100644 index c216731f..00000000 --- a/src/pending-rewrite/utils/ctx_addons.py +++ /dev/null @@ -1,157 +0,0 @@ -import asyncio -import discord -from LionContext import LionContext as Context -from cmdClient.lib import SafeCancellation - -from data import tables -from core import Lion -from . import lib -from settings import GuildSettings, UserSettings - - -@Context.util -async def embed_reply(ctx, desc, colour=discord.Colour.orange(), **kwargs): - """ - Simple helper to embed replies. - All arguments are passed to the embed constructor. - `desc` is passed as the `description` kwarg. - """ - embed = discord.Embed(description=desc, colour=colour, **kwargs) - try: - return await ctx.reply(embed=embed, reference=ctx.msg.to_reference(fail_if_not_exists=False)) - except discord.Forbidden: - if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_messages: - await ctx.reply("Command failed, I don't have permission to send embeds in this channel!") - raise SafeCancellation - - -@Context.util -async def error_reply(ctx, error_str, send_args={}, **kwargs): - """ - Notify the user of a user level error. - Typically, this will occur in a red embed, posted in the command channel. - """ - embed = discord.Embed( - colour=discord.Colour.red(), - description=error_str, - **kwargs - ) - message = None - try: - message = await ctx.ch.send( - embed=embed, - reference=ctx.msg.to_reference(fail_if_not_exists=False), - **send_args - ) - ctx.sent_messages.append(message) - return message - except discord.Forbidden: - if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_messages: - await ctx.reply("Command failed, I don't have permission to send embeds in this channel!") - raise SafeCancellation - - -@Context.util -async def offer_delete(ctx: Context, *to_delete, timeout=300): - """ - Offers to delete the provided messages via a reaction on the last message. - Removes the reaction if the offer times out. - - If any exceptions occur, handles them silently and returns. - - Parameters - ---------- - to_delete: List[Message] - The messages to delete. - - timeout: int - Time in seconds after which to remove the delete offer reaction. - """ - # Get the delete emoji from the config - emoji = lib.cross - - # Return if there are no messages to delete - if not to_delete: - return - - # The message to add the reaction to - react_msg = to_delete[-1] - - # Build the reaction check function - if ctx.guild: - modrole = ctx.guild_settings.mod_role.value if ctx.guild else None - - def check(reaction, user): - if not (reaction.message.id == react_msg.id and reaction.emoji == emoji): - return False - if user == ctx.guild.me: - return False - return ((user == ctx.author) - or (user.permissions_in(ctx.ch).manage_messages) - or (modrole and modrole in user.roles)) - else: - def check(reaction, user): - return user == ctx.author and reaction.message.id == react_msg.id and reaction.emoji == emoji - - try: - # Add the reaction to the message - await react_msg.add_reaction(emoji) - - # Wait for the user to press the reaction - reaction, user = await ctx.client.wait_for("reaction_add", check=check, timeout=timeout) - - # Since the check was satisfied, the reaction is correct. Delete the messages, ignoring any exceptions - deleted = False - # First try to bulk delete if we have the permissions - if ctx.guild and ctx.ch.permissions_for(ctx.guild.me).manage_messages: - try: - await ctx.ch.delete_messages(to_delete) - deleted = True - except Exception: - deleted = False - - # If we couldn't bulk delete, delete them one by one - if not deleted: - try: - asyncio.gather(*[message.delete() for message in to_delete], return_exceptions=True) - except Exception: - pass - except (asyncio.TimeoutError, asyncio.CancelledError): - # Timed out waiting for the reaction, attempt to remove the delete reaction - try: - await react_msg.remove_reaction(emoji, ctx.client.user) - except Exception: - pass - except discord.Forbidden: - pass - except discord.NotFound: - pass - except discord.HTTPException: - pass - - -def context_property(func): - setattr(Context, func.__name__, property(func)) - return func - - -@context_property -def best_prefix(ctx): - return ctx.client.prefix - - -@context_property -def guild_settings(ctx): - if ctx.guild: - tables.guild_config.fetch_or_create(ctx.guild.id) - return GuildSettings(ctx.guild.id if ctx.guild else 0) - - -@context_property -def author_settings(ctx): - return UserSettings(ctx.author.id) - - -@context_property -def alion(ctx): - return Lion.fetch(ctx.guild.id if ctx.guild else 0, ctx.author.id) diff --git a/src/pending-rewrite/utils/interactive.py b/src/pending-rewrite/utils/interactive.py deleted file mode 100644 index 8f31de7c..00000000 --- a/src/pending-rewrite/utils/interactive.py +++ /dev/null @@ -1,461 +0,0 @@ -import asyncio -import discord -from LionContext import LionContext as Context -from cmdClient.lib import UserCancelled, ResponseTimedOut - -from .lib import paginate_list - -# TODO: Interactive locks -cancel_emoji = '❌' -number_emojis = ( - '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣' -) - - -async def discord_shield(coro): - try: - await coro - except discord.HTTPException: - pass - - -@Context.util -async def cancellable(ctx, msg, add_reaction=True, cancel_message=None, timeout=300): - """ - Add a cancellation reaction to the given message. - Pressing the reaction triggers cancellation of the original context, and a UserCancelled-style error response. - """ - # TODO: Not consistent with the exception driven flow, make a decision here? - # Add reaction - if add_reaction and cancel_emoji not in (str(r.emoji) for r in msg.reactions): - try: - await msg.add_reaction(cancel_emoji) - except discord.HTTPException: - return - - # Define cancellation function - async def _cancel(): - try: - await ctx.client.wait_for( - 'reaction_add', - timeout=timeout, - check=lambda r, u: (u == ctx.author - and r.message == msg - and str(r.emoji) == cancel_emoji) - ) - except asyncio.TimeoutError: - pass - else: - await ctx.client.active_command_response_cleaner(ctx) - if cancel_message: - await ctx.error_reply(cancel_message) - else: - try: - await ctx.msg.add_reaction(cancel_emoji) - except discord.HTTPException: - pass - [task.cancel() for task in ctx.tasks] - - # Launch cancellation task - task = asyncio.create_task(_cancel()) - ctx.tasks.append(task) - return task - - -@Context.util -async def listen_for(ctx, allowed_input=None, timeout=120, lower=True, check=None): - """ - Listen for a one of a particular set of input strings, - sent in the current channel by `ctx.author`. - When found, return the message containing them. - - Parameters - ---------- - allowed_input: Union(List(str), None) - List of strings to listen for. - Allowed to be `None` precisely when a `check` function is also supplied. - timeout: int - Number of seconds to wait before timing out. - lower: bool - Whether to shift the allowed and message strings to lowercase before checking. - check: Function(message) -> bool - Alternative custom check function. - - Returns: discord.Message - The message that was matched. - - Raises - ------ - cmdClient.lib.ResponseTimedOut: - Raised when no messages matching the given criteria are detected in `timeout` seconds. - """ - # Generate the check if it hasn't been provided - if not check: - # Quick check the arguments are sane - if not allowed_input: - raise ValueError("allowed_input and check cannot both be None") - - # Force a lower on the allowed inputs - allowed_input = [s.lower() for s in allowed_input] - - # Create the check function - def check(message): - result = (message.author == ctx.author) - result = result and (message.channel == ctx.ch) - result = result and ((message.content.lower() if lower else message.content) in allowed_input) - return result - - # Wait for a matching message, catch and transform the timeout - try: - message = await ctx.client.wait_for('message', check=check, timeout=timeout) - except asyncio.TimeoutError: - raise ResponseTimedOut("Session timed out waiting for user response.") from None - - return message - - -@Context.util -async def selector(ctx, header, select_from, timeout=120, max_len=20): - """ - Interactive routine to prompt the `ctx.author` to select an item from a list. - Returns the list index that was selected. - - Parameters - ---------- - header: str - String to put at the top of each page of selection options. - Intended to be information about the list the user is selecting from. - select_from: List(str) - The list of strings to select from. - timeout: int - The number of seconds to wait before throwing `ResponseTimedOut`. - max_len: int - The maximum number of items to display on each page. - Decrease this if the items are long, to avoid going over the char limit. - - Returns - ------- - int: - The index of the list entry selected by the user. - - Raises - ------ - cmdClient.lib.UserCancelled: - Raised if the user manually cancels the selection. - cmdClient.lib.ResponseTimedOut: - Raised if the user fails to respond to the selector within `timeout` seconds. - """ - # Handle improper arguments - if len(select_from) == 0: - raise ValueError("Selection list passed to `selector` cannot be empty.") - - # Generate the selector pages - footer = "Please reply with the number of your selection, or press {} to cancel.".format(cancel_emoji) - list_pages = paginate_list(select_from, block_length=max_len) - pages = ["\n".join([header, page, footer]) for page in list_pages] - - # Post the pages in a paged message - out_msg = await ctx.pager(pages, add_cancel=True) - cancel_task = await ctx.cancellable(out_msg, add_reaction=False, timeout=None) - - if len(select_from) <= 5: - for i, _ in enumerate(select_from): - asyncio.create_task(discord_shield(out_msg.add_reaction(number_emojis[i]))) - - # Build response tasks - valid_input = [str(i+1) for i in range(0, len(select_from))] + ['c', 'C'] - listen_task = asyncio.create_task(ctx.listen_for(valid_input, timeout=None)) - emoji_task = asyncio.create_task(ctx.client.wait_for( - 'reaction_add', - check=lambda r, u: (u == ctx.author - and r.message == out_msg - and str(r.emoji) in number_emojis) - )) - # Wait for the response tasks - done, pending = await asyncio.wait( - (listen_task, emoji_task), - timeout=timeout, - return_when=asyncio.FIRST_COMPLETED - ) - - # Cleanup - try: - await out_msg.delete() - except discord.HTTPException: - pass - - # Handle different return cases - if listen_task in done: - emoji_task.cancel() - - result_msg = listen_task.result() - try: - await result_msg.delete() - except discord.HTTPException: - pass - if result_msg.content.lower() == 'c': - raise UserCancelled("Selection cancelled!") - result = int(result_msg.content) - 1 - elif emoji_task in done: - listen_task.cancel() - - reaction, _ = emoji_task.result() - result = number_emojis.index(str(reaction.emoji)) - elif cancel_task in done: - # Manually cancelled case.. the current task should have been cancelled - # Raise UserCancelled in case the task wasn't cancelled for some reason - raise UserCancelled("Selection cancelled!") - elif not done: - # Timeout case - raise ResponseTimedOut("Selector timed out waiting for a response.") - - # Finally cancel the canceller and return the provided index - cancel_task.cancel() - return result - - -@Context.util -async def pager(ctx, pages, locked=True, start_at=0, add_cancel=False, **kwargs): - """ - Shows the user each page from the provided list `pages` one at a time, - providing reactions to page back and forth between pages. - This is done asynchronously, and returns after displaying the first page. - - Parameters - ---------- - pages: List(Union(str, discord.Embed)) - A list of either strings or embeds to display as the pages. - locked: bool - Whether only the `ctx.author` should be able to use the paging reactions. - kwargs: ... - Remaining keyword arguments are transparently passed to the reply context method. - - Returns: discord.Message - This is the output message, returned for easy deletion. - """ - # Handle broken input - if len(pages) == 0: - raise ValueError("Pager cannot page with no pages!") - - # Post first page. Method depends on whether the page is an embed or not. - if isinstance(pages[start_at], discord.Embed): - out_msg = await ctx.reply(embed=pages[start_at], **kwargs) - else: - out_msg = await ctx.reply(pages[start_at], **kwargs) - - # Run the paging loop if required - if len(pages) > 1: - task = asyncio.create_task(_pager(ctx, out_msg, pages, locked, start_at, add_cancel, **kwargs)) - ctx.tasks.append(task) - elif add_cancel: - await out_msg.add_reaction(cancel_emoji) - - # Return the output message - return out_msg - - -async def _pager(ctx, out_msg, pages, locked, start_at, add_cancel, **kwargs): - """ - Asynchronous initialiser and loop for the `pager` utility above. - """ - # Page number - page = start_at - - # Add reactions to the output message - next_emoji = "▶" - prev_emoji = "◀" - - try: - await out_msg.add_reaction(prev_emoji) - if add_cancel: - await out_msg.add_reaction(cancel_emoji) - await out_msg.add_reaction(next_emoji) - except discord.Forbidden: - # We don't have permission to add paging emojis - # Die as gracefully as we can - if ctx.guild: - perms = ctx.ch.permissions_for(ctx.guild.me) - if not perms.add_reactions: - await ctx.error_reply( - "Cannot page results because I do not have the `add_reactions` permission!" - ) - elif not perms.read_message_history: - await ctx.error_reply( - "Cannot page results because I do not have the `read_message_history` permission!" - ) - else: - await ctx.error_reply( - "Cannot page results due to insufficient permissions!" - ) - else: - await ctx.error_reply( - "Cannot page results!" - ) - return - - # Check function to determine whether a reaction is valid - def reaction_check(reaction, user): - result = reaction.message.id == out_msg.id - result = result and str(reaction.emoji) in [next_emoji, prev_emoji] - result = result and not (user.id == ctx.client.user.id) - result = result and not (locked and user != ctx.author) - return result - - # Check function to determine if message has a page number - def message_check(message): - result = message.channel.id == ctx.ch.id - result = result and not (locked and message.author != ctx.author) - result = result and message.content.lower().startswith('p') - result = result and message.content[1:].isdigit() - result = result and 1 <= int(message.content[1:]) <= len(pages) - return result - - # Begin loop - while True: - # Wait for a valid reaction or message, break if we time out - reaction_task = asyncio.create_task( - ctx.client.wait_for('reaction_add', check=reaction_check) - ) - message_task = asyncio.create_task( - ctx.client.wait_for('message', check=message_check) - ) - done, pending = await asyncio.wait( - (reaction_task, message_task), - timeout=300, - return_when=asyncio.FIRST_COMPLETED - ) - if done: - if reaction_task in done: - # Cancel the message task and collect the reaction result - message_task.cancel() - reaction, user = reaction_task.result() - - # Attempt to remove the user's reaction, silently ignore errors - asyncio.ensure_future(out_msg.remove_reaction(reaction.emoji, user)) - - # Change the page number - page += 1 if reaction.emoji == next_emoji else -1 - page %= len(pages) - elif message_task in done: - # Cancel the reaction task and collect the message result - reaction_task.cancel() - message = message_task.result() - - # Attempt to delete the user's message, silently ignore errors - asyncio.ensure_future(message.delete()) - - # Move to the correct page - page = int(message.content[1:]) - 1 - - # Edit the message with the new page - active_page = pages[page] - if isinstance(active_page, discord.Embed): - await out_msg.edit(embed=active_page, **kwargs) - else: - await out_msg.edit(content=active_page, **kwargs) - else: - # No tasks finished, so we must have timed out, or had an exception. - # Break the loop and clean up - break - - # Clean up by removing the reactions - try: - await out_msg.clear_reactions() - except discord.Forbidden: - try: - await out_msg.remove_reaction(next_emoji, ctx.client.user) - await out_msg.remove_reaction(prev_emoji, ctx.client.user) - except discord.NotFound: - pass - except discord.NotFound: - pass - - -@Context.util -async def input(ctx, msg="", timeout=120): - """ - Listen for a response in the current channel, from ctx.author. - Returns the response from ctx.author, if it is provided. - Parameters - ---------- - msg: string - Allows a custom input message to be provided. - Will use default message if not provided. - timeout: int - Number of seconds to wait before timing out. - Raises - ------ - cmdClient.lib.ResponseTimedOut: - Raised when ctx.author does not provide a response before the function times out. - """ - # Deliver prompt - offer_msg = await ctx.reply(msg or "Please enter your input.") - - # Criteria for the input message - def checks(m): - return m.author == ctx.author and m.channel == ctx.ch - - # Listen for the reply - try: - result_msg = await ctx.client.wait_for("message", check=checks, timeout=timeout) - except asyncio.TimeoutError: - raise ResponseTimedOut("Session timed out waiting for user response.") from None - - result = result_msg.content - - # Attempt to delete the prompt and reply messages - try: - await offer_msg.delete() - await result_msg.delete() - except Exception: - pass - - return result - - -@Context.util -async def ask(ctx, msg, timeout=30, use_msg=None, del_on_timeout=False): - """ - Ask ctx.author a yes/no question. - Returns 0 if ctx.author answers no - Returns 1 if ctx.author answers yes - Parameters - ---------- - msg: string - Adds the question to the message string. - Requires an input. - timeout: int - Number of seconds to wait before timing out. - use_msg: string - A completely custom string to use instead of the default string. - del_on_timeout: bool - Whether to delete the question if it times out. - Raises - ------ - Nothing - """ - out = "{} {}".format(msg, "`y(es)`/`n(o)`") - - offer_msg = use_msg or await ctx.reply(out) - if use_msg and msg: - await use_msg.edit(content=msg) - - result_msg = await ctx.listen_for(["y", "yes", "n", "no"], timeout=timeout) - - if result_msg is None: - if del_on_timeout: - try: - await offer_msg.delete() - except Exception: - pass - return None - result = result_msg.content.lower() - try: - if not use_msg: - await offer_msg.delete() - await result_msg.delete() - except Exception: - pass - if result in ["n", "no"]: - return 0 - return 1 diff --git a/src/pending-rewrite/utils/seekers.py b/src/pending-rewrite/utils/seekers.py deleted file mode 100644 index e7938d47..00000000 --- a/src/pending-rewrite/utils/seekers.py +++ /dev/null @@ -1,427 +0,0 @@ -import asyncio -import discord - -from LionContext import LionContext as Context -from cmdClient.lib import InvalidContext, UserCancelled, ResponseTimedOut, SafeCancellation -from . import interactive as _interactive - - -@Context.util -async def find_role(ctx, userstr, create=False, interactive=False, collection=None, allow_notfound=True): - """ - Find a guild role given a partial matching string, - allowing custom role collections and several behavioural switches. - - Parameters - ---------- - userstr: str - String obtained from a user, expected to partially match a role in the collection. - The string will be tested against both the id and the name of the role. - create: bool - Whether to offer to create the role if it does not exist. - The bot will only offer to create the role if it has the `manage_channels` permission. - interactive: bool - Whether to offer the user a list of roles to choose from, - or pick the first matching role. - collection: List[Union[discord.Role, discord.Object]] - Collection of roles to search amongst. - If none, uses the guild role list. - allow_notfound: bool - Whether to return `None` when there are no matches, instead of raising `SafeCancellation`. - Overriden by `create`, if it is set. - - Returns - ------- - discord.Role: - If a valid role is found. - None: - If no valid role has been found. - - Raises - ------ - cmdClient.lib.UserCancelled: - If the user cancels interactive role selection. - cmdClient.lib.ResponseTimedOut: - If the user fails to respond to interactive role selection within `60` seconds` - cmdClient.lib.SafeCancellation: - If `allow_notfound` is `False`, and the search returned no matches. - """ - # Handle invalid situations and input - if not ctx.guild: - raise InvalidContext("Attempt to use find_role outside of a guild.") - - if userstr == "": - raise ValueError("User string passed to find_role was empty.") - - # Create the collection to search from args or guild roles - collection = collection if collection is not None else ctx.guild.roles - - # If the unser input was a number or possible role mention, get it out - userstr = userstr.strip() - roleid = userstr.strip('<#@&!> ') - roleid = int(roleid) if roleid.isdigit() else None - searchstr = userstr.lower() - - # Find the role - role = None - - # Check method to determine whether a role matches - def check(role): - return (role.id == roleid) or (searchstr in role.name.lower()) - - # Get list of matching roles - roles = list(filter(check, collection)) - - if len(roles) == 0: - # Nope - role = None - elif len(roles) == 1: - # Select our lucky winner - role = roles[0] - else: - # We have multiple matching roles! - if interactive: - # Interactive prompt with the list of roles, handle `Object`s - role_names = [ - role.name if isinstance(role, discord.Role) else str(role.id) for role in roles - ] - - try: - selected = await ctx.selector( - "`{}` roles found matching `{}`!".format(len(roles), userstr), - role_names, - timeout=60 - ) - except UserCancelled: - raise UserCancelled("User cancelled role selection.") from None - except ResponseTimedOut: - raise ResponseTimedOut("Role selection timed out.") from None - - role = roles[selected] - else: - # Just select the first one - role = roles[0] - - # Handle non-existence of the role - if role is None: - msgstr = "Couldn't find a role matching `{}`!".format(userstr) - if create: - # Inform the user - msg = await ctx.error_reply(msgstr) - if ctx.guild.me.guild_permissions.manage_roles: - # Offer to create it - resp = await ctx.ask("Would you like to create this role?", timeout=30) - if resp: - # They accepted, create the role - # Before creation, check if the role name is too long - if len(userstr) > 100: - await ctx.error_reply("Could not create a role with a name over 100 characters long!") - else: - role = await ctx.guild.create_role( - name=userstr, - reason="Interactive role creation for {} (uid:{})".format(ctx.author, ctx.author.id) - ) - await msg.delete() - await ctx.reply("You have created the role `{}`!".format(userstr)) - - # If we still don't have a role, cancel unless allow_notfound is set - if role is None and not allow_notfound: - raise SafeCancellation - elif not allow_notfound: - raise SafeCancellation(msgstr) - else: - await ctx.error_reply(msgstr) - - return role - - -@Context.util -async def find_channel(ctx, userstr, interactive=False, collection=None, chan_type=None, type_name=None): - """ - Find a guild channel given a partial matching string, - allowing custom channel collections and several behavioural switches. - - Parameters - ---------- - userstr: str - String obtained from a user, expected to partially match a channel in the collection. - The string will be tested against both the id and the name of the channel. - interactive: bool - Whether to offer the user a list of channels to choose from, - or pick the first matching channel. - collection: List(discord.Channel) - Collection of channels to search amongst. - If none, uses the full guild channel list. - chan_type: discord.ChannelType - Type of channel to restrict the collection to. - type_name: str - Optional name to use for the channel type if it is not found. - Used particularly with custom collections. - - Returns - ------- - discord.Channel: - If a valid channel is found. - None: - If no valid channel has been found. - - Raises - ------ - cmdClient.lib.UserCancelled: - If the user cancels interactive channel selection. - cmdClient.lib.ResponseTimedOut: - If the user fails to respond to interactive channel selection within `60` seconds` - """ - # Handle invalid situations and input - if not ctx.guild: - raise InvalidContext("Attempt to use find_channel outside of a guild.") - - if userstr == "": - raise ValueError("User string passed to find_channel was empty.") - - # Create the collection to search from args or guild channels - collection = collection if collection else ctx.guild.channels - if chan_type is not None: - if chan_type == discord.ChannelType.text: - # Hack to support news channels as text channels - collection = [chan for chan in collection if isinstance(chan, discord.TextChannel)] - else: - collection = [chan for chan in collection if chan.type == chan_type] - - # If the user input was a number or possible channel mention, extract it - chanid = userstr.strip('<#@&!>') - chanid = int(chanid) if chanid.isdigit() else None - searchstr = userstr.lower() - - # Find the channel - chan = None - - # Check method to determine whether a channel matches - def check(chan): - return (chan.id == chanid) or (searchstr in chan.name.lower()) - - # Get list of matching roles - channels = list(filter(check, collection)) - - if len(channels) == 0: - # Nope - chan = None - elif len(channels) == 1: - # Select our lucky winner - chan = channels[0] - else: - # We have multiple matching channels! - if interactive: - # Interactive prompt with the list of channels - chan_names = [chan.name for chan in channels] - - try: - selected = await ctx.selector( - "`{}` channels found matching `{}`!".format(len(channels), userstr), - chan_names, - timeout=60 - ) - except UserCancelled: - raise UserCancelled("User cancelled channel selection.") from None - except ResponseTimedOut: - raise ResponseTimedOut("Channel selection timed out.") from None - - chan = channels[selected] - else: - # Just select the first one - chan = channels[0] - - if chan is None: - typestr = type_name - addendum = "" - if chan_type and not type_name: - chan_type_strings = { - discord.ChannelType.category: "category", - discord.ChannelType.text: "text channel", - discord.ChannelType.voice: "voice channel", - discord.ChannelType.stage_voice: "stage channel", - } - typestr = chan_type_strings.get(chan_type, None) - if typestr and chanid: - actual = ctx.guild.get_channel(chanid) - if actual and actual.type in chan_type_strings: - addendum = "\n{} appears to be a {} instead.".format( - actual.mention, - chan_type_strings[actual.type] - ) - typestr = typestr or "channel" - - await ctx.error_reply("Couldn't find a {} matching `{}`!{}".format(typestr, userstr, addendum)) - - return chan - - -@Context.util -async def find_member(ctx, userstr, interactive=False, collection=None, silent=False): - """ - Find a guild member given a partial matching string, - allowing custom member collections. - - Parameters - ---------- - userstr: str - String obtained from a user, expected to partially match a member in the collection. - The string will be tested against both the userid, full user name and user nickname. - interactive: bool - Whether to offer the user a list of members to choose from, - or pick the first matching channel. - collection: List(discord.Member) - Collection of members to search amongst. - If none, uses the full guild member list. - silent: bool - Whether to reply with an error when there are no matches. - - Returns - ------- - discord.Member: - If a valid member is found. - None: - If no valid member has been found. - - Raises - ------ - cmdClient.lib.UserCancelled: - If the user cancels interactive member selection. - cmdClient.lib.ResponseTimedOut: - If the user fails to respond to interactive member selection within `60` seconds` - """ - # Handle invalid situations and input - if not ctx.guild: - raise InvalidContext("Attempt to use find_member outside of a guild.") - - if userstr == "": - raise ValueError("User string passed to find_member was empty.") - - # Create the collection to search from args or guild members - collection = collection if collection else ctx.guild.members - - # If the user input was a number or possible member mention, extract it - userid = userstr.strip('<#@&!>') - userid = int(userid) if userid.isdigit() else None - searchstr = userstr.lower() - - # Find the member - member = None - - # Check method to determine whether a member matches - def check(member): - return ( - member.id == userid - or searchstr in member.display_name.lower() - or searchstr in str(member).lower() - ) - - # Get list of matching roles - members = list(filter(check, collection)) - - if len(members) == 0: - # Nope - member = None - elif len(members) == 1: - # Select our lucky winner - member = members[0] - else: - # We have multiple matching members! - if interactive: - # Interactive prompt with the list of members - member_names = [ - "{} {}".format( - member.nick if member.nick else (member if members.count(member) > 1 - else member.name), - ("<{}>".format(member)) if member.nick else "" - ) for member in members - ] - - try: - selected = await ctx.selector( - "`{}` members found matching `{}`!".format(len(members), userstr), - member_names, - timeout=60 - ) - except UserCancelled: - raise UserCancelled("User cancelled member selection.") from None - except ResponseTimedOut: - raise ResponseTimedOut("Member selection timed out.") from None - - member = members[selected] - else: - # Just select the first one - member = members[0] - - if member is None and not silent: - await ctx.error_reply("Couldn't find a member matching `{}`!".format(userstr)) - - return member - - -@Context.util -async def find_message(ctx, msgid, chlist=None, ignore=[]): - """ - Searches for the given message id in the guild channels. - - Parameters - ------- - msgid: int - The `id` of the message to search for. - chlist: Optional[List[discord.TextChannel]] - List of channels to search in. - If `None`, searches all the text channels that the `ctx.author` can read. - ignore: list - A list of channelids to explicitly ignore in the search. - - Returns - ------- - Optional[discord.Message]: - If a message is found, returns the message. - Otherwise, returns `None`. - """ - if not ctx.guild: - raise InvalidContext("Cannot use this seeker outside of a guild!") - - msgid = int(msgid) - - # Build the channel list to search - if chlist is None: - chlist = [ch for ch in ctx.guild.text_channels if ch.permissions_for(ctx.author).read_messages] - - # Remove any channels we are ignoring - chlist = [ch for ch in chlist if ch.id not in ignore] - - tasks = set() - - i = 0 - while True: - done = set((task for task in tasks if task.done())) - tasks = tasks.difference(done) - - results = [task.result() for task in done] - - result = next((result for result in results if result is not None), None) - if result: - [task.cancel() for task in tasks] - return result - - if i < len(chlist): - task = asyncio.create_task(_search_in_channel(chlist[i], msgid)) - tasks.add(task) - i += 1 - elif len(tasks) == 0: - return None - - await asyncio.sleep(0.1) - - -async def _search_in_channel(channel: discord.TextChannel, msgid: int): - if not isinstance(channel, discord.TextChannel): - return - try: - message = await channel.fetch_message(msgid) - except Exception: - return None - else: - return message diff --git a/src/pending-rewrite/wards.py b/src/pending-rewrite/wards.py deleted file mode 100644 index 8dce9a3b..00000000 --- a/src/pending-rewrite/wards.py +++ /dev/null @@ -1,40 +0,0 @@ -from cmdClient import check -from cmdClient.checks import in_guild - -from meta import client - -from data import tables - - -def is_guild_admin(member): - if member.id in client.owners: - return True - - # First check guild admin permissions - admin = member.guild_permissions.administrator - - # Then check the admin role, if it is set - if not admin: - admin_role_id = tables.guild_config.fetch_or_create(member.guild.id).admin_role - admin = admin_role_id and (admin_role_id in (r.id for r in member.roles)) - return admin - - -@check( - name="ADMIN", - msg=("You need to be a server admin to do this!"), - requires=[in_guild] -) -async def guild_admin(ctx, *args, **kwargs): - return is_guild_admin(ctx.author) - - -@check( - name="MODERATOR", - msg=("You need to be a server moderator to do this!"), - requires=[in_guild], - parents=(guild_admin,) -) -async def guild_moderator(ctx, *args, **kwargs): - modrole = ctx.guild_settings.mod_role.value - return (modrole and (modrole in ctx.author.roles))