From 70fa80fe295c1034ec6777d66d659653312d9893 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 12 Sep 2021 11:36:54 +0300 Subject: [PATCH] Add module. --- bot/modules/workout/admin.py | 83 +++++++++++ bot/modules/workout/data.py | 10 ++ bot/modules/workout/module.py | 4 + bot/modules/workout/tracker.py | 247 +++++++++++++++++++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 bot/modules/workout/admin.py create mode 100644 bot/modules/workout/data.py create mode 100644 bot/modules/workout/module.py create mode 100644 bot/modules/workout/tracker.py diff --git a/bot/modules/workout/admin.py b/bot/modules/workout/admin.py new file mode 100644 index 00000000..d6e1b1f6 --- /dev/null +++ b/bot/modules/workout/admin.py @@ -0,0 +1,83 @@ +from settings import GuildSettings, GuildSetting +from wards import guild_admin + +import settings + +from .data import workout_channels + + +@GuildSettings.attach_setting +class workout_length(settings.Integer, GuildSetting): + category = "Workout" + + attr_name = "min_workout_length" + _data_column = "min_workout_length" + + display_name = "min_workout_length" + desc = "Minimum length of a workout." + + _default = 20 + + long_desc = ( + "Minimun time a user must spend in a workout channel for it to count as a valid workout. " + "Value must be given in minutes." + ) + _accepts = "An integer number of minutes." + + @property + def success_response(self): + return "The minimum workout length is now `{}` minutes.".format(self.formatted) + + +@GuildSettings.attach_setting +class workout_reward(settings.Integer, GuildSetting): + category = "Workout" + + attr_name = "workout_reward" + _data_column = "workout_reward" + + display_name = "workout_reward" + desc = "Number of daily LionCoins to reward for completing a workout." + + _default = 350 + + long_desc = ( + "Number of LionCoins given when a member completes their daily workout." + ) + _accepts = "An integer number of LionCoins." + + @property + def success_response(self): + return "The workout reward is now `{}` LionCoins.".format(self.formatted) + + +@GuildSettings.attach_setting +class workout_channels_setting(settings.ChannelList, settings.ListData, settings.Setting): + category = "Workout" + + attr_name = 'workout_channels' + + _table_interface = workout_channels + _id_column = 'guildid' + _data_column = 'channelid' + _setting = settings.VoiceChannel + + write_ward = guild_admin + display_name = "workout_channels" + desc = "Channels in which members can do workouts." + + _force_unique = True + + long_desc = ( + "Sessions in these channels will be treated as workouts." + ) + + # Flat cache, no need to expire objects + _cache = {} + + @property + def success_response(self): + if self.value: + return "The workout channels have been updated:\n{}".format(self.formatted) + else: + return "The workout channels have been removed." diff --git a/bot/modules/workout/data.py b/bot/modules/workout/data.py new file mode 100644 index 00000000..8bc18297 --- /dev/null +++ b/bot/modules/workout/data.py @@ -0,0 +1,10 @@ +from data import Table, RowTable + + +workout_channels = Table('workout_channels') + +workout_sessions = RowTable( + 'workout_sessions', + ('sessionid', 'guildid', 'userid', 'start_time', 'duration', 'channelid'), + 'sessionid' +) diff --git a/bot/modules/workout/module.py b/bot/modules/workout/module.py new file mode 100644 index 00000000..c214df70 --- /dev/null +++ b/bot/modules/workout/module.py @@ -0,0 +1,4 @@ +from LionModule import LionModule + + +module = LionModule("Workout") diff --git a/bot/modules/workout/tracker.py b/bot/modules/workout/tracker.py new file mode 100644 index 00000000..ddf1c9d8 --- /dev/null +++ b/bot/modules/workout/tracker.py @@ -0,0 +1,247 @@ +import asyncio +import logging +import datetime as dt +import discord + +from core import Lion +from settings import GuildSettings +from meta import client +from data import NULL, tables + +from .module import module +from .data import workout_sessions +from . import admin + + +leave_tasks = {} + + +async def on_workout_join(member): + key = (member.guild.id, member.id) + + # Cancel a leave task if the member rejoined in time + if member.id in leave_tasks: + leave_tasks[key].cancel() + leave_tasks.pop(key) + return + + # Create a started workout entry + workout = workout_sessions.create_row( + guildid=member.guild.id, + userid=member.id, + channelid=member.voice.channel.id + ) + + # Add to current workouts + client.objects['current_workouts'][key] = workout + + # Log + client.log( + "User '{m.name}'(uid:{m.id}) started a workout in channel " + "'{m.voice.channel.name}' (cid:{m.voice.channel.id}) " + "of guild '{m.guild.name}' (gid:{m.guild.id}).".format(m=member), + context="WORKOUT_STARTED" + ) + GuildSettings(member.guild.id).event_log.log( + "{} started a workout in {}".format( + member.mention, + member.voice.channel.mention + ), title="Workout Started" + ) + + +async def on_workout_leave(member): + key = (member.guild.id, member.id) + + # Create leave task in case of temporary disconnect + task = asyncio.create_task(asyncio.sleep(3)) + leave_tasks[key] = task + + # Wait for the leave task, abort if it gets cancelled + try: + await task + if member.id in leave_tasks: + if leave_tasks[key] == task: + leave_tasks.pop(key) + else: + return + except asyncio.CancelledError: + # Task was cancelled by rejoining + if key in leave_tasks and leave_tasks[key] == task: + leave_tasks.pop(key) + return + + # Retrieve workout row and remove from current workouts + workout = client.objects['current_workouts'].pop(key) + + await workout_left(member, workout) + + +async def workout_left(member, workout): + time_diff = (dt.datetime.utcnow() - workout.start_time).total_seconds() + min_length = GuildSettings(member.guild.id).min_workout_length.value + if time_diff < 60 * min_length: + # Left workout before it was finished. Log and delete + client.log( + "User '{m.name}'(uid:{m.id}) left their workout in guild '{m.guild.name}' (gid:{m.guild.id}) " + "before it was complete! ({diff:.2f} minutes). Deleting workout.\n" + "{workout}".format( + m=member, + diff=time_diff / 60, + workout=workout + ), + context="WORKOUT_ABORTED", + post=True + ) + GuildSettings(member.guild.id).event_log.log( + "{} left their workout before it was complete! (`{:.2f}` minutes)".format( + member.mention, + time_diff / 60, + ), title="Workout Left" + ) + workout_sessions.delete_where(sessionid=workout.sessionid) + else: + # Completed the workout + client.log( + "User '{m.name}'(uid:{m.id}) completed their daily workout in guild '{m.guild.name}' (gid:{m.guild.id}) " + "({diff:.2f} minutes). Saving workout and notifying user.\n" + "{workout}".format( + m=member, + diff=time_diff / 60, + workout=workout + ), + context="WORKOUT_COMPLETED", + post=True + ) + workout.duration = time_diff + await workout_complete(member, workout) + + +async def workout_complete(member, workout): + key = (member.guild.id, member.id) + + # update and notify + user = Lion.fetch(*key) + user_data = user.data + with user_data.batch_update(): + user_data.workout_count = user_data.workout_count + 1 + user_data.last_workout_start = workout.start_time + + settings = GuildSettings(member.guild.id) + reward = settings.workout_reward.value + user.addCoins(reward) + + settings.event_log.log( + "{} completed their daily workout and was rewarded `{}` coins! (`{:.2f}` minutes)".format( + member.mention, + reward, + workout.duration / 60, + ), title="Workout Completed" + ) + + embed = discord.Embed( + description=( + "Congratulations on completing your daily workout!\n" + "You have been rewarded with `350` LionCoins. Good job!" + ), + timestamp=dt.datetime.utcnow(), + colour=discord.Color.orange() + ) + embed.set_footer( + text=member.guild.name, + icon_url=member.guild.icon_url + ) + try: + await member.send(embed=embed) + except discord.Forbidden: + client.log( + "Couldn't notify user '{m.name}'(uid:{m.id}) about their completed workout! " + "They might have me blocked.".format(m=member), + context="WORKOUT_COMPLETED", + post=True + ) + + +@client.add_after_event("voice_state_update") +async def workout_voice_tracker(client, member, before, after): + # Wait until launch tasks are complete + while not module.ready: + asyncio.sleep(0.1) + + # Check whether we are moving to/from a workout channel + settings = GuildSettings(member.guild.id) + channels = settings.workout_channels.value + from_workout = before.channel in channels + to_workout = after.channel in channels + + if to_workout ^ from_workout: + # Ensure guild row exists + tables.guild_config.fetch_or_create(member.guild.id) + + # Fetch workout user + user = Lion.fetch(member.guild.id, member.id) + + # Ignore all workout events from users who have already completed their workout today + if user.data.last_workout_start is not None: + last_date = user.localize(user.data.last_workout_start).date() + today = user.localize(dt.datetime.utcnow()).date() + if last_date == today: + return + + # TODO: Check if they have completed a workout today, if so, ignore + if to_workout and not from_workout: + await on_workout_join(member) + elif from_workout and not to_workout: + if (member.guild.id, member.id) in client.objects['current_workouts']: + await on_workout_leave(member) + else: + client.log( + "Possible missed workout!\n" + "Member '{m.name}'(uid:{m.id}) left the workout channel '{c.name}'(cid:{c.id}) " + "in guild '{m.guild.name}'(gid:{m.guild.id}), but we never saw them join!".format( + m=member, + c=before.channel + ), + context="WORKOUT_TRACKER", + level=logging.ERROR, + post=True + ) + settings.event_log.log( + "{} left the workout channel {}, but I never saw them join!".format( + member.mention, + before.channel.mention, + ), title="Possible Missed Workout!" + ) + + +@module.launch_task +async def load_workouts(client): + client.objects['current_workouts'] = {} # (guildid, userid) -> Row + # Process any incomplete workouts + workouts = workout_sessions.fetch_rows_where( + duration=NULL + ) + count = 0 + for workout in workouts: + channelids = admin.workout_channels_setting.get(workout.guildid).data + member = Lion.fetch(workout.guildid, workout.userid).member + if member: + if member.voice and (member.voice.channel.id in channelids): + client.objects['current_workouts'][(workout.guildid, workout.userid)] = workout + count += 1 + else: + asyncio.create_task(workout_left(member, workout)) + else: + client.log( + "Removing incomplete workout from " + "non-existent member (mid:{}) in guild (gid:{})".format( + workout.userid, + workout.guildid + ), + context="WORKOUT_LAUNCH", + post=True + ) + if count > 0: + client.log( + "Loaded {} in-progress workouts.".format(count), context="WORKOUT_LAUNCH", post=True + )