diff --git a/bot/data/__init__.py b/bot/data/__init__.py index 2fd48650..affb1609 100644 --- a/bot/data/__init__.py +++ b/bot/data/__init__.py @@ -1,4 +1,4 @@ -from .conditions import Condition, condition +from .conditions import Condition, condition, NULL from .database import Database from .models import RowModel, RowTable, WeakCache from .table import Table @@ -6,4 +6,4 @@ from .base import Expression, RawExpr from .columns import ColumnExpr, Column, Integer, String from .registry import Registry, AttachableClass, Attachable from .adapted import RegisterEnum -from .queries import ORDER, NULLS +from .queries import ORDER, NULLS, JOINTYPE diff --git a/bot/data/conditions.py b/bot/data/conditions.py index d4fe1b9d..f40dff62 100644 --- a/bot/data/conditions.py +++ b/bot/data/conditions.py @@ -12,6 +12,8 @@ A Condition is a "logical" database expression, intended for use in Where statem Conditions support bitwise logical operators ~, &, |, each producing another Condition. """ +NULL = None + class Joiner(Enum): EQUALS = ('=', '!=') diff --git a/bot/data/models.py b/bot/data/models.py index 1bbc7f56..b05edf59 100644 --- a/bot/data/models.py +++ b/bot/data/models.py @@ -173,6 +173,10 @@ class RowModel: return obj + @classmethod + def as_tuple(cls): + return (cls.table.identifier, ()) + def __init__(self, data): self.data = data diff --git a/bot/meta/LionCog.py b/bot/meta/LionCog.py index 6c7ff23c..39ca43aa 100644 --- a/bot/meta/LionCog.py +++ b/bot/meta/LionCog.py @@ -52,6 +52,7 @@ class LionCog(Cog): for command in placeholder_group.commands: placeholder_group.remove_command(command.name) + target_group.remove_command(command.name) acmd = command.app_command._copy_with(parent=target_group.app_command, binding=self) command.app_command = acmd target_group.add_command(command) diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index aa7932f7..e52755f2 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -2,9 +2,12 @@ this_package = 'modules' active = [ '.sysadmin', - '.test', - '.reminders', + '.config', '.economy', + '.reminders', + '.shop', + '.tasklist', + '.test', ] diff --git a/bot/modules/config/__init__.py b/bot/modules/config/__init__.py new file mode 100644 index 00000000..ad3920eb --- /dev/null +++ b/bot/modules/config/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +logger = logging.getLogger(__name__) +babel = LocalBabel('config') + + +async def setup(bot): + from .cog import ConfigCog + await bot.add_cog(ConfigCog(bot)) diff --git a/bot/modules/config/cog.py b/bot/modules/config/cog.py new file mode 100644 index 00000000..05f7ca12 --- /dev/null +++ b/bot/modules/config/cog.py @@ -0,0 +1,30 @@ +import discord +from discord import app_commands as appcmds +from discord.ext import commands as cmds + +from meta import LionBot, LionContext, LionCog + +from . import babel + +_p = babel._p + + +class ConfigCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + + async def cog_load(self): + ... + + async def cog_unload(self): + ... + + @cmds.hybrid_group( + name=_p('group:configure', "configure"), + ) + @appcmds.guild_only + async def configure_group(self, ctx: LionContext): + """ + Bare command group, has no function. + """ + return diff --git a/bot/modules/economy/cog.py b/bot/modules/economy/cog.py index 6ce843ce..560b9e39 100644 --- a/bot/modules/economy/cog.py +++ b/bot/modules/economy/cog.py @@ -5,7 +5,8 @@ import discord from discord.ext import commands as cmds from discord import app_commands as appcmds -from data import Registry, RowModel, RegisterEnum, ORDER +from psycopg import sql +from data import Registry, RowModel, RegisterEnum, ORDER, JOINTYPE, RawExpr from data.columns import Integer, Bool, String, Column, Timestamp from meta import LionCog, LionBot, LionContext @@ -153,12 +154,14 @@ class EconomyData(Registry, name='economy'): guildid: int, actorid: int, userid: int, itemid: int, amount: int ): - row = await EconomyData.Transaction.execute_transaction( - TransactionType.PURCHASE, - guildid=guildid, actorid=actorid, from_account=userid, to_account=None, - amount=amount - ) - return await cls.create(transactionid=row.transactionid, itemid=itemid) + conn = await cls._connector.get_connection() + async with conn.transaction(): + row = await EconomyData.Transaction.execute_transaction( + TransactionType.PURCHASE, + guildid=guildid, actorid=actorid, from_account=userid, to_account=None, + amount=amount + ) + return await cls.create(transactionid=row.transactionid, itemid=itemid) class TaskTransaction(RowModel): """ @@ -174,6 +177,38 @@ class EconomyData(Registry, name='economy'): transactionid = Integer(primary=True) count = Integer() + @classmethod + async def count_recent_for(cls, userid, guildid, interval='24h'): + """ + Retrieve the number of tasks rewarded in the last `interval`. + """ + T = EconomyData.Transaction + query = cls.table.select_where().with_no_adapter() + query.join(T, using=(T.transactionid.name, ), join_type=JOINTYPE.LEFT) + query.select(recent=sql.SQL("SUM({})").format(cls.count.expr)) + query.where( + T.to_account == userid, + T.guildid == guildid, + T.created_at > RawExpr(sql.SQL("timezone('utc', NOW()) - INTERVAL {}").format(interval), ()), + ) + result = await query + return result[0]['recent'] or 0 + + @classmethod + async def reward_completed(cls, userid, guildid, count, amount): + """ + Reward the specified member `amount` coins for completing `count` tasks. + """ + # TODO: Bonus logic, perhaps apply_bonus(amount), or put this method in the economy cog? + conn = await cls._connector.get_connection() + async with conn.transaction(): + row = await EconomyData.Transaction.execute_transaction( + TransactionType.TASKS, + guildid=guildid, actorid=userid, from_account=None, to_account=userid, + amount=amount + ) + return await cls.create(transactionid=row.transactionid, count=count) + class SessionTransaction(RowModel): """ Schema diff --git a/bot/modules/pending-rewrite/todo/Tasklist.py b/bot/modules/pending-rewrite/todo/Tasklist.py deleted file mode 100644 index 94d6cfdf..00000000 --- a/bot/modules/pending-rewrite/todo/Tasklist.py +++ /dev/null @@ -1,710 +0,0 @@ -import re -import datetime -import discord -import asyncio - -from cmdClient.lib import SafeCancellation -from meta import client, conf -from core import Lion -from data import NULL, NOTNULL -from settings import GuildSettings -from utils.lib import parse_ranges, utc_now - -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 = conf.emojis.forward - prev_emoji = conf.emojis.backward - question_emoji = conf.emojis.question - cancel_emoji = conf.emojis.cancel - refresh_emoji = conf.emojis.refresh - - 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, ctx, flags, 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( - userid=self.member.id, - deleted_at=NULL, - _extra="ORDER BY created_at ASC, taskid ASC" - ) - self._refreshed_at = datetime.datetime.utcnow() - - async 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.completed_at 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.completed_at]) - - 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 formatted 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.completed_at), 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): - """ - Update the current message with the current page. - """ - await self.message.edit(embed=self.pages[self.current_page]) - - 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() - await 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: - # TODO: Refactor into update method - await self._update() - # 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.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, bonus=True) - - # 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( - 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.id, task) - for task in tasks - ] - return data.tasklist.insert_many( - *insert, - insert_keys=('userid', 'content') - ) - - def _delete_tasks(self, *indexes): - """ - Delete tasks from the task list - """ - taskids = [self.tasklist[i].taskid for i in indexes] - - now = utc_now() - return data.tasklist.update_where( - { - 'deleted_at': now, - 'last_updated_at': now - }, - taskid=taskids, - ) - - def _edit_task(self, index, new_content): - """ - Update the provided task with the new content - """ - taskid = self.tasklist[index].taskid - - now = utc_now() - return data.tasklist.update_where( - { - 'content': new_content, - 'last_updated_at': now - }, - taskid=taskid, - ) - - def _check_tasks(self, *indexes): - """ - Mark provided tasks as complete - """ - taskids = [self.tasklist[i].taskid for i in indexes] - - now = utc_now() - return data.tasklist.update_where( - { - 'completed_at': now, - 'last_updated_at': now - }, - taskid=taskids, - completed_at=NULL, - ) - - def _uncheck_tasks(self, *indexes): - """ - Mark provided tasks as incomplete - """ - taskids = [self.tasklist[i].taskid for i in indexes] - - now = utc_now() - return data.tasklist.update_where( - { - 'completed_at': None, - 'last_updated_at': now - }, - taskid=taskids, - completed_at=NOTNULL, - ) - - 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.strip() for line in userstr.splitlines()) - tasks = [task for task in tasks if task] - 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(*)",), - userid=self.member.id, - deleted_at=NULL - )[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].completed_at] - to_uncheck = [index for index in indexes if self.tasklist[index].completed_at] - - 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 = 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 - await self._format_tasklist() - await self._update() - 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 - await self._format_tasklist() - await self._update() - 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 - await self._format_tasklist() - await self._update() - 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) diff --git a/bot/modules/pending-rewrite/todo/__init__.py b/bot/modules/pending-rewrite/todo/__init__.py deleted file mode 100644 index df71f3a2..00000000 --- a/bot/modules/pending-rewrite/todo/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .module import module - -from . import Tasklist -from . import admin -from . import data -from . import commands diff --git a/bot/modules/pending-rewrite/todo/admin.py b/bot/modules/pending-rewrite/todo/admin.py deleted file mode 100644 index 6a672ccd..00000000 --- a/bot/modules/pending-rewrite/todo/admin.py +++ /dev/null @@ -1,111 +0,0 @@ -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 = 99 - - 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 = 50 - - 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/pending-rewrite/todo/commands.py b/bot/modules/pending-rewrite/todo/commands.py deleted file mode 100644 index 78ceba5c..00000000 --- a/bot/modules/pending-rewrite/todo/commands.py +++ /dev/null @@ -1,115 +0,0 @@ -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 To-Do list.", - group="Productivity", - flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==', 'text') -) -@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, flags, 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/pending-rewrite/todo/data.py b/bot/modules/pending-rewrite/todo/data.py deleted file mode 100644 index be2d8c65..00000000 --- a/bot/modules/pending-rewrite/todo/data.py +++ /dev/null @@ -1,25 +0,0 @@ -from data import RowTable, Table - -tasklist = RowTable( - 'tasklist', - ('taskid', 'userid', 'content', 'rewarded', 'created_at', 'completed_at', 'deleted_at', 'last_updated_at'), - 'taskid' -) - - -tasklist_channels = Table('tasklist_channels') - -tasklist_rewards = Table('tasklist_reward_history') - - -@tasklist_rewards.save_query -def count_recent_for(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 " - "userid = {}" - "AND reward_time > timezone('utc', NOW()) - INTERVAL '{}'".format(userid, interval) - ) - return curs.fetchone()[0] or 0 diff --git a/bot/modules/pending-rewrite/todo/module.py b/bot/modules/pending-rewrite/todo/module.py deleted file mode 100644 index df49e633..00000000 --- a/bot/modules/pending-rewrite/todo/module.py +++ /dev/null @@ -1,3 +0,0 @@ -from LionModule import LionModule - -module = LionModule("Todo") diff --git a/bot/modules/tasklist/__init__.py b/bot/modules/tasklist/__init__.py new file mode 100644 index 00000000..b89ba23a --- /dev/null +++ b/bot/modules/tasklist/__init__.py @@ -0,0 +1,11 @@ +import logging +from babel.translator import LocalBabel + + +babel = LocalBabel('tasklist') +logger = logging.getLogger(__name__) + + +async def setup(bot): + from .cog import TasklistCog + await bot.add_cog(TasklistCog(bot)) diff --git a/bot/modules/tasklist/cog.py b/bot/modules/tasklist/cog.py new file mode 100644 index 00000000..407feeeb --- /dev/null +++ b/bot/modules/tasklist/cog.py @@ -0,0 +1,762 @@ +from typing import Optional +import datetime as dt + +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds +from discord.app_commands.transformers import AppCommandOptionType as cmdopt + +from meta import LionBot, LionCog, LionContext +from meta.errors import UserInputError +from utils.lib import utc_now, error_embed +from utils.ui import ChoicedEnum, Transformed + +from data import Condition, NULL +from wards import low_management + +from . import babel, logger +from .data import TasklistData +from .tasklist import Tasklist +from .ui import TasklistUI, SingleEditor, BulkEditor +from .settings import TasklistSettings, TasklistConfigUI + +_p = babel._p + + +MAX_LENGTH = 100 + + +class BeforeSelection(ChoicedEnum): + """ + Set of choices for the before arguments of `remove`. + """ + HOUR = _p('argtype:Before|opt:HOUR', "The last hour") + HALFDAY = _p('argtype:Before|opt:HALFDAY', "The last 12 hours") + DAY = _p('argtype:Before|opt:DAY', "The last 24 hours") + TODAY = _p('argtype:Before|opt:TODAY', "Today") + YESTERDAY = _p('argtype:Before|opt:YESTERDAY', "Yesterday") + MONDAY = _p('argtype:Before|opt:Monday', "This Monday") + THISMONTH = _p('argtype:Before|opt:THISMONTH', "This Month") + + @property + def choice_name(self): + return self.value + + @property + def choice_value(self): + return self.name + + def needs_timezone(self): + return self in ( + BeforeSelection.TODAY, + BeforeSelection.YESTERDAY, + BeforeSelection.MONDAY, + BeforeSelection.THISMONTH + ) + + def cutoff(self, timezone): + """ + Cut-off datetime for this period, in the given timezone. + """ + now = dt.datetime.now(tz=timezone) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + if self is BeforeSelection.HOUR: + return now - dt.timedelta(hours=1) + elif self is BeforeSelection.HALFDAY: + return now - dt.timedelta(hours=12) + elif self is BeforeSelection.DAY: + return now - dt.timedelta(hours=24) + elif self is BeforeSelection.TODAY: + time = day_start + elif self is BeforeSelection.YESTERDAY: + time = day_start - dt.timedelta(days=1) + elif self is BeforeSelection.MONDAY: + time = day_start - dt.timedelta(days=now.weekday) + elif self is BeforeSelection.THISMONTH: + time = day_start.replace(day=0) + return time + + +class TasklistCog(LionCog): + """ + Command cog for the tasklist module. + + All tasklist modification commands will summon the + member's TasklistUI, if currently in a tasklist-enabled channel, + or in a rented room channel (TODO). + + Commands + -------- + /tasklist open + Summon the TasklistUI panel for the current member. + /tasklist new [parent:str] + Create a new task and add it to the tasklist. + /tasklist edit [taskid:int] [content:str] [parent:str] + With no arguments, opens up the task editor modal. + With only `taskid` given, opens up a single modal editor for that task. + With both `taskid` and `content` given, updates the given task content. + If only `content` is given, errors. + /tasklist clear + Clears the tasklist, after confirmation. + /tasklist remove [taskids:ranges] [created_before:dur] [updated_before:dur] [completed:bool] [cascade:bool] + Remove tasks described by a sequence of conditions. + Duration arguments use a time selector menu rather than a Duration type. + With no arguments, acts like `clear`. + /tasklist tick [cascade:bool] + Tick a selection of taskids, accepting ranges. + /tasklist untick [cascade:bool] + Untick a selection of taskids, accepting ranges. + + Interface + --------- + This cog does not expose a public interface. + + Attributes + ---------- + bot: LionBot + The client which owns this Cog. + data: TasklistData + The tasklist data registry. + babel: LocalBabel + The LocalBabel instance for this module. + """ + depends = {'CoreCog', 'ConfigCog', 'Economy'} + + def __init__(self, bot: LionBot): + self.bot = bot + self.data = bot.db.load_registry(TasklistData()) + self.babel = babel + self.settings = TasklistSettings() + + async def cog_load(self): + await self.data.init() + self.bot.core.guild_settings.attach(self.settings.task_reward) + self.bot.core.guild_settings.attach(self.settings.task_reward_limit) + + # TODO: Better method for getting single load + # Or better, unloading crossloaded group + configcog = self.bot.get_cog('ConfigCog') + self.crossload_group(self.configure_group, configcog.configure_group) + + @LionCog.listener('on_tasks_completed') + async def reward_tasks_completed(self, member: discord.Member, *taskids: int): + conn = await self.bot.db.get_connection() + async with conn.transaction(): + tasklist = await Tasklist.fetch(self.bot, self.data, member.id) + tasks = await tasklist.fetch_tasks(*taskids) + unrewarded = [task for task in tasks if not task.rewarded] + if unrewarded: + reward = (await self.settings.task_reward.get(member.guild.id)).value + limit = (await self.settings.task_reward_limit.get(member.guild.id)).value + + ecog = self.bot.get_cog('Economy') + recent = await ecog.data.TaskTransaction.count_recent_for(member.id, member.guild.id) or 0 + max_to_reward = max(limit-recent, 0) + to_reward = unrewarded[:max_to_reward] + + count = len(to_reward) + amount = count * reward + await ecog.data.TaskTransaction.reward_completed(member.id, member.guild.id, count, amount) + await tasklist.update_tasks(*(task.taskid for task in to_reward), rewarded=True) + logger.debug( + f"Rewarded in " + f"'{amount}' coins for completing '{count}' tasks." + ) + + @cmds.hybrid_group( + name=_p('group:tasklist', "tasklist") + ) + async def tasklist_group(self, ctx: LionContext): + raise NotImplementedError + + async def task_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: + t = self.bot.translator.t + + # Should usually be cached, so this won't trigger repetitive db access + tasklist = await Tasklist.fetch(self.bot, self.data, interaction.user.id) + + labels = [] + for label, task in tasklist.labelled.items(): + labelstring = '.'.join(map(str, label)) + '.' * (len(label) == 1) + taskstring = f"{labelstring} {task.content}" + labels.append((labelstring, taskstring)) + + matching = [(label, task) for label, task in labels if label.startswith(partial)] + + if not matching: + matching = [(label, task) for label, task in labels if partial.lower() in task.lower()] + + if not matching: + options = [ + appcmds.Choice( + name=t(_p( + 'argtype:taskid|error:no_matching', + "No tasks matching {partial}!", + )).format(partial=partial), + value=partial + ) + ] + else: + options = [ + appcmds.Choice(name=task_string, value=label) + for label, task_string in matching + ] + return options[:25] + + async def is_tasklist_channel(self, channel) -> bool: + if not channel.guild: + return True + channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value + return (not channels) or (channel in channels) or (channel.category in channels) + + @tasklist_group.command( + name=_p('cmd:tasklist_open', "open"), + description=_p( + 'cmd:tasklist_open|desc', + "Open your tasklist." + ) + ) + async def tasklist_open_cmd(self, ctx: LionContext): + # TODO: Further arguments for style, e.g. gui/block/text + if await self.is_tasklist_channel(ctx.channel): + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) + await tasklistui.summon() + await ctx.interaction.delete_original_response() + else: + t = self.bot.translator.t + channels = (await self.settings.tasklist_channels.get(ctx.guild.id)).value + viewable = [ + channel for channel in channels + if (channel.permissions_for(ctx.author).send_messages + or channel.permissions_for(ctx.author).send_messages_in_threads) + ] + embed = discord.Embed( + title=t(_p('cmd:tasklist_open|error:tasklist_channel|title', "Sorry, I can't do that here")), + colour=discord.Colour.brand_red() + ) + if viewable: + embed.description = t(_p( + 'cmd:tasklist_open|error:tasklist_channel|desc', + "Please use direct messages or one of the following channels " + "or categories for managing your tasks:\n{channels}" + )).format(channels='\n'.join(channel.mention for channel in viewable)) + else: + embed.description = t(_p( + 'cmd:tasklist_open|error:tasklist_channel|desc', + "There are no channels available here where you may open your tasklist!" + )) + await ctx.reply(embed=embed, ephemeral=True) + + @tasklist_group.command( + name=_p('cmd:tasklist_new', "new"), + description=_p( + 'cmd:tasklist_new|desc', + "Add a new task to your tasklist." + ) + ) + @appcmds.rename( + content=_p('cmd:tasklist_new|param:content', "task"), + parent=_p('cmd:tasklist_new|param:parent', 'parent') + ) + @appcmds.describe( + content=_p('cmd:tasklist_new|param:content|desc', "Content of your new task."), + parent=_p('cmd:tasklist_new|param:parent', 'Parent of this task.') + ) + async def tasklist_new_cmd(self, ctx: LionContext, + content: appcmds.Range[str, 1, MAX_LENGTH], + parent: Optional[str] = None): + t = self.bot.translator.t + if not ctx.interaction: + return + + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + # Fetch parent task if required + pid = tasklist.parse_label(parent) if parent else None + if parent and pid is None: + # Could not parse + await ctx.interaction.edit_original_response( + embed=error_embed( + t(_p( + 'cmd:tasklist_new|error:parse_parent', + "Could not find task number `{input}` in your tasklist." + )).format(input=parent) + ), + ) + return + + # Create task + await tasklist.create_task(content, parentid=pid) + + if await self.is_tasklist_channel(ctx.interaction.channel): + # summon tasklist + tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) + await tasklistui.summon() + await ctx.interaction.delete_original_response() + else: + # ack creation + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:tasklist_new|resp:success', + "{tick} Task created successfully." + )).format(tick=self.bot.config.emojis.tick) + ) + await ctx.interaction.edit_original_response(embed=embed) + + @tasklist_new_cmd.autocomplete('parent') + async def tasklist_new_cmd_parent_acmpl(self, interaction: discord.Interaction, partial: str): + return await self.task_acmpl(interaction, partial) + + @tasklist_group.command( + name=_p('cmd:tasklist_edit', "edit"), + description=_p( + 'cmd:tasklist_edit|desc', + "Edit tasks in your tasklist." + ) + ) + @appcmds.rename( + taskstr=_p('cmd:tasklist_edit|param:taskstr', "task"), + new_content=_p('cmd:tasklist_edit|param:new_content', "new_task"), + new_parent=_p('cmd:tasklist_edit|param:new_parent', "new_parent"), + ) + @appcmds.describe( + taskstr=_p('cmd:tasklist_edit|param:taskstr|desc', "Which task do you want to update?"), + new_content=_p('cmd:tasklist_edit|param:new_content|desc', "What do you want to change the task to?"), + new_parent=_p('cmd:tasklist_edit|param:new_parent|desc', "Which task do you want to be the new parent?"), + ) + async def tasklist_edit_cmd(self, ctx: LionContext, + taskstr: str, + new_content: Optional[appcmds.Range[str, 1, MAX_LENGTH]] = None, + new_parent: Optional[str] = None): + t = self.bot.translator.t + if not ctx.interaction: + return + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + + # Fetch task to edit + tid = tasklist.parse_label(taskstr) if taskstr else None + if tid is None: + # Could not parse + await ctx.interaction.response.send_message( + embed=error_embed( + t(_p( + 'cmd:tasklist_edit|error:parse_taskstr', + "Could not find task number `{input}` in your tasklist." + )).format(input=taskstr) + ), + ephemeral=True, + ) + return + + async def handle_update(interaction, new_content, new_parent): + # Parse new parent if given + pid = tasklist.parse_label(new_parent) if new_parent else None + if new_parent and not pid: + # Could not parse + await interaction.response.send_message( + embed=error_embed( + t(_p( + 'cmd:tasklist_edit|error:parse_parent', + "Could not find task number `{input}` in your tasklist." + )).format(input=new_parent) + ), + ephemeral=True + ) + return + + args = {} + if new_content: + args['content'] = new_content + if new_parent: + args['parentid'] = pid + if args: + await tasklist.update_tasks(tid, **args) + + if await self.is_tasklist_channel(ctx.channel): + tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) + await tasklistui.summon() + else: + embed = discord.Embed( + colour=discord.Color.brand_green(), + description=t(_p( + 'cmd:tasklist_edit|resp:success|desc', + "{tick} Task updated successfully." + )).format(tick=self.bot.config.emojis.tick), + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + if new_content or new_parent: + # Manual edit route + await handle_update(ctx.interaction, new_content, new_parent) + if not ctx.interaction.response.is_done(): + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + await ctx.interaction.delete_original_response() + else: + # Modal edit route + task = tasklist.tasklist[tid] + parent_label = tasklist.labelid(task.parentid) if task.parentid else None + + editor = SingleEditor( + title=t(_p('ui:tasklist_single_editor|title', "Edit Task")) + ) + editor.task.default = task.content + editor.parent.default = tasklist.format_label(parent_label) if parent_label else None + + @editor.submit_callback() + async def update_task(interaction: discord.Interaction): + await handle_update(interaction, editor.task.value, editor.parent.value) + if not interaction.response.is_done(): + await interaction.response.defer() + + await ctx.interaction.response.send_modal(editor) + + @tasklist_edit_cmd.autocomplete('taskstr') + async def tasklist_edit_cmd_taskstr_acmpl(self, interaction: discord.Interaction, partial: str): + return await self.task_acmpl(interaction, partial) + + @tasklist_edit_cmd.autocomplete('new_parent') + async def tasklist_edit_cmd_new_parent_acmpl(self, interaction: discord.Interaction, partial: str): + return await self.task_acmpl(interaction, partial) + + @tasklist_group.command( + name=_p('cmd:tasklist_clear', "clear"), + description=_p('cmd:tasklist_clear|desc', "Clear your tasklist.") + ) + async def tasklist_clear_cmd(self, ctx: LionContext): + t = ctx.bot.translator.t + + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + await tasklist.update_tasklist(deleted_at=utc_now()) + await ctx.reply( + t(_p( + 'cmd:tasklist_clear|resp:success', + "Your tasklist has been cleared." + )), + ephemeral=True + ) + tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) + await tasklistui.summon() + + @tasklist_group.command( + name=_p('cmd:tasklist_remove', "remove"), + description=_p( + 'cmd:tasklist_remove|desc', + "Remove tasks matching all the provided conditions. (E.g. remove tasks completed before today)." + ) + ) + @appcmds.rename( + taskidstr=_p('cmd:tasklist_remove|param:taskidstr', "tasks"), + created_before=_p('cmd:tasklist_remove|param:created_before', "created_before"), + updated_before=_p('cmd:tasklist_remove|param:updated_before', "updated_before"), + completed=_p('cmd:tasklist_remove|param:completed', "completed"), + cascade=_p('cmd:tasklist_remove|param:cascade', "cascade") + ) + @appcmds.describe( + taskidstr=_p( + 'cmd:tasklist_remove|param:taskidstr|desc', + "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)." + ), + created_before=_p( + 'cmd:tasklist_remove|param:created_before|desc', + "Only delete tasks created before the selected time." + ), + updated_before=_p( + 'cmd:tasklist_remove|param:updated_before|desc', + "Only deleted tasks update (i.e. completed or edited) before the selected time." + ), + completed=_p( + 'cmd:tasklist_remove|param:completed', + "Only delete tasks which are (not) complete." + ), + cascade=_p( + 'cmd:tasklist_remove|param:cascade', + "Whether to recursively remove subtasks of removed tasks." + ) + ) + async def tasklist_remove_cmd(self, ctx: LionContext, + taskidstr: str, + created_before: Optional[Transformed[BeforeSelection, cmdopt.string]] = None, + updated_before: Optional[Transformed[BeforeSelection, cmdopt.string]] = None, + completed: Optional[bool] = None, + cascade: bool = True): + t = self.bot.translator.t + if not ctx.interaction: + return + + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + + conditions = [] + if taskidstr: + try: + taskids = tasklist.parse_labels(taskidstr) + except UserInputError as error: + await ctx.interaction.edit_original_response( + embed=error_embed(error.msg) + ) + return + + if not taskids: + # Explicitly error if none of the ranges matched + await ctx.interaction.edit_original_response( + embed=error_embed( + 'cmd:tasklist_remove_cmd|error:no_matching', + "No tasks on your tasklist match `{input}`" + ).format(input=taskidstr) + ) + return + + conditions.append(self.data.Task.taskid == taskids) + + if created_before is not None or updated_before is not None: + # TODO: Extract timezone from user settings + timezone = None + if created_before is not None: + conditions.append(self.data.Task.created_at <= created_before.cutoff(timezone)) + if updated_before is not None: + conditions.append(self.data.Task.last_updated_at <= updated_before.cutoff(timezone)) + + if completed is True: + conditions.append(self.data.Task.completed_at != NULL) + elif completed is False: + conditions.append(self.data.Task.completed_at == NULL) + + tasks = await self.data.Task.fetch_where(*conditions, userid=ctx.author.id) + if not tasks: + await ctx.interaction.edit_original_response( + embed=error_embed( + 'cmd:tasklist_remove_cmd|error:no_matching', + "No tasks on your tasklist matching all the given conditions!" + ).format(input=taskidstr) + ) + return + taskids = [task.taskid for task in tasks] + await tasklist.update_tasks(*taskids, cascade=cascade, deleted_at=utc_now()) + + # Ack changes or summon tasklist + if await self.is_tasklist_channel(ctx.channel): + # Summon tasklist + tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) + await tasklistui.summon() + await ctx.interaction.delete_original_response() + else: + # Ack deletion + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:tasklist_remove|resp:success', + "{tick} tasks deleted." + )).format(tick=self.bot.config.emojis.tick) + ) + await ctx.interaction.edit_original_response(embed=embed) + + @tasklist_group.command( + name=_p('cmd:tasklist_tick', "tick"), + description=_p('cmd:tasklist_tick|desc', "Mark the given tasks as completed.") + ) + @appcmds.rename( + taskidstr=_p('cmd:tasklist_tick|param:taskidstr', "tasks"), + cascade=_p('cmd:tasklist_tick|param:cascade', "cascade") + ) + @appcmds.describe( + taskidstr=_p( + 'cmd:tasklist_tick|param:taskidstr|desc', + "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)." + ), + cascade=_p( + 'cmd:tasklist_tick|param:cascade|desc', + "Whether to also mark all subtasks as complete." + ) + ) + async def tasklist_tick_cmd(self, ctx: LionContext, taskidstr: str, cascade: bool = True): + t = self.bot.translator.t + if not ctx.interaction: + return + + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + + try: + taskids = tasklist.parse_labels(taskidstr) + except UserInputError as error: + await ctx.interaction.edit_original_response( + embed=error_embed(error.msg) + ) + return + + if not taskids: + if not taskids: + # Explicitly error if none of the ranges matched + await ctx.interaction.edit_original_response( + embed=error_embed( + 'cmd:tasklist_remove_cmd|error:no_matching', + "No tasks on your tasklist match `{input}`" + ).format(input=taskidstr) + ) + return + + tasks = [tasklist.tasklist[taskid] for taskid in taskids] + tasks = [task for task in tasks if task.completed_at is None] + taskids = [task.taskid for task in tasks] + if taskids: + await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=utc_now()) + if ctx.guild: + self.bot.dispatch('tasks_completed', ctx.author, *taskids) + + # Ack changes or summon tasklist + if await self.is_tasklist_channel(ctx.channel): + # Summon tasklist + tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) + await tasklistui.summon() + await ctx.interaction.delete_original_response() + else: + # Ack edit + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:tasklist_tick|resp:success', + "{tick} tasks marked as complete." + )).format(tick=self.bot.config.emojis.tick) + ) + await ctx.interaction.edit_original_response(embed=embed) + + @tasklist_group.command( + name=_p('cmd:tasklist_untick', "untick"), + description=_p('cmd:tasklist_untick|desc', "Mark the given tasks as incomplete.") + ) + @appcmds.rename( + taskidstr=_p('cmd:tasklist_untick|param:taskidstr', "taskids"), + cascade=_p('cmd:tasklist_untick|param:cascade', "cascade") + ) + @appcmds.describe( + taskidstr=_p( + 'cmd:tasklist_untick|param:taskidstr|desc', + "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)." + ), + cascade=_p( + 'cmd:tasklist_untick|param:cascade|desc', + "Whether to also mark all subtasks as incomplete." + ) + ) + async def tasklist_untick_cmd(self, ctx: LionContext, taskidstr: str, cascade: Optional[bool] = False): + t = self.bot.translator.t + if not ctx.interaction: + return + + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + + try: + taskids = tasklist.parse_labels(taskidstr) + except UserInputError as error: + await ctx.interaction.edit_original_response( + embed=error_embed(error.msg) + ) + return + + if not taskids: + # Explicitly error if none of the ranges matched + await ctx.interaction.edit_original_response( + embed=error_embed( + 'cmd:tasklist_remove_cmd|error:no_matching', + "No tasks on your tasklist match `{input}`" + ).format(input=taskidstr) + ) + return + + tasks = [tasklist.tasklist[taskid] for taskid in taskids] + tasks = [task for task in tasks if task.completed_at is not None] + taskids = [task.taskid for task in tasks] + if taskids: + await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=None) + + # Ack changes or summon tasklist + if await self.is_tasklist_channel(ctx.channel): + # Summon tasklist + tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) + await tasklistui.summon() + await ctx.interaction.delete_original_response() + else: + # Ack edit + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:tasklist_untick|resp:success', + "{tick} tasks marked as incomplete." + )).format(tick=self.bot.config.emojis.tick) + ) + await ctx.interaction.edit_original_response(embed=embed) + + # Setting Commands + @LionCog.placeholder_group + @cmds.hybrid_group('configure', with_app_command=False) + async def configure_group(self, ctx: LionContext): + ... + + @configure_group.command( + name=_p('cmd:configure_tasklist', "tasklist"), + description=_p('cmd:configure_tasklist|desc', "Configuration panel") + ) + @appcmds.rename( + reward=_p('cmd:configure_tasklist|param:reward', "reward"), + reward_limit=_p('cmd:configure_tasklist|param:reward_limit', "reward_limit") + ) + @appcmds.describe( + reward=TasklistSettings.task_reward._desc, + reward_limit=TasklistSettings.task_reward_limit._desc + ) + @appcmds.check(low_management) + async def configure_tasklist_cmd(self, ctx: LionContext, + reward: Optional[int] = None, + reward_limit: Optional[int] = None): + t = self.bot.translator.t + if not ctx.guild: + return + if not ctx.interaction: + return + + task_reward = await self.settings.task_reward.get(ctx.guild.id) + task_reward_limit = await self.settings.task_reward_limit.get(ctx.guild.id) + + # TODO: Batch properly + updated = False + if reward is not None: + task_reward.data = reward + await task_reward.write() + updated = True + + if reward_limit is not None: + task_reward_limit.data = reward_limit + await task_reward_limit.write() + updated = True + + # Send update ack if required + if updated: + description = t(_p( + 'cmd:configure_tasklist|resp:success|desc', + "Members will now be rewarded {coin}**{amount}** for " + "each task they complete up to a maximum of `{limit}` tasks per 24h." + )).format( + coin=self.bot.config.emojis.coin, + amount=task_reward.data, + limit=task_reward_limit.data + ) + await ctx.reply( + embed=discord.Embed( + colour=discord.Colour.brand_green(), + description=description + ) + ) + + if ctx.channel.id not in TasklistConfigUI._listening or not ctx.interaction.response.is_done(): + # Launch setting group UI + configui = TasklistConfigUI(self.bot, self.settings, ctx.guild.id, ctx.channel.id) + await configui.run(ctx.interaction) + await configui.wait() diff --git a/bot/modules/tasklist/data.py b/bot/modules/tasklist/data.py new file mode 100644 index 00000000..fb31de34 --- /dev/null +++ b/bot/modules/tasklist/data.py @@ -0,0 +1,45 @@ +from psycopg import sql + +from data import RowModel, Registry, Table +from data.columns import Integer, String, Timestamp, Bool + + +class TasklistData(Registry): + class Task(RowModel): + """ + Row model describing a single task in a tasklist. + + Schema + ------ + CREATE TABLE tasklist( + taskid SERIAL PRIMARY KEY, + userid BIGINT NOT NULL REFERENCES user_config ON DELETE CASCADE, + parentid INTEGER REFERENCES tasklist (taskid) ON DELETE SET NULL, + content TEXT NOT NULL, + rewarded BOOL DEFAULT FALSE, + deleted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ, + last_updated_at TIMESTAMPTZ + ); + CREATE INDEX tasklist_users ON tasklist (userid); + + CREATE TABLE tasklist_channels( + guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, + channelid BIGINT NOT NULL + ); + CREATE INDEX tasklist_channels_guilds ON tasklist_channels (guildid); + """ + _tablename_ = "tasklist" + + taskid = Integer(primary=True) + userid = Integer() + parentid = Integer() + rewarded = Bool() + content = String() + completed_at = Timestamp() + created_at = Timestamp() + deleted_at = Timestamp() + last_updated_at = Timestamp() + + channels = Table('tasklist_channels') diff --git a/bot/modules/tasklist/settings.py b/bot/modules/tasklist/settings.py new file mode 100644 index 00000000..aa164a42 --- /dev/null +++ b/bot/modules/tasklist/settings.py @@ -0,0 +1,280 @@ +from typing import Optional +import discord +from discord.ui.select import select, Select, SelectOption, ChannelSelect +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.text_input import TextInput, TextStyle + +from settings import ListData, ModelData +from settings.setting_types import StringSetting, BoolSetting, ChannelListSetting, IntegerSetting +from settings.groups import SettingGroup + +from meta import conf, LionBot +from utils.lib import tabulate +from utils.ui import LeoUI, FastModal, error_handler_for, ModalRetryUI +from core.data import CoreData +from babel.translator import ctx_translator + +from . import babel +from .data import TasklistData + +_p = babel._p + + +class TasklistSettings(SettingGroup): + class task_reward(ModelData, IntegerSetting): + """ + Guild configuration for the task completion economy award. + + Exposed via `/configure tasklist`, and the standard configuration interface. + """ + setting_id = 'task_reward' + + _display_name = _p('guildset:task_reward', "task_reward") + _desc = _p( + 'guildset:task_reward|desc', + "Number of LionCoins given for each completed task." + ) + _long_desc = _p( + 'guildset:task_reward|long_desc', + "The number of coins members will be rewarded each time they complete a task on their tasklist." + ) + _default = 50 + + _model = CoreData.Guild + _column = CoreData.Guild.task_reward.name + + @property + def update_message(self): + t = ctx_translator.get().t + return t(_p( + 'guildset:task_reward|response', + "Members will now be rewarded {coin}**{amount}** for each completed task." + )).format(coin=conf.emojis.coin, amount=self.data) + + @property + def set_str(self): + return '' + + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + if data is not None: + return "{coin}**{amount}** per task.".format( + coin=conf.emojis.coin, + amount=data + ) + + class task_reward_limit(ModelData, IntegerSetting): + setting_id = 'task_reward_limit' + + _display_name = _p('guildset:task_reward_limit', "task_reward_limit") + _desc = _p( + 'guildset:task_reward_limit|desc', + "Maximum number of task rewards given per 24h." + ) + _long_desc = _p( + 'guildset:task_reward_limit|long_desc', + "Maximum number of times in each 24h period that members will be rewarded " + "for completing a task." + ) + _default = 10 + + _model = CoreData.Guild + _column = CoreData.Guild.task_reward_limit.name + + @property + def update_message(self): + t = ctx_translator.get().t + return t(_p( + 'guildset:task_reward_limit|response', + "Members will now be rewarded for task completion at most **{amount}** times per 24h." + )).format(amount=self.data) + + @property + def set_str(self): + return '' + + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + if data is not None: + return "`{number}` per 24 hours.".format( + number=data + ) + + class tasklist_channels(ListData, ChannelListSetting): + setting_id = 'tasklist_channels' + + _display_name = _p('guildset:tasklist_channels', "tasklist_channels") + _desc = _p( + 'guildset:tasklist_channels|desc', + "Channels in which to allow the tasklist." + ) + _long_desc = _p( + 'guildset:tasklist_channels|long_desc', + "If set, members will only be able to open their tasklist in these channels.\n" + "If a category is selected, this will allow all channels under that category." + ) + _default = None + + _table_interface = TasklistData.channels + _id_column = 'guildid' + _data_column = 'channelid' + _order_column = 'channelid' + + _cache = {} + + @property + def set_str(self): + return "Channel selector below." + + +class TasklistConfigUI(LeoUI): + # TODO: Back option to globall guild config + # TODO: Cohesive edit + _listening = {} + + def __init__(self, bot: LionBot, settings: TasklistSettings, guildid: int, channelid: int,**kwargs): + super().__init__(**kwargs) + self.bot = bot + self.settings = settings + self.guildid = guildid + self.channelid = channelid + + # Original interaction, used when the UI runs as an initial interaction response + self._original: Optional[discord.Interaction] = None + # UI message, used when UI run as a followup message + self._message: Optional[discord.Message] = None + + self.task_reward = None + self.task_reward_limit = None + self.tasklist_channels = None + + self.embed: Optional[discord.Embed] = None + + self.set_labels() + + @property + def instances(self): + return (self.task_reward, self.task_reward_limit, self.tasklist_channels) + + @button(label='CLOSE_PLACEHOLDER') + async def close_pressed(self, interaction: discord.Interaction, pressed): + """ + Close the configuration UI. + """ + try: + if self._message: + await self._message.delete() + self._message = None + elif self._original: + await self._original.delete_original_response() + self._original = None + except discord.HTTPException: + pass + await self.close() + + @button(label='RESET_PLACEHOLDER') + async def reset_pressed(self, interaction: discord.Interaction, pressed): + """ + Reset the tasklist configuration. + """ + await interaction.response.defer() + + self.task_reward.data = None + await self.task_reward.write() + self.task_reward_limit.data = None + await self.task_reward_limit.write() + self.tasklist_channels.data = None + await self.tasklist_channels.write() + + await self.refresh() + await self.redraw() + + @select(cls=ChannelSelect, placeholder="CHANNEL_SELECTOR_PLACEHOLDER", min_values=0, max_values=25) + async def channels_selected(self, interaction: discord.Interaction, selected: Select): + """ + Multi-channel selector to select the tasklist channels in the Guild. + Allows any channel type. + Selected category channels will apply to their children. + """ + await interaction.response.defer() + self.tasklist_channels.value = selected.values + await self.tasklist_channels.write() + await self.refresh() + await self.redraw() + + async def cleanup(self): + self._listening.pop(self.channelid, None) + self.task_reward.deregister_callback(self.id) + self.task_reward_limit.deregister_callback(self.id) + try: + if self._original is not None: + await self._original.delete_original_response() + self._original = None + if self._message is not None: + await self._message.delete() + self._message = None + except discord.HTTPException: + pass + + async def run(self, interaction: discord.Interaction): + if old := self._listening.get(self.channelid, None): + await old.close() + + await self.refresh() + + if interaction.response.is_done(): + # Use followup + self._message = await interaction.followup.send(embed=self.embed, view=self) + else: + # Use interaction response + self._original = interaction + await interaction.response.send_message(embed=self.embed, view=self) + + self.task_reward.register_callback(self.id)(self.reload) + self.task_reward_limit.register_callback(self.id)(self.reload) + self._listening[self.channelid] = self + + async def reload(self, *args, **kwargs): + await self.refresh() + await self.redraw() + + async def refresh(self): + self.task_reward = await self.settings.task_reward.get(self.guildid) + self.task_reward_limit = await self.settings.task_reward_limit.get(self.guildid) + self.tasklist_channels = await self.settings.tasklist_channels.get(self.guildid) + self._layout = [ + (self.channels_selected,), + (self.reset_pressed, self.close_pressed) + ] + self.embed = await self.make_embed() + + def set_labels(self): + t = self.bot.translator.t + self.close_pressed.label = t(_p('ui:tasklist_config|button:close|label', "Close")) + self.reset_pressed.label = t(_p('ui:tasklist_config|button:reset|label', "Reset")) + self.channels_selected.placeholder = t(_p( + 'ui:tasklist_config|menu:channels|placeholder', + "Set Tasklist Channels" + )) + + async def redraw(self): + try: + if self._message: + await self._message.edit(embed=self.embed, view=self) + elif self._original: + await self._original.edit_original_response(embed=self.embed, view=self) + except discord.HTTPException: + pass + + async def make_embed(self): + t = self.bot.translator.t + embed = discord.Embed( + colour=discord.Colour.orange(), + title=t(_p( + 'ui:tasklist_config|embed|title', + "Tasklist Configuration Panel" + )) + ) + for setting in self.instances: + embed.add_field(**setting.embed_field, inline=False) + return embed diff --git a/bot/modules/tasklist/tasklist.py b/bot/modules/tasklist/tasklist.py new file mode 100644 index 00000000..1c1db406 --- /dev/null +++ b/bot/modules/tasklist/tasklist.py @@ -0,0 +1,259 @@ +from typing import Optional +from weakref import WeakValueDictionary +import re + +from meta import LionBot +from meta.errors import UserInputError +from utils.lib import utc_now + +from .data import TasklistData + + +class Tasklist: + """ + Class representing a single user's tasklist. + + Attributes + ---------- + bot: LionBot + Client which controls this tasklist. + data: TasklistData + Initialised tasklist data registry. + userid: int + The user who owns this tasklist. + tasklist: dict[int, TasklistData.Task] + A local cache map of tasks the user owns. + May or may not contain deleted tasks. + """ + _cache_ = WeakValueDictionary() + + label_range_re = re.compile( + r"^(?P(\d+\.)*\d+)\.?((\s*(?P-)\s*)(?P(\d+\.)*\d*\.?))?$" + ) + + def __init__(self, bot: LionBot, data: TasklistData, userid: int): + self.bot = bot + self.data = data + self.userid = userid + + self.tasklist: dict[int, TasklistData.Task] = {} + + @classmethod + async def fetch(cls, bot: LionBot, data: TasklistData, userid: int) -> 'Tasklist': + """ + Fetch and initialise a Tasklist, using cache where possible. + """ + if userid not in cls._cache_: + cls = cls(bot, data, userid) + await cls.refresh() + cls._cache_[userid] = cls + return cls._cache_[userid] + + def _label(self, task, taskmap, labels, counters) -> tuple[int, ...]: + tid = task.taskid + + if tid in labels: + label = labels[tid] + else: + pid = task.parentid + counters[pid] = i = counters.get(pid, 0) + 1 + if pid is not None and (parent := taskmap.get(pid, None)) is not None: + plabel = self._label(parent, taskmap, labels, counters) + else: + plabel = () + labels[tid] = label = (*plabel, i) + return label + + @property + def labelled(self) -> dict[tuple[int, ...], TasklistData.Task]: + """ + A sorted map of task string ids to tasks. + This is the tasklist that is visible to the user. + """ + taskmap = { + task.taskid: task + for task in sorted(self.tasklist.values(), key=lambda t: t.taskid) + if task.deleted_at is None + } + labels = {} + counters = {} + for task in taskmap.values(): + self._label(task, taskmap, labels, counters) + labelmap = { + label: taskmap[taskid] + for taskid, label in sorted(labels.items(), key=lambda lt: lt[1]) + } + return labelmap + + def labelid(self, taskid) -> Optional[tuple[int, ...]]: + """ + Relatively expensive method to get the label for a given task, if it exists. + """ + task = self.tasklist.get(taskid, None) + if task is None: + return None + + labelled = self.labelled + mapper = {t.taskid: label for label, t in labelled.items()} + return mapper[taskid] + + async def refresh(self): + """ + Update the `tasklist` from data. + """ + tasks = await self.data.Task.fetch_where(userid=self.userid, deleted_at=None) + self.tasklist = {task.taskid: task for task in tasks} + + async def _owner_check(self, *taskids: int) -> bool: + """ + Check whether all of the given tasks are owned by this tasklist user. + + Applies cache where possible. + """ + missing = [tid for tid in taskids if tid not in self.tasklist] + if missing: + missing = [tid for tid in missing if (tid, ) not in self.data.Task._cache_] + if missing: + tasks = await self.data.Task.fetch_where(taskid=missing) + missing = [task.taskid for task in tasks if task.userid != self.userid] + + return not bool(missing) + + async def fetch_tasks(self, *taskids: int) -> list[TasklistData.Task]: + """ + Fetch the tasks from the tasklist with the given taskids. + + Raises a ValueError if the tasks are not owned by the tasklist user. + """ + # Check the tasklist user owns all the tasks + # Also ensures the Row objects are in cache + if not await self._owner_check(*taskids): + raise ValueError("The given tasks are not in this tasklist!") + return [await self.data.Task.fetch(tid) for tid in taskids] + + async def create_task(self, content: str, **kwargs) -> TasklistData.Task: + """ + Create a new task with the given content. + """ + task = await self.data.Task.create(userid=self.userid, content=content, **kwargs) + self.tasklist[task.taskid] = task + return task + + async def update_tasks(self, *taskids: int, cascade=False, **kwargs): + """ + Update the given taskids with the provided new values. + + If `cascade` is True, also applies the updates to all children. + """ + if not taskids: + raise ValueError("No tasks provided to update.") + + if cascade: + taskids = self.children_cascade(*taskids) + + # Ensure the taskids exist and belong to this user + await self.fetch_tasks(*taskids) + + # Update the tasks + kwargs.setdefault('last_updated_at', utc_now()) + tasks = await self.data.Task.table.update_where( + userid=self.userid, + taskid=taskids, + ).set(**kwargs) + + # Return the updated tasks + return tasks + + async def update_tasklist(self, **kwargs): + """ + Update every task in the tasklist, regardless of cache. + """ + kwargs.setdefault('last_updated_at', utc_now()) + tasks = await self.data.Task.table.update_where(userid=self.userid).set(**kwargs) + + return tasks + + def children_cascade(self, *taskids) -> list[int]: + """ + Return the provided taskids with all their descendants. + Only checks the current tasklist cache for descendants. + """ + taskids = set(taskids) + added = True + while added: + added = False + for task in self.tasklist.values(): + if task.deleted_at is None and task.taskid not in taskids and task.parentid in taskids: + taskids.add(task.taskid) + added = True + return list(taskids) + + def parse_label(self, labelstr: str) -> Optional[int]: + """ + Parse a provided label string into a taskid, if it can be found. + + Returns None if no matching taskids are found. + """ + splits = [s for s in labelstr.split('.') if s] + if all(split.isdigit() for split in splits): + tasks = self.labelled + label = tuple(map(int, splits)) + if label in tasks: + return tasks[label].taskid + + def format_label(self, label: tuple[int, ...]) -> str: + """ + Format the provided label tuple into the standard number format. + """ + return '.'.join(map(str, label)) + '.' * (len(label) == 1) + + def parse_labels(self, labelstr: str) -> Optional[list[str]]: + """ + Parse a comma separated list of labels and label ranges into a list of labels. + + E.g. `1, 2, 3`, `1, 2-5, 7`, `1, 2.1, 3`, `1, 2.1-3`, `1, 2.1-` + + May raise `UserInputError`. + """ + labelmap = {label: task.taskid for label, task in self.labelled.items()} + + splits = labelstr.split(',') + splits = [split.strip(' ,.') for split in splits] + splits = [split for split in splits if split] + + taskids = set() + for split in splits: + match = self.label_range_re.match(split) + if match: + start = match['start'] + ranged = match['range'] + end = match['end'] + + start_label = tuple(map(int, start.split('.'))) + head = start_label[:-1] + start_tail = start_label[-1] + + if end: + end_label = tuple(map(int, end.split('.'))) + end_tail = end_label[-1] + + if len(end_label) > 1 and head != end_label[:-1]: + # Error: Parents don't match in range ... + raise UserInputError("Parents don't match in range `{range}`") + + for tail in range(max(start_tail, 1), end_tail + 1): + label = (*head, tail) + if label not in labelmap: + break + taskids.add(labelmap[label]) + elif ranged: + # No end but still ranged + for label, taskid in labelmap.items(): + if (label[:-1] == head) and (label[-1] >= start_tail): + taskids.add(taskid) + elif start_label in labelmap: + taskids.add(labelmap[start_label]) + else: + # Error + raise UserInputError("Could not parse `{range}` as a task number or range.") + return list(taskids) diff --git a/bot/modules/tasklist/ui.py b/bot/modules/tasklist/ui.py new file mode 100644 index 00000000..a88b36a1 --- /dev/null +++ b/bot/modules/tasklist/ui.py @@ -0,0 +1,656 @@ +from typing import Optional +import re + +import discord +from discord.ui.select import select, Select, SelectOption +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.text_input import TextInput, TextStyle + +from meta import conf +from meta.errors import UserInputError +from utils.lib import MessageArgs, utc_now +from utils.ui import LeoUI, LeoModal, FastModal, error_handler_for, ModalRetryUI +from utils.ui.pagers import BasePager, Pager +from babel.translator import ctx_translator + +from . import babel, logger +from .tasklist import Tasklist +from .data import TasklistData + +_p = babel._p + +checkmark = "✔" +checked_emoji = conf.emojis.task_checked +unchecked_emoji = conf.emojis.task_unchecked + + +class SingleEditor(FastModal): + task: TextInput = TextInput( + label='', + max_length=100, + required=True + ) + + def setup_task(self): + t = ctx_translator.get().t + self.task.label = t(_p('modal:tasklist_single_editor|field:task|label', "Task content")) + + parent: TextInput = TextInput( + label='', + max_length=10, + required=False + ) + + def setup_parent(self): + t = ctx_translator.get().t + self.parent.label = t(_p( + 'modal:tasklist_single_editor|field:parent|label', + "Parent Task" + )) + self.parent.placeholder = t(_p( + 'modal:tasklist_single_editor|field:parent|placeholder', + "Enter a task number, e.g. 2.1" + )) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setup() + + def setup(self): + self.setup_task() + self.setup_parent() + + @error_handler_for(UserInputError) + async def rerequest(self, interaction: discord.Interaction, error: UserInputError): + await ModalRetryUI(self, error.msg).respond_to(interaction) + + +class BulkEditor(LeoModal): + """ + Error-handling modal for bulk-editing a tasklist. + """ + line_regex = re.compile(r"(?P\s*)-?\s*(\[\s*(?P[^]]?)\s*\]\s*)?(?P.*)") + + tasklist_editor: TextInput = TextInput( + label='', + style=TextStyle.long, + max_length=4000, + required=False + ) + + def setup_tasklist_editor(self): + t = ctx_translator.get().t + self.tasklist_editor.label = t(_p( + 'modal:tasklist_bulk_editor|field:tasklist|label', "Tasklist" + )) + self.tasklist_editor.placeholder = t(_p( + 'modal:tasklist_bulk_editor|field:tasklist|placeholder', + "- [ ] This is task 1, unfinished.\n" + "- [x] This is task 2, finished.\n" + " - [ ] This is subtask 2.1." + )) + + def __init__(self, tasklist: Tasklist, **kwargs): + self.setup() + super().__init__(**kwargs) + + self.tasklist = tasklist + self.bot = tasklist.bot + self.userid = tasklist.userid + + self.lines = self.format_tasklist() + self.tasklist_editor.default = '\n'.join(self.lines.values()) + + self._callbacks = [] + + def setup(self): + t = ctx_translator.get().t + self.title = t(_p( + 'modal:tasklist_bulk_editor', "Tasklist Editor" + )) + self.setup_tasklist_editor() + + def add_callback(self, coro): + self._callbacks.append(coro) + return coro + + def format_tasklist(self): + """ + Format the tasklist into lines of editable text. + """ + labelled = self.tasklist.labelled + lines = {} + total_len = 0 + for label, task in labelled.items(): + prefix = ' ' * (len(label) - 1) + box = '- [ ]' if task.completed_at is None else '- [x]' + line = f"{prefix}{box} {task.content}" + if total_len + len(line) > 4000: + break + lines[task.taskid] = line + total_len += len(line) + return lines + + async def on_submit(self, interaction: discord.Interaction): + try: + await self.parse_editor() + for coro in self._callbacks: + await coro(interaction) + await interaction.response.defer() + except UserInputError as error: + await ModalRetryUI(self, error.msg).respond_to(interaction) + + def _parser(self, task_lines): + t = ctx_translator.get().t + taskinfo = [] # (parent, truedepth, ticked, content) + depthtree = [] # (depth, index) + + for line in task_lines: + match = self.line_regex.match(line) + if not match: + raise UserInputError( + t(_p( + 'modal:tasklist_bulk_editor|error:parse_task', + "Malformed taskline!\n`{input}`" + )).format(input=line) + ) + depth = len(match['depth']) + check = bool(match['check']) + content = match['content'] + if not content: + continue + if len(content) > 100: + raise UserInputError( + t(_p( + 'modal:tasklist_bulk_editor|error:task_too_long', + "Please keep your tasks under 100 characters!" + )) + ) + + for i in range(len(depthtree)): + lastdepth = depthtree[-1][0] + if lastdepth >= depth: + depthtree.pop() + if lastdepth <= depth: + break + parent = depthtree[-1][1] if depthtree else None + depthtree.append((depth, len(taskinfo))) + taskinfo.append((parent, len(depthtree) - 1, check, content)) + return taskinfo + + async def parse_editor(self): + # First parse each line + new_lines = self.tasklist_editor.value.splitlines() + taskinfo = self._parser(new_lines) + + old_info = self._parser(self.lines.values()) + same_layout = ( + len(old_info) == len(taskinfo) + and all(info[:2] == oldinfo[:2] for (info, oldinfo) in zip(taskinfo, old_info)) + ) + + # TODO: Incremental/diff editing + conn = await self.bot.db.get_connection() + async with conn.transaction(): + now = utc_now() + + if same_layout: + # if the layout has not changed, just edit the tasks + for taskid, (oldinfo, newinfo) in zip(self.lines.keys(), zip(old_info, taskinfo)): + args = {} + if oldinfo[2] != newinfo[2]: + args['completed_at'] = now if newinfo[2] else None + if oldinfo[3] != newinfo[3]: + args['content'] = newinfo[3] + if args: + await self.tasklist.update_tasks(taskid, **args) + else: + # Naive implementation clearing entire tasklist + # Clear tasklist + await self.tasklist.update_tasklist(deleted_at=now) + + # Create tasklist + created = {} + target_depth = 0 + while True: + to_insert = {} + for i, (parent, truedepth, ticked, content) in enumerate(taskinfo): + if truedepth == target_depth: + to_insert[i] = ( + self.tasklist.userid, + content, + created[parent] if parent is not None else None, + now if ticked else None + ) + if to_insert: + # Batch insert + tasks = await self.tasklist.data.Task.table.insert_many( + ('userid', 'content', 'parentid', 'completed_at'), + *to_insert.values() + ) + for i, task in zip(to_insert.keys(), tasks): + created[i] = task['taskid'] + target_depth += 1 + else: + # Reached maximum depth + break + + +class TasklistUI(BasePager): + """ + Paged UI panel for managing the tasklist. + """ + # Cache of live tasklist widgets + # (channelid, userid) -> Tasklist + _live_ = {} + + def __init__(self, + tasklist: Tasklist, + channel: discord.abc.Messageable, guild: Optional[discord.Guild] = None, **kwargs): + kwargs.setdefault('timeout', 3600) + super().__init__(**kwargs) + + self.tasklist = tasklist + self.bot = tasklist.bot + self.userid = tasklist.userid + self.channel = channel + self.guild = guild + + # List of lists of (label, task) pairs + self._pages = [] + + self.page_num = -1 + self._channelid = channel.id + self.current_page = None + + self._deleting = False + + self._message: Optional[discord.Message] = None + + self.button_labels() + self.set_active() + + @classmethod + async def fetch(cls, tasklist, channel, *args, **kwargs): + key = (channel.id, tasklist.userid) + if key not in cls._live_: + self = cls(tasklist, channel, *args, **kwargs) + cls._live_[key] = self + return cls._live_[key] + + def access_check(self, userid): + return userid == self.userid + + async def interaction_check(self, interaction: discord.Interaction): + t = self.bot.translator.t + if not self.access_check(interaction.user.id): + embed = discord.Embed( + description=t(_p( + 'ui:tasklist|error:wrong_user', + "This is not your tasklist!" + )), + colour=discord.Colour.brand_red() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return False + else: + return True + + async def cleanup(self): + self.set_inactive() + self._live_.pop((self.channel.id, self.userid), None) + + if self._message is not None: + try: + await self._message.edit(view=None) + except discord.HTTPException: + pass + self._message = None + + try: + if self._message is not None: + await self._message.edit(view=None) + except discord.HTTPException: + pass + + async def summon(self): + """ + Refresh and re-display the tasklist widget as required. + """ + await self.refresh() + + resend = not await self._check_recent() + if resend and self._message: + # Delete our current message if possible + try: + await self._message.delete() + except discord.HTTPException: + # If we cannot delete, it has probably already been deleted + # Or we don't have permission somehow + pass + self._message = None + + # Redraw + try: + await self.redraw() + except discord.HTTPException: + if self._message: + self._message = None + await self.redraw() + + async def _check_recent(self) -> bool: + """ + Check whether the tasklist message is a "recent" message in the channel. + """ + if self._message is not None: + height = 0 + async for message in self.channel.history(limit=5): + if message.id == self._message.id: + return True + if message.id < self._message.id: + return False + if message.attachments or message.embeds or height > 20: + return False + height += len(message.content.count('\n')) + return False + return False + + async def get_page(self, page_id) -> MessageArgs: + t = self.bot.translator.t + + tasks = [t for t in self.tasklist.tasklist.values() if t.deleted_at is None] + total = len(tasks) + completed = sum(t.completed_at is not None for t in tasks) + + if self.guild: + user = self.guild.get_member(self.userid) + else: + user = self.bot.get_user(self.userid) + user_name = user.name if user else str(self.userid) + user_colour = user.colour if user else discord.Color.orange() + + author = t(_p( + 'ui:tasklist|embed|author', + "{name}'s tasklist ({completed}/{total} complete)" + )).format( + name=user_name, + completed=completed, + total=total + ) + + embed = discord.Embed( + colour=user_colour, + ) + embed.set_author( + name=author, + icon_url=user.avatar if user else None + ) + + if self._pages: + page = self._pages[page_id % len(self._pages)] + block = self._format_page(page) + embed.description = "{task_block}".format(task_block=block) + else: + embed.description = t(_p( + 'ui:tasklist|embed|description', + "**You have no tasks on your tasklist!**\n" + "Add a task with `/tasklist new`, or by pressing the `New` button below." + )) + + page_args = MessageArgs(embed=embed) + return page_args + + async def page_cmd(self, interaction: discord.Interaction, value: str): + return await Pager.page_cmd(self, interaction, value) + + async def page_acmpl(self, interaction: discord.Interaction, partial: str): + return await Pager.page_acmpl(self, interaction, partial) + + def _format_page(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str: + """ + Format a single block of page data into the task codeblock. + """ + lines = [] + numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page) + for label, task in page: + label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1) + number = f"**`{label_string}`**" + if len(label) > 1: + depth = sum(len(str(c)) + 1 for c in label[:-1]) * ' ' + depth = f"`{depth}`" + else: + depth = '' + task_string = "{depth}{cross}{number} {content}{cross}".format( + depth=depth, + number=number, + emoji=unchecked_emoji if task.completed_at is None else checked_emoji, + content=task.content, + cross='~~' if task.completed_at is not None else '' + ) + lines.append(task_string) + return '\n'.join(lines) + + def _format_page_text(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str: + """ + Format a single block of page data into the task codeblock. + """ + lines = [] + numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page) + for label, task in page: + box = '[ ]' if task.completed_at is None else f"[{checkmark}]" + task_string = "{prepad} {depth} {content}".format( + prepad=' ' * numpad, + depth=(len(label) - 1) * ' ', + content=task.content + ) + label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1) + taskline = box + ' ' + label_string + task_string[len(label_string):] + lines.append(taskline) + return "```md\n{}```".format('\n'.join(lines)) + + def refresh_pages(self): + labelled = list(self.tasklist.labelled.items()) + count = len(labelled) + pages = [] + + if count > 0: + # Break into pages + edges = [0] + line_ptr = 0 + while line_ptr < count: + line_ptr += 20 + if line_ptr < count: + # Seek backwards to find the best parent + i = line_ptr - 5 + minlabel = (i, len(labelled[i][0])) + while i < line_ptr: + i += 1 + ilen = len(labelled[i][0]) + if ilen <= minlabel[1]: + minlabel = (i, ilen) + line_ptr = minlabel[0] + else: + line_ptr = count + edges.append(line_ptr) + + pages = [labelled[edges[i]:edges[i+1]] for i in range(len(edges) - 1)] + + self._pages = pages + return pages + + @select(placeholder="TOGGLE_PLACEHOLDER") + async def toggle_selector(self, interaction: discord.Interaction, selected: Select): + await interaction.response.defer() + taskids = list(map(int, selected.values)) + tasks = await self.tasklist.fetch_tasks(*taskids) + to_complete = [task for task in tasks if task.completed_at is None] + to_uncomplete = [task for task in tasks if task.completed_at is not None] + if to_complete: + await self.tasklist.update_tasks( + *(t.taskid for t in to_complete), + cascade=True, + completed_at=utc_now() + ) + if self.guild: + if (member := self.guild.get_member(self.userid)): + self.bot.dispatch('tasks_completed', member, *(t.taskid for t in to_complete)) + if to_uncomplete: + await self.tasklist.update_tasks( + *(t.taskid for t in to_uncomplete), + completed_at=None + ) + await self.refresh() + await self.redraw() + + async def toggle_selector_refresh(self): + t = self.bot.translator.t + self.toggle_selector.placeholder = t(_p( + 'ui:tasklist|menu:toggle_selector|placeholder', + "Select to Toggle" + )) + options = [] + block = self._pages[self.page_num % len(self._pages)] + colwidth = max(sum(len(str(c)) + 1 for c in lbl) for lbl, _ in block) + for lbl, task in block: + value = str(task.taskid) + lblstr = '.'.join(map(str, lbl)) + '.' * (len(lbl) == 1) + name = f"{lblstr:<{colwidth}} {task.content}" + emoji = unchecked_emoji if task.completed_at is None else checked_emoji + options.append(SelectOption(label=name, value=value, emoji=emoji)) + + self.toggle_selector.options = options + self.toggle_selector.min_values = 0 + self.toggle_selector.max_values = len(options) + + @button(label="NEW_PLACEHOLDER", style=ButtonStyle.green) + async def new_pressed(self, interaction: discord.Interaction, pressed: Button): + t = self.bot.translator.t + editor = SingleEditor( + title=t(_p('ui:tasklist_single_editor|title', "Add task")) + ) + + @editor.submit_callback() + async def create_task(interaction): + new_task = editor.task.value + parent = editor.parent.value + pid = self.tasklist.parse_label(parent) if parent else None + if parent and pid is None: + # Could not parse + raise UserInputError( + t(_p( + 'ui:tasklist_single_editor|error:parse_parent', + "Could not find the given parent task number `{input}` in your tasklist." + )).format(input=parent) + ) + await interaction.response.defer() + await self.tasklist.create_task(new_task, parentid=pid) + await self.refresh() + await self.redraw() + + await interaction.response.send_modal(editor) + + @button(label="EDITOR_PLACEHOLDER", style=ButtonStyle.blurple) + async def edit_pressed(self, interaction: discord.Interaction, pressed: Button): + editor = BulkEditor(self.tasklist) + + @editor.add_callback + async def editor_callback(interaction: discord.Interaction): + await self.refresh() + await self.redraw() + + await interaction.response.send_modal(editor) + + @button(label="DELETE_PLACEHOLDER", style=ButtonStyle.red) + async def del_pressed(self, interaction: discord.Interaction, pressed: Button): + self._deleting = 1 - self._deleting + await interaction.response.defer() + await self.refresh() + await self.redraw() + + @select(placeholder="DELETE_SELECT_PLACEHOLDER") + async def delete_selector(self, interaction: discord.Interaction, selected: Select): + await interaction.response.defer() + taskids = list(map(int, selected.values)) + if taskids: + await self.tasklist.update_tasks( + *taskids, + cascade=True, + deleted_at=utc_now() + ) + await self.refresh() + await self.redraw() + + async def delete_selector_refresh(self): + self.delete_selector.placeholder = t(_p('ui:tasklist|menu:delete|placeholder', "Select to Delete")) + self.delete_selector.options = self.toggle_selector.options + + @button(label="ClOSE_PLACEHOLDER", style=ButtonStyle.red) + async def close_pressed(self, interaction: discord.Interaction, pressed: Button): + await interaction.response.defer() + if self._message is not None: + try: + await self._message.delete() + except discord.HTTPException: + pass + await self.close() + + @button(label="CLEAR_PLACEHOLDER", style=ButtonStyle.red) + async def clear_pressed(self, interaction: discord.Interaction, pressed: Button): + await interaction.response.defer() + await self.tasklist.update_tasklist( + deleted_at=utc_now(), + ) + await self.refresh() + await self.redraw() + + def button_labels(self): + t = self.bot.translator.t + self.new_pressed.label = t(_p('ui:tasklist|button:new', "New")) + self.edit_pressed.label = t(_p('ui:tasklist|button:edit', "Edit")) + self.del_pressed.label = t(_p('ui:tasklist|button:delete', "Delete")) + self.clear_pressed.label = t(_p('ui:tasklist|button:clear', "Clear")) + self.close_pressed.label = t(_p('ui:tasklist|button:close', "Close")) + + async def refresh(self): + # Refresh data + await self.tasklist.refresh() + self.refresh_pages() + + async def redraw(self): + self.current_page = await self.get_page(self.page_num) + + # Refresh the layout + if len(self._pages) > 1: + # Paged layout + await self.toggle_selector_refresh() + self._layout = [ + (self.new_pressed, self.edit_pressed, self.del_pressed), + (self.toggle_selector,), + (self.prev_page_button, self.close_pressed, self.next_page_button) + ] + if self._deleting: + await self.delete_selector_refresh() + self._layout.append((self.delete_selector,)) + self._layout[0] = (*self._layout[0], self.clear_pressed) + elif len(self.tasklist.tasklist) > 0: + # Single page, with tasks + await self.toggle_selector_refresh() + self._layout = [ + (self.new_pressed, self.edit_pressed, self.del_pressed, self.close_pressed), + (self.toggle_selector,), + ] + if self._deleting: + await self.delete_selector_refresh() + self._layout[0] = (*self._layout[0], self.clear_pressed) + self._layout.append((self.delete_selector,)) + else: + # With no tasks, nothing to select + self._layout = [ + (self.new_pressed, self.edit_pressed, self.close_pressed) + ] + + # Resend + if not self._message: + self._message = await self.channel.send(**self.current_page.send_args, view=self) + else: + await self._message.edit(**self.current_page.edit_args, view=self) diff --git a/bot/settings/__init__.py b/bot/settings/__init__.py index e34eec30..9c38704c 100644 --- a/bot/settings/__init__.py +++ b/bot/settings/__init__.py @@ -1,7 +1,7 @@ from babel.translator import LocalBabel babel = LocalBabel('settings_base') -from .data import ModelData +from .data import ModelData, ListData from .base import BaseSetting from .ui import SettingWidget, InteractiveSetting from .groups import SettingDotDict, SettingGroup, ModelSettings, ModelSetting diff --git a/bot/settings/data.py b/bot/settings/data.py index b11ed1c7..8c1d2edc 100644 --- a/bot/settings/data.py +++ b/bot/settings/data.py @@ -111,7 +111,7 @@ class ListData: table = cls._table_interface # type: Table query = table.select_where(**{cls._id_column: parent_id}).select(cls._data_column) if cls._order_column: - query.order_by(cls._order_column, order=cls._order_type) + query.order_by(cls._order_column, direction=cls._order_type) rows = await query data = [row[cls._data_column] for row in rows] @@ -128,7 +128,7 @@ class ListData: """ table = cls._table_interface conn = await table.connector.get_connection() - with conn.transaction(): + async with conn.transaction(): # Handle None input as an empty list if data is None: data = [] diff --git a/bot/settings/groups.py b/bot/settings/groups.py index a50f0e9f..66764c7c 100644 --- a/bot/settings/groups.py +++ b/bot/settings/groups.py @@ -73,8 +73,8 @@ class SettingGroup: """ rows = [] for setting in self.settings.values(): - name = f"{setting.display_name}" set = await setting.get(parent_id) + name = set.display_name value = set.formatted rows.append((name, value, set.hover_desc)) table_rows = tabulate( diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index b5a1e7f9..57091b30 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -1002,7 +1002,7 @@ class ListSetting: Format the list by adding `,` between each formatted item """ if not data: - return None + return 'Not Set' else: formatted_items = [] for item in data: diff --git a/bot/settings/ui.py b/bot/settings/ui.py index 80e65bd3..05f72353 100644 --- a/bot/settings/ui.py +++ b/bot/settings/ui.py @@ -11,6 +11,7 @@ from discord.ui.text_input import TextInput from utils.lib import tabulate, recover_context from utils.ui import FastModal from meta.config import conf +from babel.translator import ctx_translator from .base import BaseSetting, ParentID, SettingData, SettingValue @@ -166,10 +167,10 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): __slots__ = ('_widget',) # Configuration interface descriptions - display_name: str # User readable name of the setting - desc: str # User readable brief description of the setting - long_desc: str # User readable long description of the setting - accepts: str # User readable description of the acceptable values + _display_name: str # User readable name of the setting + _desc: str # User readable brief description of the setting + _long_desc: str # User readable long description of the setting + _accepts: str # User readable description of the acceptable values Widget = SettingWidget @@ -184,6 +185,26 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): self._widget: Optional[SettingWidget] = None + @property + def long_desc(self): + t = ctx_translator.get().t + return t(self._long_desc) + + @property + def display_name(self): + t = ctx_translator.get().t + return t(self._display_name) + + @property + def desc(self): + t = ctx_translator.get().t + return t(self._desc) + + @property + def accepts(self): + t = ctx_translator.get().t + return t(self._accepts) + async def write(self, **kwargs) -> None: await super().write(**kwargs) for listener in self._listeners_.values(): @@ -249,9 +270,13 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): Returns a {name, value} pair for use in an Embed field. """ name = self.display_name - value = f"{self.long_dec}\n{self.desc_table}" + value = f"{self.long_desc}\n{self.desc_table}" return {'name': name, 'value': value} + @property + def set_str(self): + return None + @property def embed(self): """ @@ -265,10 +290,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): @property def desc_table(self): - return tabulate( - ("Current Value", self.formatted or "Not Set"), - ("Default Value", self._format_data(self.parent_id, self.default) or "None"), - ) + lines = [] + lines.append(('Currently', self.formatted or "Not Set")) + if (default := self.default) is not None: + lines.append(('By Default', self._format_data(self.parent_id, default) or "No Default")) + if (set_str := self.set_str) is not None: + lines.append(('Set Using', set_str)) + + return '\n'.join(tabulate(*lines)) @property def input_field(self) -> TextInput: diff --git a/bot/utils/ui/pagers.py b/bot/utils/ui/pagers.py index 284cbd03..0737296e 100644 --- a/bot/utils/ui/pagers.py +++ b/bot/utils/ui/pagers.py @@ -46,6 +46,17 @@ class BasePager(LeoUI): representing all `BasePager`s that are currently running. This allows access from external page controlling utilities, e.g. the `/page` command. """ + # List of valid keys indicating movement to the next page + next_list = _p('cmd:page|pager:Pager|options:next', "n, nxt, next, forward, +") + + # List of valid keys indicating movement to the previous page + prev_list = _p('cmd:page|pager:Pager|options:prev', "p, prev, back, -") + + # List of valid keys indicating movement to the first page + first_list = _p('cmd:page|pager:Pager|options:first', "f, first, one, start") + + # List of valid keys indicating movement to the last page + last_list = _p('cmd:page|pager:Pager|options:last', "l, last, end") # channelid -> pager.id -> list of active pagers in this channel active_pagers: dict[int, dict[int, 'BasePager']] = defaultdict(dict) @@ -152,17 +163,6 @@ class Pager(BasePager): locked: bool Whether to only allow the author to use the paging interface. """ - # List of valid keys indicating movement to the next page - next_list = _p('cmd:page|pager:Pager|options:next', "n, nxt, next, forward, +") - - # List of valid keys indicating movement to the previous page - prev_list = _p('cmd:page|pager:Pager|options:prev', "p, prev, back, -") - - # List of valid keys indicating movement to the first page - first_list = _p('cmd:page|pager:Pager|options:first', "f, first, one, start") - - # List of valid keys indicating movement to the last page - last_list = _p('cmd:page|pager:Pager|options:last', "l, last, end") def __init__(self, pages: list[MessageArgs], start_from=0, diff --git a/bot/wards.py b/bot/wards.py index 1ea4da38..ccac0639 100644 --- a/bot/wards.py +++ b/bot/wards.py @@ -7,3 +7,15 @@ async def sys_admin(ctx: LionContext) -> bool: """ admins = ctx.bot.config.bot.getintlist('admins') return ctx.author.id in admins + + +async def high_management(ctx: LionContext) -> bool: + if await sys_admin(ctx): + return True + if not ctx.guild: + return False + return ctx.author.guild_permissions.administrator + + +async def low_management(ctx: LionContext) -> bool: + return (await high_management(ctx)) or ctx.author.guild_permissions.manage_guild diff --git a/config/emojis.conf b/config/emojis.conf index d5bdadbe..5d603a72 100644 --- a/config/emojis.conf +++ b/config/emojis.conf @@ -48,3 +48,6 @@ tick = :✅: clock = :⏱️: coin = <:coin:975880967485022239> + +task_checked = :🟢: +task_unchecked = :⚫: diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index 753812ae..10761d04 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -53,6 +53,8 @@ CREATE TABLE bot_config_presence( activity_type ActivityType, activity_name Text ); +-- DROP TABLE AppData CASCADE; +-- DROP TABLE AppConfig CASCADE; -- }}} @@ -243,6 +245,27 @@ CREATE VIEW member_inventory_info AS ORDER BY itemid ASC; -- }}} +-- Task Data {{{ +ALTER TABLE tasklist_channels + ADD CONSTRAINT fk_tasklist_channels_guilds + FOREIGN KEY (guildid) + REFERENCES guild_config (guildid) + ON DELETE CASCADE + NOT VALID; + +ALTER TABLE tasklist + ADD CONSTRAINT fk_tasklist_users + FOREIGN KEY (userid) + REFEREnCES user_config (userid) + ON DELETE CASCADE + NOT VALID; + +ALTER TABLE tasklist + ADD COLUMN parentid INTEGER REFERENCES tasklist (taskid) ON DELETE SET NULL; + +-- DROP TABLE tasklist_reward_history CASCADE; +-- }}} + INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration'); COMMIT; diff --git a/data/schema.sql b/data/schema.sql index 64bde777..6c794b20 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (12, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (13, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -17,6 +17,46 @@ $$ language 'plpgsql'; -- }}} -- App metadata {{{ +CREATE TABLE app_config( + appname TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE bot_config( + appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE, + default_skin TEXT +); + +CREATE TABLE shard_data( + shardname TEXT PRIMARY KEY, + appname TEXT REFERENCES bot_config(appname) ON DELETE CASCADE, + shard_id INTEGER NOT NULL, + shard_count INTEGER NOT NULL, + last_login TIMESTAMPTZ, + guild_count INTEGER +); + +CREATE TYPE OnlineStatus AS ENUM( + 'ONLINE', + 'IDLE', + 'DND', + 'OFFLINE' +); + +CREATE TYPE ActivityType AS ENUM( + 'PLAYING', + 'WATCHING', + 'LISTENING', + 'STREAMING' +); + +CREATE TABLE bot_config_presence( + appname TEXT PRIMARY KEY REFERENCES bot_config(appname) ON DELETE CASCADE, + online_status OnlineStatus, + activity_type ActivityType, + activity_name Text +); + CREATE TABLE AppData( appid TEXT PRIMARY KEY, last_study_badge_scan TIMESTAMP @@ -44,6 +84,71 @@ CREATE TABLE global_guild_blacklist( ); -- }}} +-- Analytics data {{{ +CREATE SCHEMA "analytics"; + +CREATE TABLE analytics.snapshots( + snapshotid SERIAL PRIMARY KEY, + appname TEXT NOT NULL REFERENCES bot_config (appname), + guild_count INTEGER NOT NULL, + member_count INTEGER NOT NULL, + user_count INTEGER NOT NULL, + in_voice INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc') +); + + +CREATE TABLE analytics.events( + eventid SERIAL PRIMARY KEY, + appname TEXT NOT NULL REFERENCES bot_config (appname), + ctxid BIGINT, + guildid BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc') +); + +CREATE TYPE analytics.CommandStatus AS ENUM( + 'COMPLETED', + 'CANCELLED' + 'FAILED' +); + +CREATE TABLE analytics.commands( + cmdname TEXT NOT NULL, + cogname TEXT, + userid BIGINT NOT NULL, + status analytics.CommandStatus NOT NULL, + error TEXT, + execution_time REAL NOT NULL +) INHERITS (analytics.events); + + +CREATE TYPE analytics.GuildAction AS ENUM( + 'JOINED', + 'LEFT' +); + +CREATE TABLE analytics.guilds( + guildid BIGINT NOT NULL, + action analytics.GuildAction NOT NULL +) INHERITS (analytics.events); + + +CREATE TYPE analytics.VoiceAction AS ENUM( + 'JOINED', + 'LEFT' +); + +CREATE TABLE analytics.voice_sessions( + userid BIGINT NOT NULL, + action analytics.VoiceAction NOT NULL +) INHERITS (analytics.events); + +CREATE TABLE analytics.gui_renders( + cardname TEXT NOT NULL, + duration INTEGER NOT NULL +) INHERITS (analytics.events); +--- }}} + -- User configuration data {{{ CREATE TABLE user_config( userid BIGINT PRIMARY KEY, @@ -51,7 +156,11 @@ CREATE TABLE user_config( topgg_vote_reminder BOOLEAN, avatar_hash TEXT, name TEXT, + first_seen TIMESTAMPTZ DEFAULT now(), + last_seen TIMESTAMPTZ, API_timestamp BIGINT, + locale_hint TEXT, + locale TEXT, gems INTEGER DEFAULT 0 ); -- }}} @@ -91,7 +200,11 @@ CREATE TABLE guild_config( persist_roles BOOLEAN, daily_study_cap INTEGER, pomodoro_channel BIGINT, - name TEXT + name TEXT, + first_joined_at TIMESTAMPTZ DEFAULT now(), + left_at TIMESTAMPTZ, + locale TEXT, + force_locale BOOLEAN ); CREATE TABLE ignored_members( @@ -146,7 +259,7 @@ CREATE INDEX workout_sessions_members ON workout_sessions (guildid, userid); -- Tasklist data {{{ CREATE TABLE tasklist( taskid SERIAL PRIMARY KEY, - userid BIGINT NOT NULL, + userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE, content TEXT NOT NULL, rewarded BOOL DEFAULT FALSE, deleted_at TIMESTAMPTZ, @@ -157,28 +270,22 @@ CREATE TABLE tasklist( CREATE INDEX tasklist_users ON tasklist (userid); CREATE TABLE tasklist_channels( - guildid BIGINT NOT NULL, + guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, channelid BIGINT NOT NULL ); CREATE INDEX tasklist_channels_guilds ON tasklist_channels (guildid); - -CREATE TABLE tasklist_reward_history( - userid BIGINT NOT NULL, - reward_time TIMESTAMP DEFAULT (now() at time zone 'utc'), - reward_count INTEGER -); -CREATE INDEX tasklist_reward_history_users ON tasklist_reward_history (userid, reward_time); -- }}} -- Reminder data {{{ CREATE TABLE reminders( reminderid SERIAL PRIMARY KEY, - userid BIGINT NOT NULL, + userid BIGINT NOT NULL REFERENCES user_config ON DELETE CASCADE, remind_at TIMESTAMP NOT NULL, content TEXT NOT NULL, message_link TEXT, interval INTEGER, created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), + failed BOOLEAN, title TEXT, footer TEXT ); @@ -234,6 +341,8 @@ CREATE TABLE member_inventory( inventoryid SERIAL PRIMARY KEY, guildid BIGINT NOT NULL, userid BIGINT NOT NULL, + first_joined TIMESTAMPTZ DEFAULT now(), + last_left TIMESTAMPTZ, transactionid INTEGER REFERENCES coin_transactions(transactionid) ON DELETE SET NULL, itemid INTEGER NOT NULL REFERENCES shop_items(itemid) ON DELETE CASCADE ); @@ -408,8 +517,8 @@ CREATE INDEX studyban_durations_guilds ON studyban_durations (guildid); -- Member configuration and stored data {{{ CREATE TABLE members( - guildid BIGINT, - userid BIGINT, + guildid BIGINT REFERENCES guild_config ON DELETE CASCADE, + userid BIGINT ON DELETE CASCADE, tracked_time INTEGER DEFAULT 0, coins INTEGER DEFAULT 0, workout_count INTEGER DEFAULT 0,