diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index fb0f4551..08767ba1 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -6,6 +6,7 @@ import asyncio 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 @@ -94,7 +95,9 @@ class TimeSlot: ), colour=discord.Colour.orange(), timestamp=self.start_time - ).set_footer(text="About to start!") + ).set_footer( + text="About to start!\nJoin the session with {}rooms book".format(client.prefix) + ) if self.members: embed.description = "Starting .".format(timestamp) @@ -119,7 +122,7 @@ class TimeSlot: description="Finishing .".format(timestamp + 3600), colour=discord.Colour.orange(), timestamp=self.start_time - ).set_footer(text="Running") + ).set_footer(text="Join the next session using {}rooms book".format(client.prefix)) if self.members: classifications = { diff --git a/bot/modules/accountability/commands.py b/bot/modules/accountability/commands.py index 551ddc15..e532fa3a 100644 --- a/bot/modules/accountability/commands.py +++ b/bot/modules/accountability/commands.py @@ -4,9 +4,9 @@ import discord import asyncio from cmdClient.checks import in_guild -from utils.lib import multiselect_regex, parse_ranges +from utils.lib import multiselect_regex, parse_ranges, prop_tabulate from data import NOTNULL -from data.conditions import GEQ +from data.conditions import GEQ, LEQ from .module import module from .lib import utc_now @@ -16,6 +16,28 @@ 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, + ) + + @module.cmd( name="rooms", desc="Book an accountability timeslot", @@ -29,7 +51,7 @@ async def cmd_rooms(ctx): {prefix}rooms book {prefix}rooms cancel Description: - Book an accountability session timeslot. + View, book, or cancel your accountability sessions. """ lower = ctx.args.lower() splits = lower.split() @@ -51,27 +73,28 @@ async def cmd_rooms(ctx): # Show unbooking menu lines = [ - "`[{:>2}]` | - ".format( - i, - int(row['start_at'].timestamp()), - int(row['start_at'].timestamp()), - int(row['start_at'].timestamp()) + 3600 - ) + "`[{:>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 rooms you want to cancel. E.g. `1, 3, 5` or `1-3, 7-8`.", embed=discord.Embed( title="Please choose the bookings you want to cancel.", description='\n'.join(lines), colour=discord.Colour.orange() ).set_footer( text=( - "Reply with the number(s) of the rooms you want to join.\n" - "E.g. 1, 3, 5 or 1-5, 7-8." + "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 accountability 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') @@ -81,8 +104,14 @@ async def cmd_rooms(ctx): message = await ctx.client.wait_for('message', check=check, timeout=60) except asyncio.TimeoutError: try: - await out_msg.delete() - await ctx.error_reply("Session timed out. No accountability bookings were cancelled.") + await out_msg.edit( + content=None, + embed=discord.Embed( + description="Cancel menu timed out, no accountability sessions were cancelled.", + colour=discord.Colour.red() + ) + ) + await out_msg.clear_reactions() except discord.HTTPException: pass return @@ -112,6 +141,7 @@ async def cmd_rooms(ctx): ) # 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: @@ -129,13 +159,29 @@ async def cmd_rooms(ctx): break ctx.alion.addCoins(cost) - await ctx.embed_reply( - "Successfully canceled your bookings." - ) - # elif command == 'book': - else: - # Show booking menu + remaining = [row for row in joined_rows if row['slotid'] not in slotids] + if not remaining: + await ctx.embed_reply("Cancelled all your upcoming accountability 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, @@ -157,29 +203,34 @@ async def cmd_rooms(ctx): start_time + datetime.timedelta(hours=n) for n in range(1, 25) ) - times = list(time for time in times if time not in already_joined_times) + times = [time for time in times if time not in already_joined_times] lines = [ - "`[{:>2}]` | `{:>{}}` attending | - ".format( - i, - attendees.get(time, 0), attendee_pad, - int(time.timestamp()), int(time.timestamp()), int(time.timestamp()) + 3600 + "`[{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 attendence rate + # 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 rooms you want to join. E.g. `1, 3, 5` or `1-3, 7-8`.", embed=discord.Embed( title="Please choose the sessions you want to book.", description='\n'.join(lines), colour=discord.Colour.orange() ).set_footer( text=( - "Reply with the number(s) of the rooms you want to join.\n" - "E.g. 1, 3, 5 or 1-5, 7-8." + "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=70 + ) def check(msg): valid = msg.channel == ctx.ch and msg.author == ctx.author @@ -190,12 +241,19 @@ async def cmd_rooms(ctx): message = await ctx.client.wait_for('message', check=check, timeout=60) except asyncio.TimeoutError: try: - await out_msg.delete() - await ctx.error_reply("Session timed out. No accountability slots were booked.") + 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 + print(message) try: await out_msg.delete() await message.delete() @@ -260,21 +318,224 @@ async def cmd_rooms(ctx): pass await slot.update_status() ctx.alion.addCoins(-cost) - await ctx.embed_reply( - "You have booked the following accountability sessions.\n{}".format( - '\n'.join( - " - ".format( - int(time.timestamp()), int(time.timestamp()), int(time.timestamp() + 3600) - ) for time in to_book - ) - ) + + # Ack purchase + embed = discord.Embed( + title="You have booked the following session{}!".format('s' if len(to_book) > 1 else ''), + description=( + "*Please attend all your booked sessions!*\n" + "*If you can't attend, cancel with* `{}rooms 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}rooms to see your current bookings.\n" + ).format(prefix=ctx.best_prefix) ) - # else: - # # Show current booking information - # embed = discord.Embed( - # title="Accountability Room Information" - # ) - # ... + 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 joined any accountability sessions yet!\n" + "Book a session by typing **`{}rooms book`** and selecting " + "the hours you intend to study, " + "then attend by joining the accountability voice channel when the session 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] + print(date, row['start_at']) + 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}** spent in accountability rooms.".format( + total_duration // 60, + total_duration % 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', + 'start_at', + 'COUNT(*) as num' + ), + _extra="GROUP BY start_at, slotid ORDER BY start_at ASC" + ) + attendees = {row['start_at']: row['num'] 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 + + booked_list = '\n'.join( + "`{:>{}}` attendees | {}".format( + num, + attendee_pad, + time_format(start) + ) for start, num in attendees.items() + ) + booked_field = ( + "{}\n\n" + "*If you can't make your booking, please cancel using `{}rooms 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 {}rooms book".format(ctx.best_prefix) + else: + booked_field = ( + "Your schedule is empty!\n" + "Book another session using `{}rooms 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="Accountability profile 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/bot/modules/accountability/data.py b/bot/modules/accountability/data.py index ef8ebd2b..293a69b6 100644 --- a/bot/modules/accountability/data.py +++ b/bot/modules/accountability/data.py @@ -21,3 +21,14 @@ accountability_members = RowTable( 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( +# """ + +# """ +# )