from typing import Optional from collections import defaultdict 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.logger import log_wrap from meta.errors import UserInputError from utils.lib import utc_now, error_embed from utils.ui import ChoicedEnum, Transformed, AButton from data import Condition, NULL from wards import low_management_ward from . import babel, logger from .data import TasklistData from .tasklist import Tasklist from .ui import TasklistUI, SingleEditor, TasklistCaller from .settings import TasklistSettings, TasklistConfigUI _p, _np = babel._p, babel._np MAX_LENGTH = 200 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() self.live_tasklists = TasklistUI._live_ async def cog_load(self): await self.data.init() self.bot.core.guild_config.register_model_setting(self.settings.task_reward) self.bot.core.guild_config.register_model_setting(self.settings.task_reward_limit) self.bot.add_view(TasklistCaller(self.bot)) configcog = self.bot.get_cog('ConfigCog') self.crossload_group(self.configure_group, configcog.configure_group) @LionCog.listener('on_tasks_completed') @log_wrap(action="reward tasks completed") async def reward_tasks_completed(self, member: discord.Member, *taskids: int): async with self.bot.db.connection() as conn: self.bot.db.conn = conn 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 = limit - recent if max_to_reward > 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." ) 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 # Also allow private rooms roomcog = self.bot.get_cog('RoomCog') if roomcog: private_rooms = roomcog.get_rooms(channel.guild.id) private_channels = {room.data.channelid for room in private_rooms.values()} else: logger.warning( "Fetching tasklist channels before private room cog is loaded!" ) private_channels = {} return (channel in channels) or (channel.id in private_channels) or (channel.category in channels) async def call_tasklist(self, interaction: discord.Interaction): await interaction.response.defer(thinking=True, ephemeral=True) channel = interaction.channel guild = channel.guild userid = interaction.user.id tasklist = await Tasklist.fetch(self.bot, self.data, userid) if await self.is_tasklist_channel(channel): # Check we have permissions to send a regular message here my_permissions = channel.permissions_for(guild.me) if not my_permissions.embed_links or not my_permissions.send_messages: t = self.bot.translator.t error = discord.Embed( colour=discord.Colour.brand_red(), title=t(_p( 'summon_tasklist|error:insufficient_perms|title', "Uh-Oh, I cannot do that here!" )), description=t(_p( 'summon_tasklist|error:insufficient_perms|desc', "This channel is configured as a tasklist channel, " "but I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission here! " "If you believe this is unintentional, please contact a server administrator." )) ) await interaction.edit_original_response(embed=error) else: tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=None) await tasklistui.summon(force=True) await interaction.delete_original_response() else: # Note that this will also close any existing listening tasklists in this channel (for this user) tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=600) await tasklistui.run(interaction) @LionCog.listener('on_tasklist_update') async def update_listening_tasklists(self, userid, channel=None, summon=True): """ Propagate a tasklist update to all persistent tasklist UIs for this user. If channel is given, also summons the UI if the channel is a tasklist channel. """ # Do the given channel first, and summon if requested if channel and (tui := TasklistUI._live_[userid].get(channel.id, None)) is not None: try: if summon and await self.is_tasklist_channel(channel): await tui.summon() else: await tui.refresh() await tui.redraw() except discord.HTTPException: await tui.close() # Now do the rest of the listening channels listening = TasklistUI._live_[userid] for cid, ui in list(listening.items()): if channel and channel.id == cid: # We already did this channel continue if cid not in listening: # UI closed while we were updating continue try: await ui.refresh() await ui.redraw() except discord.HTTPException: await ui.close() @cmds.hybrid_command( name=_p('cmd:tasklist', "tasklist"), description=_p( 'cmd:tasklist|desc', "Open your tasklist." ) ) async def tasklist_cmd(self, ctx: LionContext): if not ctx.interaction: return await self.call_tasklist(ctx.interaction) @cmds.hybrid_group( name=_p('group:tasks', "tasks"), description=_p('group:tasks|desc', "Base command group for tasklist commands.") ) async def tasklist_group(self, ctx: LionContext): raise NotImplementedError async def _task_acmpl(self, userid: int, partial: str, multi=False) -> list[appcmds.Choice]: """ Generate a list of task Choices matching a given partial string. Supports single and multiple task matching. """ 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, userid) # Special case for an empty tasklist if not tasklist.tasklist: return [ appcmds.Choice( name=t(_p( 'argtype:taskid|error:no_tasks', "Tasklist empty! No matching tasks." ))[:100], value=partial ) ] labels = [] idmap = {} for label, task in tasklist.labelled.items(): labelstring = '.'.join(map(str, label)) + '.' * (len(label) == 1) remaining_width = 100 - len(labelstring) - 1 if len(task.content) > remaining_width: content = task.content[:remaining_width - 3] + '...' else: content = task.content taskstring = f"{labelstring} {content}" idmap[task.taskid] = labelstring labels.append((labelstring, taskstring)) # Assume user is typing a label matching = [(label, task) for label, task in labels if label.startswith(partial)] # If partial does match any labels, search for partial in task content if not matching: matching = [(label, task) for label, task in labels if partial.lower() in task.lower()] if matching: # If matches were found, assume user wants one of the matches options = [ appcmds.Choice(name=task_string[:100], value=label) for label, task_string in matching ] elif multi and partial.lower().strip() in ('-', 'all'): options = [ appcmds.Choice( name=t(_p( 'argtype:taskid|match:all', "All tasks" ))[:100], value='-' ) ] elif multi and (',' in partial or '-' in partial): # Try parsing input as a multi-list try: parsed = tasklist.parse_labels(partial) multi_name = ', '.join(idmap[tid] for tid in parsed) if len(multi_name) > 100: multi_name = multi_name[:96] multi_name, _ = multi_name.rsplit(',', maxsplit=1) multi_name = multi_name + ', ...' except UserInputError as e: parsed = [] error = t(_p( 'argtype:taskid|error:parse_multi', "(Warning: {error})" )).format( error=e.msg ) remaining = 100 - len(error) multi_name = f"{partial[:remaining-1]} {error}" multi_option = appcmds.Choice( name=multi_name[:100], value=partial ) options = [multi_option] # Regardless of parse status, show matches with last split, if they exist. if ',' in partial: _, last_split = partial.rsplit(',', maxsplit=1) else: last_split = partial if '-' in last_split: _, last_split = last_split.rsplit('-', maxsplit=1) last_split = last_split.strip(' ') else: last_split = last_split.strip(' ') matching = [(label, task) for label, task in labels if label.startswith(last_split)] if not matching: matching = [(label, task) for label, task in labels if last_split.lower() in task.lower()] options.extend( appcmds.Choice(name=task_string[:100], value=label) for label, task_string in matching ) else: options = [ appcmds.Choice( name=t(_p( 'argtype:taskid|error:no_matching', "No tasks matching '{partial}'!", )).format(partial=partial[:100])[:100], value=partial ) ] return options[:25] async def task_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: """ Shared autocomplete for single task parameters. """ return await self._task_acmpl(interaction.user.id, partial, multi=False) async def tasks_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: """ Shared autocomplete for multiple task parameters. """ return await self._task_acmpl(interaction.user.id, partial, multi=True) @tasklist_group.command( name=_p('cmd:tasks_new', "new"), description=_p( 'cmd:tasks_new|desc', "Add a new task to your tasklist." ) ) @appcmds.rename( content=_p('cmd:tasks_new|param:content', "task"), parent=_p('cmd:tasks_new|param:parent', 'parent') ) @appcmds.describe( content=_p('cmd:tasks_new|param:content|desc', "Content of your new task."), parent=_p('cmd:tasks_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:tasks_new|error:parse_parent', "Could not find parent task number `{input}` in your tasklist." )).format(input=parent) ), ) return # Create task task = await tasklist.create_task(content, parentid=pid) # Ack creation label = tasklist.labelid(task.taskid) embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_p( 'cmd:tasks_new|resp:success', "{tick} Created task `{label}`." )).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(label)) ) await ctx.interaction.edit_original_response( embed=embed, view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) ) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) tasklist_new_cmd.autocomplete('parent')(task_acmpl) @tasklist_group.command( name=_p('cmd:tasks_upload', "upload"), description=_p( 'cmd:tasks_upload|desc', "Upload a list of tasks to append to or replace your tasklist." ) ) @appcmds.rename( tasklist_file=_p('cmd:tasks_upload|param:tasklist', "tasklist"), append=_p('cmd:tasks_upload|param:append', "append") ) @appcmds.describe( tasklist_file=_p( 'cmd:tasks_upload|param:tasklist|desc', "Text file containing a (standard markdown formatted) checklist of tasks to add or append." ), append=_p( 'cmd:tasks_upload|param:append|desc', "Whether to append the given tasks or replace your entire tasklist. Defaults to True." ) ) async def tasklist_upload_cmd(self, ctx: LionContext, tasklist_file: discord.Attachment, append: bool = True): t = self.bot.translator.t if not ctx.interaction: return error = None if tasklist_file.content_type and not tasklist_file.content_type.startswith('text'): # Not a text file error = t(_p( 'cmd:tasks_upload|error:not_text', "The attached tasklist must be a text file!" )) raise UserInputError(error) if tasklist_file.size > 1000000: # Too large error = t(_p( 'cmd:tasks_upload|error:too_large', "The attached tasklist was too large!" )) raise UserInputError(error) await ctx.interaction.response.defer(thinking=True, ephemeral=True) try: content = (await tasklist_file.read()).decode(encoding='UTF-8') lines = content.splitlines() if len(lines) > 1000: error = t(_p( 'cmd:tasks_upload|error:too_many_lines', "Too many tasks! Refusing to process a tasklist with more than `1000` lines." )) raise UserInputError(error) except UnicodeDecodeError: error = t(_p( 'cmd:tasks_upload|error:decoding', "Could not decode attached tasklist. Please make sure it is saved with the `UTF-8` encoding." )) raise UserInputError(error) # Contents successfully parsed, update the tasklist. tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) taskinfo = tasklist.parse_tasklist(lines) now = utc_now() # Delete tasklist if required if not append: await tasklist.update_tasklist(deleted_at=now) # Create tasklist # TODO: Refactor into common method with parse 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] = ( tasklist.userid, content, created[parent] if parent is not None else None, now if ticked else None ) if to_insert: # Batch insert tasks = await 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 # Ack modifications embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_p( 'cmd:tasks_upload|resp:success', "{tick} Updated your tasklist.", )).format( tick=self.bot.config.emojis.tick, ) ) await ctx.interaction.edit_original_response( embed=embed, view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) ) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) @tasklist_group.command( name=_p('cmd:tasks_edit', "edit"), description=_p( 'cmd:tasks_edit|desc', "Edit a task in your tasklist." ) ) @appcmds.rename( taskstr=_p('cmd:tasks_edit|param:taskstr', "task"), new_content=_p('cmd:tasks_edit|param:new_content', "new_task"), new_parent=_p('cmd:tasks_edit|param:new_parent', "new_parent"), ) @appcmds.describe( taskstr=_p('cmd:tasks_edit|param:taskstr|desc', "Which task do you want to update?"), new_content=_p('cmd:tasks_edit|param:new_content|desc', "What do you want to change the task to?"), new_parent=_p('cmd:tasks_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:tasks_edit|error:parse_taskstr', "Could not find target 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:tasks_edit|error:parse_parent', "Could not find new parent 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) embed = discord.Embed( colour=discord.Color.brand_green(), description=t(_p( 'cmd:tasks_edit|resp:success|desc', "{tick} Task `{label}` updated." )).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(tasklist.labelid(tid))), ) await interaction.response.send_message( embed=embed, view=( discord.utils.MISSING if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) ), ephemeral=True ) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) if new_content or new_parent: # Manual edit route await handle_update(ctx.interaction, new_content, new_parent) 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) await ctx.interaction.response.send_modal(editor) tasklist_edit_cmd.autocomplete('taskstr')(task_acmpl) tasklist_edit_cmd.autocomplete('new_parent')(task_acmpl) @tasklist_group.command( name=_p('cmd:tasks_clear', "clear"), description=_p('cmd:tasks_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:tasks_clear|resp:success', "Your tasklist has been cleared." )), view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot), ephemeral=True ) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) @tasklist_group.command( name=_p('cmd:tasks_remove', "remove"), description=_p( 'cmd:tasks_remove|desc', "Remove tasks matching all the provided conditions. (E.g. remove tasks completed before today)." ) ) @appcmds.rename( taskidstr=_p('cmd:tasks_remove|param:taskidstr', "tasks"), created_before=_p('cmd:tasks_remove|param:created_before', "created_before"), updated_before=_p('cmd:tasks_remove|param:updated_before', "updated_before"), completed=_p('cmd:tasks_remove|param:completed', "completed"), cascade=_p('cmd:tasks_remove|param:cascade', "cascade") ) @appcmds.describe( taskidstr=_p( 'cmd:tasks_remove|param:taskidstr|desc', "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-), or `-` to remove all." ), created_before=_p( 'cmd:tasks_remove|param:created_before|desc', "Only delete tasks created before the selected time." ), updated_before=_p( 'cmd:tasks_remove|param:updated_before|desc', "Only deleted tasks update (i.e. completed or edited) before the selected time." ), completed=_p( 'cmd:tasks_remove|param:completed', "Only delete tasks which are (not) complete." ), cascade=_p( 'cmd:tasks_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(t(_p( 'cmd:tasks_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: timezone = ctx.alion.timezone 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(t(_p( 'cmd:tasks_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] label = tasklist.format_label(tasklist.labelid(taskids[0])) await tasklist.update_tasks(*taskids, cascade=cascade, deleted_at=utc_now()) # Ack changes and summon tasklist embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_np( 'cmd:tasks_remove|resp:success', "{tick} Deleted task `{label}`", "{tick} Deleted `{count}` tasks from your tasklist.", len(taskids) )).format( tick=self.bot.config.emojis.tick, label=label, count=len(taskids) ) ) await ctx.interaction.edit_original_response( embed=embed, view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) ) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) tasklist_remove_cmd.autocomplete('taskidstr')(tasks_acmpl) @tasklist_group.command( name=_p('cmd:tasks_tick', "tick"), description=_p('cmd:tasks_tick|desc', "Mark the given tasks as completed.") ) @appcmds.rename( taskidstr=_p('cmd:tasks_tick|param:taskidstr', "tasks"), cascade=_p('cmd:tasks_tick|param:cascade', "cascade") ) @appcmds.describe( taskidstr=_p( 'cmd:tasks_tick|param:taskidstr|desc', "List of task numbers or ranges to tick (e.g. 1, 2, 5-7, 8.1-3, 9-) or '-' to tick all." ), cascade=_p( 'cmd:tasks_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(t(_p( 'cmd:tasks_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 and summon tasklist embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_np( 'cmd:tasks_tick|resp:success', "{tick} Marked `{label}` as complete.", "{tick} Marked `{count}` tasks as complete.", len(taskids) )).format( tick=self.bot.config.emojis.tick, count=len(taskids), label=tasklist.format_label(tasklist.labelid(taskids[0])) if taskids else '-' ) ) await ctx.interaction.edit_original_response( embed=embed, view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) ) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) tasklist_tick_cmd.autocomplete('taskidstr')(tasks_acmpl) @tasklist_group.command( name=_p('cmd:tasks_untick', "untick"), description=_p('cmd:tasks_untick|desc', "Mark the given tasks as incomplete.") ) @appcmds.rename( taskidstr=_p('cmd:tasks_untick|param:taskidstr', "taskids"), cascade=_p('cmd:tasks_untick|param:cascade', "cascade") ) @appcmds.describe( taskidstr=_p( 'cmd:tasks_untick|param:taskidstr|desc', "List of task numbers or ranges to untick (e.g. 1, 2, 5-7, 8.1-3, 9-) or '-' to untick all." ), cascade=_p( 'cmd:tasks_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(t(_p( 'cmd:tasks_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 or False, completed_at=None) # Ack changes and summon tasklist embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_np( 'cmd:tasks_untick|resp:success', "{tick} Marked `{label}` as incomplete.", "{tick} Marked `{count}` tasks as incomplete.", len(taskids) )).format( tick=self.bot.config.emojis.tick, count=len(taskids), label=tasklist.format_label(tasklist.labelid(taskids[0])) if taskids else '-' ) ) await ctx.interaction.edit_original_response( embed=embed, view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) ) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) tasklist_untick_cmd.autocomplete('taskidstr')(tasks_acmpl) # 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', "Tasklist 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.default_permissions(manage_guild=True) @low_management_ward 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, ctx.guild.id, ctx.channel.id) await configui.run(ctx.interaction) await configui.wait()