diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py new file mode 100644 index 00000000..4e9bbe90 --- /dev/null +++ b/bot/modules/todo/Tasklist.py @@ -0,0 +1,703 @@ +import re +import datetime +import discord +import asyncio + +from cmdClient.lib import SafeCancellation +from meta import client +from core import Lion +from settings import GuildSettings +from utils.lib import parse_ranges + +from . import data +# from .module import module + + +class Tasklist: + """ + Class representing an interactive updating tasklist. + """ + max_task_length = 100 + + active = {} # Map (userid, channelid) -> Tasklist + messages = {} # Map messageid -> Tasklist + + checkmark = "✔" + block_size = 15 + + next_emoji = "▶" + prev_emoji = "◀" + question_emoji = "❔" + cancel_emoji = "❌" + refresh_emoji = "🔄" + + paged_reaction_order = ( + prev_emoji, cancel_emoji, question_emoji, refresh_emoji, next_emoji + ) + non_paged_reaction_order = ( + question_emoji, cancel_emoji, refresh_emoji + ) + + reaction_hint = "*Press {} for info, {} to exit and {} to refresh.*".format( + question_emoji, + cancel_emoji, + refresh_emoji + ) + + _re_flags = re.DOTALL | re.IGNORECASE | re.VERBOSE + add_regex = re.compile( + r"^(?: (?:add) | \+) \s+ (.+)", + _re_flags + ) + delete_regex = re.compile( + r"^(?: d(?:el(?:ete)?)? | (?: r(?:(?:emove)|m)?) | -) \s* ([0-9, -]+)$", + _re_flags + ) + edit_regex = re.compile( + r"^e(?:dit)? \s+ (\d+ \s+ .+)", + _re_flags + ) + check_regex = re.compile( + r"^(?: c(?:heck)? | (?: done) | (?: complete))\s* ([0-9, -]+)$", + _re_flags + ) + uncheck_regex = re.compile( + r"^(?: u(?:ncheck)? | (?: undone) | (?: uncomplete)) \s* ([0-9, -]+)$", + _re_flags + ) + toggle_regex = re.compile( + r"^([0-9, -]+)$", + _re_flags + ) + cancel_regex = re.compile( + r"^(cancel)|(exit)|(quit)$", + _re_flags + ) + + interactive_help = """ + Send the following to modify your tasks while the todolist is visible. \ + `` may be given as comma separated numbers and ranges. + `` Toggle the status (checked/unchecked) 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 complete) the specified tasks. + `u/uncheck ` Uncheck (mark incomplete) the specified tasks. + `cancel` Cancel the interactive tasklist mode. + + **Examples** + `add Read chapter 1` Adds a new task `Read chapter 1`. + `e 1 Notes chapter 1` Edit task `1` to `Notes chapter 1`. + `d 1, 5-7, 9` Deletes tasks `1, 5, 6, 7, 9`. + `1, 2-5, 9` Toggle the completion status of tasks `1, 2, 3, 4, 5, 9`. + + You may also edit your tasklist at any time with `{prefix}todo` (see `{prefix}help todo`). + Note that tasks expire after 24 hours. + """.format(prefix=client.prefix) + + def __init__(self, member, channel, activate=True): + self.member = member # Discord Member owning the tasklist + self.channel = channel # Discord Channel for display and input + + self.message = None # Discord Message currently displaying the tasklist + + self.tasklist = [] # Displayed list of Row(tasklist) + + self.pages = [] # Pages to display + self.current_page = None # Current displayed page. None means set automatically + self.show_help = False # Whether to show a help section in the pages + self.has_paging = None # Whether we have added paging reactions + + self._refreshed_at = None # Timestamp of the last tasklist refresh + self._deactivation_task = None # Task for scheduled tasklist deactivation + self.interaction_lock = asyncio.Lock() # Lock to ensure interactions execute sequentially + self._deactivated = False # Flag for checking deactivation + + if activate: + # Populate the tasklist + self._refresh() + + # Add the tasklist to active tasklists + self.active[(member.id, channel.id)] = self + + @classmethod + def fetch_or_create(cls, member, channel): + tasklist = cls.active.get((member.id, channel.id), None) + return tasklist if tasklist is not None else cls(member, channel) + + def _refresh(self): + """ + Update the in-memory tasklist from data and regenerate the pages + """ + self.tasklist = data.tasklist.fetch_rows_where( + guildid=self.member.guild.id, + userid=self.member.id, + _extra=("AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h' " + "ORDER BY created_at ASC, taskid ASC") + ) + self._refreshed_at = datetime.datetime.utcnow() + + def _format_tasklist(self): + """ + Generates a sequence of pages from the tasklist + """ + # Format tasks + task_strings = [ + "{num:>{numlen}}. [{mark}] {content}".format( + num=i, + numlen=((self.block_size * (i // self.block_size + 1) - 1) // 10) + 1, + mark=self.checkmark if task.complete else ' ', + content=task.content + ) + for i, task in enumerate(self.tasklist) + ] + + # Split up tasklist into formatted blocks + task_pages = [task_strings[i:i+self.block_size] for i in range(0, len(task_strings), self.block_size)] + task_blocks = [ + "```md\n{}```".format('\n'.join(page)) for page in task_pages + ] + + # Formatting strings and data + page_count = len(task_blocks) or 1 + task_count = len(task_strings) + complete_count = len([task for task in self.tasklist if task.complete]) + + if task_count > 0: + title = "TODO list ({}/{} complete)".format( + complete_count, + task_count, + # ((complete_count * 100) // task_count), + ) + if complete_count == task_count: + hint = "You have completed all your tasks! Well done!" + else: + hint = "" + else: + title = "TODO list" + hint = "Type `add ` to start adding tasks! E.g. `add Revise Maths Paper 1`." + task_blocks = [""] # Empty page so we can post + + # Create formtted page embeds, adding help if required + pages = [] + for i, block in enumerate(task_blocks): + embed = discord.Embed( + title=title, + description="{}\n{}\n{}".format(hint, block, self.reaction_hint), + timestamp=self._refreshed_at + ).set_author(name=self.member.display_name, icon_url=self.member.avatar_url) + + if page_count > 1: + embed.set_footer(text="Page {}/{}".format(i+1, page_count)) + + if self.show_help: + embed.add_field( + name="Cheatsheet", + value=self.interactive_help + ) + pages.append(embed) + + self.pages = pages + return pages + + def _adjust_current_page(self): + """ + Update the current page number to point to a valid page. + """ + # Calculate or adjust the current page number + if self.current_page is None: + # First page with incomplete task, or the first page + first_incomplete = next((i for i, task in enumerate(self.tasklist) if not task.complete), 0) + self.current_page = first_incomplete // self.block_size + elif self.current_page >= len(self.pages): + self.current_page = len(self.pages) - 1 + elif self.current_page < 0: + self.current_page %= len(self.pages) + + async def _post(self): + """ + Post the interactive widget, add reactions, and update the message cache + """ + pages = self.pages + + # Post the page + message = await self.channel.send(embed=pages[self.current_page]) + + # 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, repost=None): + """ + Update the displayed tasklist. + If required, delete and repost the tasklist. + """ + if self._deactivated: + return + + # Update data and make page list + self._refresh() + self._format_tasklist() + self._adjust_current_page() + + if self.message and not repost: + # Read the channel history, see if we need to repost + height = 0 + async for message in self.channel.history(limit=20): + if message.id == self.message.id: + break + + height += len(message.content.splitlines()) + if message.embeds or message.attachments or height > 20: + repost = True + break + if message.id < self.message.id: + # Our message was deleted? + repost = True + break + else: + repost = True + + if not repost: + try: + await self.message.edit(embed=self.pages[self.current_page]) + # Add or remove paging reactions as required + should_have_paging = len(self.pages) > 1 + + if self.has_paging != should_have_paging: + try: + await self.message.clear_reactions() + except discord.HTTPException: + pass + if should_have_paging: + reaction_order = self.paged_reaction_order + else: + reaction_order = self.non_paged_reaction_order + + for emoji in reaction_order: + await self.message.add_reaction(emoji) + self.has_paging = should_have_paging + except discord.NotFound: + self.messages.pop(self.message.id, None) + self.message = None + repost = True + + if not self.message or repost: + if self.message: + # Delete previous message + try: + await self.message.delete() + except discord.HTTPException: + pass + await self._post() + + asyncio.create_task(self._schedule_deactivation()) + + async def deactivate(self, delete=False): + """ + Delete from active tasklists and message cache, and remove the reactions. + If `delete` is given, deletes any output message + """ + self._deactivated = True + if self._deactivation_task and not self._deactivation_task.cancelled(): + self._deactivation_task.cancel() + + self.active.pop((self.member.id, self.channel.id), None) + if self.message: + self.messages.pop(self.message.id, None) + try: + if delete: + await self.message.delete() + else: + await self.message.clear_reactions() + except discord.HTTPException: + pass + + async def _reward_complete(self, *checked_rows): + # Fetch guild task reward settings + guild_settings = GuildSettings(self.member.guild.id) + task_reward = guild_settings.task_reward.value + task_reward_limit = guild_settings.task_reward_limit.value + + # Select only tasks that haven't been rewarded before + unrewarded = [task for task in checked_rows if not task['rewarded']] + + if unrewarded: + # Select tasks to reward up to the limit of rewards + recent_rewards = data.tasklist_rewards.queries.count_recent_for(self.member.guild.id, self.member.id) + max_to_reward = max((task_reward_limit - recent_rewards, 0)) + reward_tasks = unrewarded[:max_to_reward] + + rewarding_count = len(reward_tasks) + # reached_max = (recent_rewards + rewarding_count) >= task_reward_limit + reward_coins = task_reward * len(reward_tasks) + + if reward_coins: + # Rewarding process, now that we know what we need to reward + # Add coins + user = Lion.fetch(self.member.guild.id, self.member.id) + user.addCoins(reward_coins) + + # Mark tasks as rewarded + taskids = [task['taskid'] for task in reward_tasks] + data.tasklist.update_where( + {'rewarded': True}, + taskid=taskids, + ) + + # Track reward + data.tasklist_rewards.insert( + guildid=self.member.guild.id, + userid=self.member.id, + reward_count=rewarding_count + ) + + # Log reward + client.log( + "Giving '{}' LionCoins to '{}' (uid:{}) for completing TODO tasks.".format( + reward_coins, + self.member, + self.member.id + ) + ) + + # TODO: Message in channel? Might be too spammy? + pass + + def _add_tasks(self, *tasks): + """ + Add provided tasks to the task list + """ + insert = [ + (self.member.guild.id, self.member.id, task) + for task in tasks + ] + return data.tasklist.insert_many( + *insert, + insert_keys=('guildid', 'userid', 'content') + ) + + def _delete_tasks(self, *indexes): + """ + Delete tasks from the task list + """ + taskids = [self.tasklist[i].taskid for i in indexes] + return data.tasklist.delete_where( + taskid=taskids + ) + + def _edit_task(self, index, new_content): + """ + Update the provided task with the new content + """ + taskid = self.tasklist[index].taskid + return data.tasklist.update_where( + { + 'content': new_content, + 'last_updated_at': datetime.datetime.utcnow() + }, + taskid=taskid, + ) + + def _check_tasks(self, *indexes): + """ + Mark provided tasks as complete + """ + taskids = [self.tasklist[i].taskid for i in indexes] + return data.tasklist.update_where( + { + 'complete': True, + 'last_updated_at': datetime.datetime.utcnow() + }, + taskid=taskids, + complete=False, + ) + + def _uncheck_tasks(self, *indexes): + """ + Mark provided tasks as incomplete + """ + taskids = [self.tasklist[i].taskid for i in indexes] + return data.tasklist.update_where( + { + 'complete': False, + 'last_updated_at': datetime.datetime.utcnow() + }, + taskid=taskids, + complete=True, + ) + + def _index_range_parser(self, userstr): + """ + Parse user provided task indicies. + """ + try: + indexes = parse_ranges(userstr) + except SafeCancellation: + raise SafeCancellation( + "Couldn't parse the provided task numbers! " + "Please list the task numbers or ranges separated by a comma, e.g. `1, 3, 5-7, 11`." + ) from None + + return [index for index in indexes if index < len(self.tasklist)] + + async def parse_add(self, userstr): + """ + Process arguments to an `add` request + """ + tasks = [line for line in userstr.splitlines() if line] + if not tasks: + # TODO: Maybe have interactive input here + return + + # Fetch accurate count of current tasks + count = data.tasklist.select_one_where( + select_columns=("COUNT(*)",), + guildid=self.member.guild.id, + userid=self.member.id + )[0] + + # Fetch maximum allowed count + max_task_count = GuildSettings(self.member.guild.id).task_limit.value + + # Check if we are exceeding the count + if count + len(tasks) > max_task_count: + raise SafeCancellation("Too many tasks! You can have a maximum of `{}` todo items!".format(max_task_count)) + + # Check if any task is too long + if any(len(task) > self.max_task_length for task in tasks): + raise SafeCancellation("Please keep your tasks under `{}` characters long.".format(self.max_task_length)) + + # Finally, add the tasks + self._add_tasks(*tasks) + + # Set the current page to the last one + self.current_page = -1 + + async def parse_delete(self, userstr): + """ + Process arguments to a `delete` request + """ + # Parse provided ranges + indexes = self._index_range_parser(userstr) + + if indexes: + self._delete_tasks(*indexes) + + async def parse_toggle(self, userstr): + """ + Process arguments to a `toggle` request + """ + # Parse provided ranges + indexes = self._index_range_parser(userstr) + + to_check = [index for index in indexes if not self.tasklist[index].complete] + to_uncheck = [index for index in indexes if self.tasklist[index].complete] + + if to_uncheck: + self._uncheck_tasks(*to_uncheck) + if to_check: + checked = self._check_tasks(*to_check) + await self._reward_complete(*checked) + + async def parse_check(self, userstr): + """ + Process arguments to a `check` request + """ + # Parse provided ranges + indexes = self._index_range_parser(userstr) + + if indexes: + checked = self._check_tasks(*indexes) + await self._reward_complete(*checked) + + async def parse_uncheck(self, userstr): + """ + Process arguments to an `uncheck` request + """ + # Parse provided ranges + indexes = self._index_range_parser(userstr) + + if indexes: + self._uncheck_tasks(*indexes) + + async def parse_edit(self, userstr): + """ + Process arguments to an `edit` request + """ + splits = userstr.split(maxsplit=1) + if len(splits) < 2 or not splits[0].isdigit(): + raise SafeCancellation("Please provide the task number and the new content, " + "e.g. `edit 1 Biology homework`.") + + index = int(splits[0]) + new_content = splits[1] + + if index >= len(self.tasklist): + raise SafeCancellation( + "You do not have a task number `{}` to edit!".format(index) + ) + + if len(new_content) > self.max_task_length: + raise SafeCancellation("Please keep your tasks under `{}` characters long.".format(self.max_task_length)) + + self._edit_task(index, new_content) + + self.current_page = index // self.block_size + + async def handle_reaction(self, reaction, user, added): + """ + Reaction handler for reactions on our message. + """ + str_emoji = str(reaction.emoji) + if added and str_emoji in self.paged_reaction_order: + # Attempt to remove reaction + try: + await self.message.remove_reaction(reaction.emoji, user) + except discord.HTTPException: + pass + + old_message_id = self.message.id + async with self.interaction_lock: + # Return if the message changed while we were waiting + if self.message.id != old_message_id: + return + if str_emoji == self.next_emoji and user.id == self.member.id: + self.current_page += 1 + self.current_page %= len(self.pages) + if self.show_help: + self.show_help = False + self._format_tasklist() + await self.message.edit(embed=self.pages[self.current_page]) + elif str_emoji == self.prev_emoji and user.id == self.member.id: + self.current_page -= 1 + self.current_page %= len(self.pages) + if self.show_help: + self.show_help = False + self._format_tasklist() + await self.message.edit(embed=self.pages[self.current_page]) + elif str_emoji == self.cancel_emoji and user.id == self.member.id: + await self.deactivate(delete=True) + elif str_emoji == self.question_emoji and user.id == self.member.id: + self.show_help = not self.show_help + self._format_tasklist() + await self.message.edit(embed=self.pages[self.current_page]) + elif str_emoji == self.refresh_emoji and user.id == self.member.id: + await self.update() + + async def handle_message(self, message, content=None): + """ + Message handler for messages from out member, in the correct channel. + """ + content = content or message.content + + funcmap = { + self.add_regex: self.parse_add, + self.delete_regex: self.parse_delete, + self.check_regex: self.parse_check, + self.uncheck_regex: self.parse_uncheck, + self.toggle_regex: self.parse_toggle, + self.edit_regex: self.parse_edit, + self.cancel_regex: self.deactivate, + } + async with self.interaction_lock: + for reg, func in funcmap.items(): + matches = re.search(reg, content) + if matches: + try: + await func(matches.group(1)) + await self.update() + except SafeCancellation as e: + embed = discord.Embed( + description=e.msg, + colour=discord.Colour.red() + ) + await message.reply(embed=embed) + else: + try: + await message.delete() + except discord.HTTPException: + pass + break + + async def _schedule_deactivation(self): + """ + Automatically deactivate the tasklist after some time has passed, and many messages have been sent. + """ + delay = 5 * 10 + + # Remove previous scheduled task + if self._deactivation_task and not self._deactivation_task.cancelled(): + self._deactivation_task.cancel() + + # Schedule a new task + try: + self._deactivation_task = asyncio.create_task(asyncio.sleep(delay)) + await self._deactivation_task + except asyncio.CancelledError: + return + + # If we don't have a message, nothing to do + if not self.message: + return + + # If we were updated in that time, go back to sleep + if datetime.datetime.utcnow().timestamp() - self._refreshed_at.timestamp() < delay: + asyncio.create_task(self._schedule_deactivation) + return + + # Check if lots of content has been sent since + height = 0 + async for message in self.channel.history(limit=20): + if message.id == self.message.id: + break + + height += len(message.content.splitlines()) + if message.embeds or message.attachments: + height += 10 + + if height >= 100: + break + + if message.id < self.message.id: + # Our message was deleted? + return + else: + height = 100 + + if height >= 100: + await self.deactivate() + else: + asyncio.create_task(self._schedule_deactivation()) + + +@client.add_after_event("message") +async def tasklist_message_handler(client, message): + key = (message.author.id, message.channel.id) + if key in Tasklist.active: + await Tasklist.active[key].handle_message(message) + + +@client.add_after_event("reaction_add") +async def tasklist_reaction_add_handler(client, reaction, user): + if user != client.user and reaction.message.id in Tasklist.messages: + await Tasklist.messages[reaction.message.id].handle_reaction(reaction, user, True) + + +# @module.launch_task +# Commented because we don't actually need to expire these +async def tasklist_expiry_watchdog(client): + removed = data.tasklist.queries.expire_old_tasks() + if removed: + client.log( + "Remove {} stale todo tasks.".format(len(removed)), + context="TASKLIST_EXPIRY", + post=True + ) diff --git a/bot/modules/todo/admin.py b/bot/modules/todo/admin.py new file mode 100644 index 00000000..5e64c41d --- /dev/null +++ b/bot/modules/todo/admin.py @@ -0,0 +1,111 @@ +from settings import GuildSettings, GuildSetting +import settings + +from wards import guild_admin + +from .data import tasklist_channels + + +@GuildSettings.attach_setting +class task_limit(settings.Integer, GuildSetting): + category = "TODO List" + + attr_name = "task_limit" + _data_column = "max_tasks" + + display_name = "task_limit" + desc = "Maximum number of tasks each user may have." + + _default = 30 + + long_desc = ( + "Maximum number of tasks each user may have in the todo system." + ) + _accepts = "An integer number of tasks." + + @property + def success_response(self): + return "The task limit is now `{}`.".format(self.formatted) + + +@GuildSettings.attach_setting +class task_reward(settings.Integer, GuildSetting): + category = "TODO List" + + attr_name = "task_reward" + _data_column = "task_reward" + + display_name = "task_reward" + desc = "Number of LionCoins given for each completed TODO task." + + _default = 250 + + long_desc = ( + "LionCoin reward given for completing each task on the TODO list." + ) + _accepts = "An integer number of coins." + + @property + def success_response(self): + return "Task completion will now reward `{}` LionCoins.".format(self.formatted) + + +@GuildSettings.attach_setting +class task_reward_limit(settings.Integer, GuildSetting): + category = "TODO List" + + attr_name = "task_reward_limit" + _data_column = "task_reward_limit" + + display_name = "task_reward_limit" + desc = "Maximum number of task rewards given in each 24h period." + + _default = 10 + + long_desc = ( + "Maximum number of times in each 24h period that TODO task completion can reward LionCoins." + ) + _accepts = "An integer number of times." + + @property + def success_response(self): + return "LionCoins will only be reward `{}` timers per 24h".format(self.formatted) + + +@GuildSettings.attach_setting +class tasklist_channels_setting(settings.ChannelList, settings.ListData, settings.Setting): + category = "TODO List" + + attr_name = 'tasklist_channels' + + _table_interface = tasklist_channels + _id_column = 'guildid' + _data_column = 'channelid' + _setting = settings.TextChannel + + write_ward = guild_admin + display_name = "todo_channels" + desc = "Channels where members may use the todo list." + + _force_unique = True + + long_desc = ( + "Members will only be allowed to use the `todo` command in these channels." + ) + + # Flat cache, no need to expire objects + _cache = {} + + @property + def success_response(self): + if self.value: + return "The todo channels have been updated:\n{}".format(self.formatted) + else: + return "The `todo` command may now be used anywhere." + + @property + def formatted(self): + if not self.data: + return "All channels!" + else: + return super().formatted diff --git a/bot/modules/todo/commands.py b/bot/modules/todo/commands.py new file mode 100644 index 00000000..2b21dc4b --- /dev/null +++ b/bot/modules/todo/commands.py @@ -0,0 +1,115 @@ +import asyncio +import discord + +from cmdClient.checks import in_guild + +from .module import module +from .Tasklist import Tasklist + + +@module.cmd( + name="todo", + desc="Display and edit your personal TODO list.", + group="Productivity", + flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==') +) +@in_guild() +async def cmd_todo(ctx, flags): + """ + Usage``: + {prefix}todo + {prefix}todo + {prefix}todo add + {prefix}todo delete + {prefix}todo check + {prefix}todo uncheck + {prefix}todo edit + Description: + Open your personal interactive TODO list with `{prefix}todo`, \ + and start adding tasks by sending `add your_task_here`. \ + Press ❔ to see more ways to use the interactive list. + You can also use the commands above to modify your TODOs (see the examples below). + + You may add several tasks at once by writing them on different lines \ + (type Shift-Enter to make a new line on the desktop client). + Examples:: + {prefix}todo: Open your TODO list. + {prefix}todo My New task: Add `My New task`. + {prefix}todo delete 1, 3-5: Delete tasks `1, 3, 4, 5`. + {prefix}todo check 1, 2: Mark tasks `1` and `2` as done. + {prefix}todo edit 1 My new task: Edit task `1`. + """ + tasklist_channels = ctx.guild_settings.tasklist_channels.value + if tasklist_channels and ctx.ch not in tasklist_channels: + visible = [channel for channel in tasklist_channels if channel.permissions_for(ctx.author).read_messages] + if not visible: + prompt = "The `todo` command may not be used here!" + elif len(visible) == 1: + prompt = ( + "The `todo` command may not be used here! " + "Please go to {}." + ).format(visible[0].mention) + else: + prompt = ( + "The `todo` command may not be used here! " + "Please go to one of the following.\n{}" + ).format(' '.join(vis.mention for vis in visible)) + out_msg = await ctx.msg.reply( + embed=discord.Embed( + description=prompt, + colour=discord.Colour.red() + ) + ) + await asyncio.sleep(60) + try: + await out_msg.delete() + await ctx.msg.delete() + except discord.HTTPException: + pass + return + + # TODO: Custom module, with pre-command hooks + tasklist = Tasklist.fetch_or_create(ctx.author, ctx.ch) + + keys = { + 'add': (('add', ), tasklist.parse_add), + 'check': (('check', 'done', 'complete'), tasklist.parse_check), + 'uncheck': (('uncheck', 'uncomplete'), tasklist.parse_uncheck), + 'edit': (('edit',), tasklist.parse_edit), + 'delete': (('delete',), tasklist.parse_delete) + } + + # Handle subcommands + cmd = None + args = ctx.args + splits = args.split(maxsplit=1) + if len(splits) > 1: + maybe_cmd = splits[0].lower() + for key, (aliases, _) in keys.items(): + if maybe_cmd in aliases: + cmd = key + break + + # Default to adding if no command given + if cmd: + args = splits[1].strip() + elif args: + cmd = 'add' + + async with tasklist.interaction_lock: + # Run required parsers + for key, (_, func) in keys.items(): + if flags[key] or cmd == key: + await func((flags[key] or args).strip()) + + if not (any(flags.values()) or args): + # Force a repost if no flags were provided + await tasklist.update(repost=True) + else: + # Delete if the tasklist already had a message + if tasklist.message: + try: + await ctx.msg.delete() + except discord.HTTPException: + pass + await tasklist.update() diff --git a/bot/modules/todo/data.py b/bot/modules/todo/data.py new file mode 100644 index 00000000..80cb87cf --- /dev/null +++ b/bot/modules/todo/data.py @@ -0,0 +1,37 @@ +from data import RowTable, Table + +tasklist = RowTable( + 'tasklist', + ('taskid', 'guildid', 'userid', 'content', 'complete', 'rewarded', 'created_at', 'last_updated_at'), + 'taskid' +) + + +@tasklist.save_query +def expire_old_tasks(): + with tasklist.conn: + with tasklist.conn.cursor() as curs: + curs.execute( + "DELETE FROM tasklist WHERE " + "last_updated_at < timezone('utc', NOW()) - INTERVAL '7d' " + "RETURNING *" + ) + return curs.fetchall() + + +tasklist_channels = Table('tasklist_channels') + +tasklist_rewards = Table('tasklist_reward_history') + + +@tasklist_rewards.save_query +def count_recent_for(guildid, userid, interval='24h'): + with tasklist_rewards.conn: + with tasklist_rewards.conn.cursor() as curs: + curs.execute( + "SELECT SUM(reward_count) FROM tasklist_reward_history " + "WHERE " + "guildid = {} AND userid = {}" + "AND reward_time > timezone('utc', NOW()) - INTERVAL '{}'".format(guildid, userid, interval) + ) + return curs.fetchone()[0] or 0 diff --git a/bot/modules/todo/module.py b/bot/modules/todo/module.py new file mode 100644 index 00000000..df49e633 --- /dev/null +++ b/bot/modules/todo/module.py @@ -0,0 +1,3 @@ +from LionModule import LionModule + +module = LionModule("Todo")