From a1cd8576f2264120cb7ffde0d96c006527cebb5d Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Sun, 24 Sep 2023 23:44:07 +1300 Subject: [PATCH 01/31] Fix TypeError in `room rent` - Fixed TypeError occurring when attempting to add an unknown member whilst in the `room rent` interaction --- src/modules/rooms/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/rooms/cog.py b/src/modules/rooms/cog.py index 9adc4f91..e4543cbd 100644 --- a/src/modules/rooms/cog.py +++ b/src/modules/rooms/cog.py @@ -412,7 +412,7 @@ class RoomCog(LionCog): t(_p( 'cmd:room_rent|error:member_not_found', "Could not find the requested member {mention} in this server!" - )).format(member=f"<@{mid}>") + )).format(mention=f"<@{mid}>") ), ephemeral=True ) return From 0fe7610289b58a19cb31f3de2125b4eb839ba029 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 25 Sep 2023 20:23:47 +0300 Subject: [PATCH 02/31] ux(msgeditor): Add explicit remove embed button. --- src/utils/ui/msgeditor.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/utils/ui/msgeditor.py b/src/utils/ui/msgeditor.py index c22ee60f..5e1cca2f 100644 --- a/src/utils/ui/msgeditor.py +++ b/src/utils/ui/msgeditor.py @@ -174,6 +174,31 @@ class MsgEditor(MessageUI): "Add Embed" )) + @button(label="RM_EMBED_BUTTON_PLACEHOLDER", style=ButtonStyle.red) + async def rm_embed_button(self, press: discord.Interaction, pressed: Button): + """ + Remove the existing embed from the message. + """ + await press.response.defer() + t = self.bot.translator.t + data = self.copy_data() + data.pop('embed', None) + data.pop('embeds', None) + if not data.get('content', '').strip(): + data['content'] = t(_p( + 'ui:msg_editor|button:rm_embed|sample_content', + "Content Placeholder" + )) + await self.push_change(data) + + async def rm_embed_button_refresh(self): + t = self.bot.translator.t + button = self.rm_embed_button + button.label = t(_p( + 'ui:msg_editor|button:rm_embed|label', + "Remove Embed" + )) + # -- Embed Mode -- @button(label="BODY_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) @@ -927,7 +952,7 @@ class MsgEditor(MessageUI): return MessageArgs(**args) async def refresh_layout(self): - await asyncio.gather( + to_refresh = ( self.edit_button_refresh(), self.add_embed_button_refresh(), self.body_button_refresh(), @@ -941,12 +966,16 @@ class MsgEditor(MessageUI): self.download_button_refresh(), self.undo_button_refresh(), self.redo_button_refresh(), + self.rm_embed_button_refresh(), ) + await asyncio.gather(*to_refresh) + if self.history[-1].get('embed', None): self.set_layout( (self.body_button, self.author_button, self.footer_button, self.images_button, self.add_field_button), (self.edit_field_menu,), (self.delete_field_menu,), + (self.rm_embed_button,), (self.save_button, self.download_button, self.undo_button, self.redo_button, self.quit_button), ) else: From 7867bdee5078817686d3b015319b8ed75dd6c7bd Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 25 Sep 2023 20:29:26 +0300 Subject: [PATCH 03/31] fix(rmenus): Add guild filter for name check. --- src/modules/rolemenus/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index 3618b4fd..0fdc944d 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -732,7 +732,7 @@ class RoleMenuCog(LionCog): # Parse menu options if given name = name.strip() - matching = await self.data.RoleMenu.fetch_where(name=name) + matching = await self.data.RoleMenu.fetch_where(name=name, guildid=ctx.guild.id) if matching: raise UserInputError( t(_p( From f16fddb635b0bc157d45789ee39f495ff5bbf6e4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Sep 2023 06:57:35 +0300 Subject: [PATCH 04/31] ux(rmenus): Add auto-swap where possible. --- src/modules/economy/data.py | 3 + src/modules/rolemenus/rolemenu.py | 440 +++++++++++++++++------------- 2 files changed, 246 insertions(+), 197 deletions(-) diff --git a/src/modules/economy/data.py b/src/modules/economy/data.py index da4a62df..3e6f09db 100644 --- a/src/modules/economy/data.py +++ b/src/modules/economy/data.py @@ -209,6 +209,9 @@ class EconomyData(Registry, name='economy'): ] # Execute refund transactions return await cls.execute_transactions(*records) + else: + return [] + class ShopTransaction(RowModel): """ diff --git a/src/modules/rolemenus/rolemenu.py b/src/modules/rolemenus/rolemenu.py index fcaafdcf..fc8d4fee 100644 --- a/src/modules/rolemenus/rolemenu.py +++ b/src/modules/rolemenus/rolemenu.py @@ -25,7 +25,7 @@ from . import logger, babel if TYPE_CHECKING: from .cog import RoleMenuCog -_p = babel._p +_p, _np = babel._p, babel._np MISSING = object() @@ -454,100 +454,252 @@ class RoleMenu: if emojikey(emoji) not in menu_emojis: yield str(emoji) - async def _handle_selection(self, lion, member: discord.Member, menuroleid: int): - mrole = self.rolemap.get(menuroleid, None) - if mrole is None: - raise ValueError(f"Attempt to process event for invalid menuroleid {menuroleid}, THIS SHOULD NOT HAPPEN.") - - guild = member.guild - + async def _handle_positive(self, lion, member: discord.Member, mrole: RoleMenuRole) -> discord.Embed: t = self.bot.translator.t - + guild = member.guild role = guild.get_role(mrole.data.roleid) - if role is None: - # This role no longer exists, nothing we can do + if not role: + raise ValueError("Calling _handle_positive without a valid role.") + + price = mrole.config.price.value + + obtainable = self.config.obtainable.value + remove_line = '' + if obtainable is not None: + # Check shared roles + menu_roles = {mrole.data.roleid: mrole for mrole in self.roles} + common = [role for role in member.roles if role.id in menu_roles] + + if len(common) >= obtainable: + swap = None + if len(common) == 1 and not self.config.sticky.value: + swap = menu_roles[common[0].id] + # Check if LC will be lost by exchanging the role + if (swap.config.price.value) > 0 and not self.config.refunds.value: + swap = None + if swap is not None: + # Do remove + try: + remove_embed = await self._handle_negative(lion, member, swap) + remove_line = remove_embed.description + except UserInputError: + # If we failed to remove for some reason, pretend we didn't try + swap = None + + if swap is None: + error = t(_np( + 'rolemenu|select|error:max_obtainable', + "You can own at most one role from this menu! You currently own:", + "You can own at most **{count}** roles from this menu! You currently own:", + obtainable + )).format(count=obtainable) + error = '\n'.join((error, *(role.mention for role in common))) + raise UserInputError(error) + + + if price: + # Check member balance + # TODO: More transaction safe (or rather check again after transaction) + await lion.data.refresh() + balance = lion.data.coins + if balance < price: + raise UserInputError( + t(_p( + 'rolemenu|select|error:insufficient_funds', + "The role **{role}** costs {coin}**{cost}**," + "but you only have {coin}**{balance}**!" + )).format( + role=role.name, + coin=self.bot.config.emojis.coin, + cost=price, + balance=balance, + ) + ) + + try: + await member.add_roles(role) + except discord.Forbidden: raise UserInputError( t(_p( - 'rolemenu|error:role_gone', - "This role no longer exists!" + 'rolemenu|select|error:perms', + "I don't have enough permissions to give you this role!" )) ) - if role in member.roles: - # Member already has the role, deselection case. - if self.config.sticky.value: - # Cannot deselect - raise UserInputError( - t(_p( - 'rolemenu|deselect|error:sticky', - "**{role}** is a sticky role, you cannot remove it with this menu!" - )).format(role=role.name) - ) - - # Remove the role - try: - await member.remove_roles(role) - except discord.Forbidden: - raise UserInputError( - t(_p( - 'rolemenu|deselect|error:perms', - "I don't have enough permissions to remove this role from you!" - )) - ) - except discord.HTTPException: - raise UserInputError( - t(_p( - 'rolemenu|deselect|error:discord', - "An unknown error occurred removing your role! Please try again later." - )) - ) - - # Update history - now = utc_now() - history = await self.cog.data.RoleMenuHistory.table.update_where( - menuid=self.data.menuid, - roleid=role.id, - userid=member.id, - removed_at=None, - ).set(removed_at=now) - await self.cog.cancel_expiring_tasks(*(row['equipid'] for row in history)) - - # Refund if required - transactionids = [row['transactionid'] for row in history] - if self.config.refunds.value and any(transactionids): - transactionids = [tid for tid in transactionids if tid] - economy: Economy = self.bot.get_cog('Economy') - refunded = await economy.data.Transaction.refund_transactions(*transactionids) - total_refund = sum(row.amount + row.bonus for row in refunded) - else: - total_refund = 0 - - # Ack the removal - embed = discord.Embed( - colour=discord.Colour.brand_green(), - title=t(_p( - 'rolemenu|deslect|success|title', - "Role removed" + except discord.HTTPException: + raise UserInputError( + t(_p( + 'rolemenu|select|error:discord', + "An unknown error occurred while assigning your role! " + "Please try again later." )) ) - if total_refund > 0: - embed.description = t(_p( - 'rolemenu|deselect|success:refund|desc', - "You have removed **{role}**, and been refunded {coin} **{amount}**." - )).format(role=role.name, coin=self.bot.config.emojis.coin, amount=total_refund) - if total_refund < 0: - # TODO: Consider disallowing them from removing roles if their balance would go negative - embed.description = t(_p( - 'rolemenu|deselect|success:negrefund|desc', - "You have removed **{role}**, and have lost {coin} **{amount}**." - )).format(role=role.name, coin=self.bot.config.emojis.coin, amount=-total_refund) - else: - embed.description = t(_p( - 'rolemenu|deselect|success:norefund|desc', - "You have unequipped **{role}**." - )).format(role=role.name) - return embed + + now = utc_now() + + # Create transaction if applicable + if price: + economy: Economy = self.bot.get_cog('Economy') + tx = await economy.data.Transaction.execute_transaction( + transaction_type=TransactionType.OTHER, + guildid=guild.id, actorid=member.id, + from_account=member.id, to_account=None, + amount=price + ) + tid = tx.transactionid else: - # Member does not have the role, selection case. + tid = None + + # Calculate expiry + duration = mrole.config.duration.value + if duration is not None: + expiry = now + dt.timedelta(seconds=duration) + else: + expiry = None + + # Add to equip history + equip = await self.cog.data.RoleMenuHistory.create( + menuid=self.data.menuid, roleid=role.id, + userid=member.id, + obtained_at=now, + transactionid=tid, + expires_at=expiry + ) + await self.cog.schedule_expiring(equip) + + # Ack the selection + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'rolemenu|select|success|title', + "Role equipped" + )) + ) + if price: + embed.description = t(_p( + 'rolemenu|select|success:purchase|desc', + "You have purchased the role **{role}** for {coin}**{amount}**" + )).format(role=role.name, coin=self.bot.config.emojis.coin, amount=price) + else: + embed.description = t(_p( + 'rolemenu|select|success:nopurchase|desc', + "You have equipped **{role}**" + )).format(role=role.name) + + if expiry is not None: + embed.description += '\n' + t(_p( + 'rolemenu|select|expires_at', + "The role will expire at {timestamp}." + )).format( + timestamp=discord.utils.format_dt(expiry) + ) + if remove_line: + embed.description = '\n'.join((remove_line, embed.description)) + + # TODO Event logging + return embed + + async def _handle_negative(self, lion, member: discord.Member, mrole: RoleMenuRole) -> discord.Embed: + t = self.bot.translator.t + guild = member.guild + role = guild.get_role(mrole.data.roleid) + if not role: + raise ValueError("Calling _handle_negative without a valid role.") + + if self.config.sticky.value: + # Cannot deselect + raise UserInputError( + t(_p( + 'rolemenu|deselect|error:sticky', + "**{role}** is a sticky role, you cannot remove it with this menu!" + )).format(role=role.name) + ) + + # Remove the role + try: + await member.remove_roles(role) + except discord.Forbidden: + raise UserInputError( + t(_p( + 'rolemenu|deselect|error:perms', + "I don't have enough permissions to remove this role from you!" + )) + ) + except discord.HTTPException: + raise UserInputError( + t(_p( + 'rolemenu|deselect|error:discord', + "An unknown error occurred removing your role! Please try again later." + )) + ) + + # Update history + now = utc_now() + history = await self.cog.data.RoleMenuHistory.table.update_where( + menuid=self.data.menuid, + roleid=role.id, + userid=member.id, + removed_at=None, + ).set(removed_at=now) + await self.cog.cancel_expiring_tasks(*(row['equipid'] for row in history)) + + # Refund if required + transactionids = [row['transactionid'] for row in history] + if self.config.refunds.value and any(transactionids): + transactionids = [tid for tid in transactionids if tid] + economy: Economy = self.bot.get_cog('Economy') + refunded = await economy.data.Transaction.refund_transactions(*transactionids) + total_refund = sum(row.amount + row.bonus for row in refunded) + else: + total_refund = 0 + + # Ack the removal + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'rolemenu|deslect|success|title', + "Role removed" + )) + ) + if total_refund > 0: + embed.description = t(_p( + 'rolemenu|deselect|success:refund|desc', + "You have removed **{role}**, and been refunded {coin} **{amount}**." + )).format(role=role.name, coin=self.bot.config.emojis.coin, amount=total_refund) + if total_refund < 0: + # TODO: Consider disallowing them from removing roles if their balance would go negative + embed.description = t(_p( + 'rolemenu|deselect|success:negrefund|desc', + "You have removed **{role}**, and have lost {coin} **{amount}**." + )).format(role=role.name, coin=self.bot.config.emojis.coin, amount=-total_refund) + else: + embed.description = t(_p( + 'rolemenu|deselect|success:norefund|desc', + "You have unequipped **{role}**." + )).format(role=role.name) + return embed + + async def _handle_selection(self, lion, member: discord.Member, menuroleid: int): + lock_key = ('rmenu', member.id, member.guild.id) + async with self.bot.idlock(lock_key): + # TODO: Selection locking + mrole = self.rolemap.get(menuroleid, None) + if mrole is None: + raise ValueError( + f"Attempt to process event for invalid menuroleid {menuroleid}, THIS SHOULD NOT HAPPEN." + ) + + guild = member.guild + t = self.bot.translator.t + role = guild.get_role(mrole.data.roleid) + if role is None: + # This role no longer exists, nothing we can do + raise UserInputError( + t(_p( + 'rolemenu|error:role_gone', + "The role **{name}** no longer exists!" + )).format(name=mrole.data.label) + ) + required = self.config.required_role.data if required is not None: # Check member has the required role @@ -561,118 +713,12 @@ class RoleMenu: )).format(role=name) ) - obtainable = self.config.obtainable.value - if obtainable is not None: - # Check shared roles - menu_roleids = {mrole.data.roleid for mrole in self.roles} - member_roleids = {role.id for role in member.roles} - common = len(menu_roleids.intersection(member_roleids)) - if common >= obtainable: - raise UserInputError( - t(_p( - 'rolemenu|select|error:max_obtainable', - "You already have the maximum of {obtainable} roles from this menu!" - )).format(obtainable=obtainable) - ) - - price = mrole.config.price.value - if price: - # Check member balance - # TODO: More transaction safe (or rather check again after transaction) - await lion.data.refresh() - balance = lion.data.coins - if balance < price: - raise UserInputError( - t(_p( - 'rolemenu|select|error:insufficient_funds', - "The role **{role}** costs {coin}**{cost}**," - "but you only have {coin}**{balance}**!" - )).format( - role=role.name, - coin=self.bot.config.emojis.coin, - cost=price, - balance=balance, - ) - ) - - try: - await member.add_roles(role) - except discord.Forbidden: - raise UserInputError( - t(_p( - 'rolemenu|select|error:perms', - "I don't have enough permissions to give you this role!" - )) - ) - except discord.HTTPException: - raise UserInputError( - t(_p( - 'rolemenu|select|error:discord', - "An unknown error occurred while assigning your role! " - "Please try again later." - )) - ) - - now = utc_now() - - # Create transaction if applicable - if price: - economy: Economy = self.bot.get_cog('Economy') - tx = await economy.data.Transaction.execute_transaction( - transaction_type=TransactionType.OTHER, - guildid=guild.id, actorid=member.id, - from_account=member.id, to_account=None, - amount=price - ) - tid = tx.transactionid + if role in member.roles: + # Member already has the role, deselection case. + return await self._handle_negative(lion, member, mrole) else: - tid = None - - # Calculate expiry - duration = mrole.config.duration.value - if duration is not None: - expiry = now + dt.timedelta(seconds=duration) - else: - expiry = None - - # Add to equip history - equip = await self.cog.data.RoleMenuHistory.create( - menuid=self.data.menuid, roleid=role.id, - userid=member.id, - obtained_at=now, - transactionid=tid, - expires_at=expiry - ) - await self.cog.schedule_expiring(equip) - - # Ack the selection - embed = discord.Embed( - colour=discord.Colour.brand_green(), - title=t(_p( - 'rolemenu|select|success|title', - "Role equipped" - )) - ) - if price: - embed.description = t(_p( - 'rolemenu|select|success:purchase|desc', - "You have purchased the role **{role}** for {coin}**{amount}**" - )).format(role=role.name, coin=self.bot.config.emojis.coin, amount=price) - else: - embed.description = t(_p( - 'rolemenu|select|success:nopurchase|desc', - "You have equipped the role **{role}**" - )).format(role=role.name) - - if expiry is not None: - embed.description += '\n' + t(_p( - 'rolemenu|select|expires_at', - "The role will expire at {timestamp}." - )).format( - timestamp=discord.utils.format_dt(expiry) - ) - # TODO Event logging - return embed + # Member does not have the role, selection case. + return await self._handle_positive(lion, member, mrole) async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int): """ From a58ce6264e4f16f25c2411f502a16bf02eb66aa2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 28 Sep 2023 11:50:51 +0300 Subject: [PATCH 05/31] chore: Update gitignore. --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index f2747446..d0cda90b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,14 @@ src/modules/test/* +pending-rewrite/ +logs/* +notes/* +tmp/* +output/* +locales/domains + +.idea/* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From 9de449a4fc47793f50f354556267722e67b950e3 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 28 Sep 2023 11:51:31 +0300 Subject: [PATCH 06/31] fix(babel): Standardise locale format. --- src/babel/translator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/babel/translator.py b/src/babel/translator.py index 6e5fe904..fa832383 100644 --- a/src/babel/translator.py +++ b/src/babel/translator.py @@ -11,7 +11,7 @@ from discord.enums import Locale logger = logging.getLogger(__name__) -SOURCE_LOCALE = 'en-GB' +SOURCE_LOCALE = 'en_GB' ctx_locale: ContextVar[str] = ContextVar('locale', default=SOURCE_LOCALE) ctx_translator: ContextVar['LeoBabel'] = ContextVar('translator', default=None) # type: ignore @@ -71,6 +71,7 @@ class LeoBabel(Translator): self.translators.clear() def get_translator(self, locale, domain): + locale = locale.replace('-', '_') if locale else None if locale == SOURCE_LOCALE: translator = null elif locale in self.supported_locales and domain in self.supported_domains: @@ -93,7 +94,8 @@ class LeoBabel(Translator): return lazystr._translate_with(translator) async def translate(self, string: locale_str, locale: Locale, context): - if locale.value in self.supported_locales: + loc = locale.value.replace('-', '_') + if loc in self.supported_locales: domain = string.extras.get('domain', None) if domain is None and isinstance(string, LazyStr): logger.debug( @@ -101,7 +103,7 @@ class LeoBabel(Translator): ) return None - translator = self.get_translator(locale.value, domain) + translator = self.get_translator(loc, domain) if not isinstance(string, LazyStr): lazy = LazyStr(Method.GETTEXT, string.message) else: From daa215d10ce850642ded2fc3c05a286029813ce1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 28 Sep 2023 11:52:59 +0300 Subject: [PATCH 07/31] (utils): Add cmd length calculator. --- src/utils/lib.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/utils/lib.py b/src/utils/lib.py index 1bec50cc..f084dde9 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -4,6 +4,7 @@ import datetime import iso8601 # type: ignore import pytz import re +import json from contextvars import Context import discord @@ -829,3 +830,60 @@ async def check_dm(user: discord.User | discord.Member) -> bool: return False except discord.HTTPException: return True + + +async def command_lengths(tree) -> dict[str, int]: + cmds = tree.get_commands() + payloads = [ + await cmd.get_translated_payload(tree.translator) + for cmd in cmds + ] + lens = {} + for command in payloads: + name = command['name'] + crumbs = {} + cmd_len = lens[name] = _recurse_length(command, crumbs, (name,)) + if name == 'configure' or cmd_len > 4000: + print(f"'{name}' over 4000. Breadcrumb Trail follows:") + lines = [] + for loc, val in crumbs.items(): + locstr = '.'.join(loc) + lines.append(f"{locstr}: {val}") + print('\n'.join(lines)) + print(json.dumps(command, indent=2)) + return lens + +def _recurse_length(payload, breadcrumbs={}, header=()) -> int: + total = 0 + total_header = (*header, '') + breadcrumbs[total_header] = 0 + + if isinstance(payload, dict): + # Read strings that count towards command length + # String length is length of longest localisation, including default. + for key in ('name', 'description', 'value'): + if key in payload: + value = payload[key] + if isinstance(value, str): + values = (value, *payload.get(key + '_localizations', {}).values()) + maxlen = max(map(len, values)) + total += maxlen + breadcrumbs[(*header, key)] = maxlen + + for key, value in payload.items(): + loc = (*header, key) + total += _recurse_length(value, breadcrumbs, loc) + elif isinstance(payload, list): + for i, item in enumerate(payload): + if isinstance(item, dict) and 'name' in item: + loc = (*header, f"{i}<{item['name']}>") + else: + loc = (*header, str(i)) + total += _recurse_length(item, breadcrumbs, loc) + + if total: + breadcrumbs[total_header] = total + else: + breadcrumbs.pop(total_header) + + return total From 3c7e1f646b080a38231cca6485527b6eb3f15eba Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 28 Sep 2023 11:53:46 +0300 Subject: [PATCH 08/31] chore: Add voice to requirements. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a34bcc40..011dd9dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ aiohttp==3.7.4.post0 cachetools==4.2.2 configparser==5.0.2 -discord.py +discord.py [voice] iso8601==0.1.16 psycopg[pool] pytz==2021.1 From 5f93e13469d36d25d0f07b549d2c1ef3c6cd57cb Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 29 Sep 2023 15:21:01 +0300 Subject: [PATCH 09/31] fix(rmenus): Implement editmenu style pathway. --- src/modules/rolemenus/cog.py | 41 +++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index 0fdc944d..588a45a2 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -971,7 +971,41 @@ class RoleMenuCog(LionCog): ) # TODO: Generate the custom message from the template if it doesn't exist - # TODO: Pathway for setting menu style + if menu_style is not None: + if not managed and not reposting: + raise UserInputError( + t(_p( + 'cmd:rolemenu_edit|parse:style|error:not_managed', + "Cannot change the style of a role menu attached to a message I did not send." + )) + ) + if menu_style is MenuType.REACTION: + # Check menu is suitable for moving to reactions + roles = target.roles + if len(roles) > 20: + raise UserInputError( + t(_p( + 'cmd:rolemenu_edit|parse:style|error:too_many_reactions', + "Too many roles! Reaction role menus can have at most `20` roles." + )) + ) + emojis = [mrole.config.emoji.value for mrole in roles] + emojis = [emoji for emoji in emojis if emoji] + uniq = set(emojis) + if len(uniq) != len(roles): + raise UserInputError( + t(_p( + "cmd:rolemenu_edit|parse:style|error:incomplete_emojis", + "Cannot switch to the reaction role style! Every role needs a distinct emoji first." + )) + ) + update_args[self.data.RoleMenu.menutype.name] = menu_style + ack_lines.append( + t(_p( + 'cmd:rolemenu_edit|parse:style|success', + "Updated role menu style." + )) + ) if rawmessage is not None: msg_config = target.config.rawmessage @@ -1019,6 +1053,11 @@ class RoleMenuCog(LionCog): )).format(channel=channel.mention, exception=e.text)) else: await target.update_message() + if menu_style is not None: + try: + await target.update_reactons() + except SafeCancellation as e: + error_lines.append(e.msg) # Ack the updates if ack_lines or error_lines: From eda2b929676b4952705b812b1d713f0e5c0288ac Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 29 Sep 2023 16:13:24 +0300 Subject: [PATCH 10/31] fix(rmenus): Update msg data from origin message. --- src/modules/rolemenus/cog.py | 2 ++ src/modules/rolemenus/rolemenu.py | 14 ++++++++++++++ src/modules/rolemenus/ui/menueditor.py | 1 + 3 files changed, 17 insertions(+) diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index 588a45a2..9efef99a 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -126,6 +126,7 @@ async def rolemenu_ctxcmd(interaction: discord.Interaction, message: discord.Mes else: menu = await RoleMenu.fetch(self.bot, menuid) menu._message = message + await menu.update_raw() # Open the editor editor = MenuEditor(self.bot, menu, callerid=interaction.user.id) @@ -895,6 +896,7 @@ class RoleMenuCog(LionCog): )).format(name=name) ) await target.fetch_message() + await target.update_raw() # Parse provided options reposting = channel is not None diff --git a/src/modules/rolemenus/rolemenu.py b/src/modules/rolemenus/rolemenu.py index fc8d4fee..bc4437af 100644 --- a/src/modules/rolemenus/rolemenu.py +++ b/src/modules/rolemenus/rolemenu.py @@ -192,6 +192,20 @@ class RoleMenu: self._message = _message return self._message + async def update_raw(self): + """ + Updates the saved raw message data for non-owned menus. + """ + message = await self.fetch_message() + if not self.managed and message is not None: + message_data = {} + message_data['content'] = message.content + if message.embeds: + message_data['embed'] = message.embeds[0].to_dict() + rawmessage = json.dumps(message_data) + if rawmessage != self.data.rawmessage: + await self.data.update(rawmessage=rawmessage) + def emoji_map(self): emoji_map = {} for mrole in self.roles: diff --git a/src/modules/rolemenus/ui/menueditor.py b/src/modules/rolemenus/ui/menueditor.py index a4c5f55f..0b8de19e 100644 --- a/src/modules/rolemenus/ui/menueditor.py +++ b/src/modules/rolemenus/ui/menueditor.py @@ -1144,3 +1144,4 @@ class MenuEditor(MessageUI): self.pagen = self.pagen % self.page_count self.page_block = blocks[self.pagen] await self.menu.fetch_message() + await self.menu.update_raw() From ff7c88dc8cae7563a909dd928fdcfe5c83c534bb Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 29 Sep 2023 16:22:16 +0300 Subject: [PATCH 11/31] fix(schedule): Fix typo in blacklist. --- src/modules/schedule/cog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index 39b60960..93775082 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -415,7 +415,10 @@ class ScheduleCog(LionCog): tasks = [] for (gid, uid), member in to_blacklist.items(): role = autoblacklisting[gid][1] - task = asyncio.create_task(member.add_role(role)) + task = asyncio.create_task(member.add_roles( + role, + reason="Automatic scheduled session blacklist" + )) tasks.append(task) # TODO: Logging and some error handling await asyncio.gather(*tasks, return_exceptions=True) From ca963ee8b1a8d236f10de7f5f56c8ddae820d117 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 29 Sep 2023 16:31:35 +0300 Subject: [PATCH 12/31] fix(schedule): Fix 'min_attendance' not saving. --- src/modules/schedule/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/schedule/settings.py b/src/modules/schedule/settings.py index 8bb022d0..3faebea8 100644 --- a/src/modules/schedule/settings.py +++ b/src/modules/schedule/settings.py @@ -400,6 +400,7 @@ class ScheduleSettings(SettingGroup): "Minimum attendance must be an integer number of minutes between `1` and `60`." )) raise UserInputError(error) + return num @ScheduleConfig.register_model_setting class BlacklistRole(ModelData, RoleSetting): From 1f92957393a3f647a1fadff6aec7c1bfc06b35b5 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 30 Sep 2023 12:13:50 +0300 Subject: [PATCH 13/31] feat(schedule): Complete schedule cmd impl. --- src/modules/schedule/cog.py | 192 +++++++++++++++++++++++++++++++++--- src/modules/schedule/lib.py | 19 +++- 2 files changed, 199 insertions(+), 12 deletions(-) diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index 93775082..a0cb5082 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -29,7 +29,7 @@ from .settings import ScheduleSettings, ScheduleConfig from .ui.scheduleui import ScheduleUI from .ui.settingui import ScheduleSettingUI from .core import TimeSlot, ScheduledSession, SessionMember -from .lib import slotid_to_utc, time_to_slotid +from .lib import slotid_to_utc, time_to_slotid, format_until _p, _np = babel._p, babel._np @@ -732,12 +732,29 @@ class ScheduleCog(LionCog): "View and manage your scheduled session." ) ) + @appcmds.rename( + cancel=_p( + 'cmd:schedule|param:cancel', "cancel" + ), + book=_p( + 'cmd:schedule|param:book', "book" + ), + ) + @appcmds.describe( + cancel=_p( + 'cmd:schedule|param:cancel|desc', + "Select a booked timeslot to cancel." + ), + book=_p( + 'cmd:schedule|param:book|desc', + "Select a timeslot to schedule. (Times shown in your set timezone.)" + ), + ) @appcmds.guild_only - async def schedule_cmd(self, ctx: LionContext): - # TODO: Auotocomplete for book and cancel options - # Will require TTL caching for member schedules. - book = None - cancel = None + async def schedule_cmd(self, ctx: LionContext, + cancel: Optional[str] = None, + book: Optional[str] = None, + ): if not ctx.guild: return if not ctx.interaction: @@ -750,6 +767,9 @@ class ScheduleCog(LionCog): now = utc_now() lines: list[tuple[bool, str]] = [] # (error_status, msg) + if book or cancel: + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + if cancel is not None: schedule = await self._fetch_schedule(ctx.author.id) # Validate provided @@ -759,7 +779,7 @@ class ScheduleCog(LionCog): 'cmd:schedule|cancel_booking|error:parse_slot', "Time slot `{provided}` not recognised. " "Please select a session to cancel from the autocomplete options." - )) + )).format(provided=cancel) line = (True, error) elif (slotid := int(cancel)) not in schedule: # Can't cancel slot because it isn't booked @@ -802,8 +822,8 @@ class ScheduleCog(LionCog): 'cmd:schedule|create_booking|error:parse_slot', "Time slot `{provided}` not recognised. " "Please select a session to cancel from the autocomplete options." - )) - lines = (True, error) + )).format(provided=book) + line = (True, error) elif (slotid := int(book)) in schedule: # Can't book because the slot is already booked error = t(_p( @@ -812,7 +832,7 @@ class ScheduleCog(LionCog): )).format( time=discord.utils.format_dt(slotid_to_utc(slotid), style='t') ) - lines = (True, error) + line = (True, error) elif (slotid_to_utc(slotid) - now).total_seconds() < 60: # Can't book because it is running or about to start error = t(_p( @@ -826,7 +846,7 @@ class ScheduleCog(LionCog): # The slotid is valid and bookable # Run the booking try: - await self.create_booking(guildid, ctx.author.id) + await self.create_booking(guildid, ctx.author.id, slotid) ack = t(_p( 'cmd:schedule|create_booking|success', "You have successfully scheduled a session at {time}." @@ -859,6 +879,155 @@ class ScheduleCog(LionCog): await ui.run(ctx.interaction) await ui.wait() + @schedule_cmd.autocomplete('book') + async def schedule_cmd_book_acmpl(self, interaction: discord.Interaction, partial: str): + """ + List the sessions available for the member to book. + """ + # TODO: Warning about setting timezone? + userid = interaction.user.id + schedule = await self._fetch_schedule(userid) + t = self.bot.translator.t + + + if not interaction.guild or not isinstance(interaction.user, discord.Member): + choice = appcmds.Choice( + name=_p( + 'cmd:schedule|acmpl:book|error:not_in_guild', + "You need to be in a server to book sessions!" + ), + value='None' + ) + choices = [choice] + else: + member = interaction.user + # Check blacklist role + blacklist_role = (await self.settings.BlacklistRole.get(interaction.guild.id)).value + if blacklist_role and blacklist_role in member.roles: + choice = appcmds.Choice( + name=_p( + 'cmd:schedule|acmpl:book|error:blacklisted', + "Cannot Book -- Blacklisted" + ), + value='None' + ) + choices = [choice] + else: + nowid = self.nowid + if ((slotid_to_utc(nowid + 3600) - utc_now()).total_seconds() < 60): + # Start from next session instead + nowid += 3600 + upcoming = [nowid + 3600 * i for i in range(1, 25)] + upcoming = [slotid for slotid in upcoming if slotid not in schedule] + choices = [] + # We can have a max of 25 acmpl choices + # But there are at most 24 sessions to book + # So we can use the top choice for a message + + lion = await self.bot.core.lions.fetch_member(interaction.guild.id, member.id, member=member) + tz = lion.timezone + tzstring = t(_p( + 'cmd:schedule|acmpl:book|timezone_info', + "Using timezone '{timezone}' where it is '{now}'. Change with '/my timezone'" + )).format( + timezone=str(tz), + now=dt.datetime.now(tz).strftime('%H:%M') + ) + choices.append( + appcmds.Choice( + name=tzstring, value='None', + ) + ) + + slot_format = t(_p( + 'cmd:schedule|acmpl:book|format', + "{start} - {end} ({until})" + )) + for slotid in upcoming: + slot_start = slotid_to_utc(slotid).astimezone(tz).strftime('%H:%M') + slot_end = slotid_to_utc(slotid + 3600).astimezone(tz).strftime('%H:%M') + distance = int((slotid - nowid) // 3600) + until = format_until(t, distance) + name = slot_format.format( + start=slot_start, + end=slot_end, + until=until + ) + if partial.lower() in name.lower(): + choices.append( + appcmds.Choice( + name=name, + value=str(slotid) + ) + ) + if len(choices) == 1: + choices.append( + appcmds.Choice( + name=t(_p( + "cmd:schedule|acmpl:book|no_matching", + "No bookable sessions matching '{partial}'" + )).format(partial=partial[:25]), + value=partial + ) + ) + return choices + + @schedule_cmd.autocomplete('cancel') + async def schedule_cmd_cancel_acmpl(self, interaction: discord.Interaction, partial: str): + user = interaction.user + schedule = await self._fetch_schedule(user.id) + t = self.bot.translator.t + + choices = [] + + minid = self.nowid + if ((slotid_to_utc(self.nowid + 3600) - utc_now()).total_seconds() < 60): + minid = minid + 3600 + can_cancel = list(slotid for slotid in schedule if slotid > minid) + if not can_cancel: + choice = appcmds.Choice( + name=_p( + 'cmd:schedule|acmpl:cancel|error:empty_schedule', + "You do not have any upcoming sessions to cancel!" + ), + value='None' + ) + choices.append(choice) + else: + lion = await self.bot.core.lions.fetch_member(interaction.guild.id, user.id) + tz = lion.timezone + for slotid in can_cancel: + slot_format = t(_p( + 'cmd:schedule|acmpl:book|format', + "{start} - {end} ({until})" + )) + slot_start = slotid_to_utc(slotid).astimezone(tz).strftime('%H:%M') + slot_end = slotid_to_utc(slotid + 3600).astimezone(tz).strftime('%H:%M') + distance = int((slotid - minid) // 3600) + until = format_until(t, distance) + name = slot_format.format( + start=slot_start, + end=slot_end, + until=until + ) + if partial.lower() in name.lower(): + choices.append( + appcmds.Choice( + name=name, + value=str(slotid) + ) + ) + if not choices: + choice = appcmds.Choice( + name=t(_p( + 'cmd:schedule|acmpl:cancel|error:no_matching', + "No cancellable sessions matching '{partial}'" + )).format(partial=partial[:25]), + value='None' + ) + choices.append(choice) + return choices + async def _fetch_schedule(self, userid, **kwargs): """ Fetch the given user's schedule (i.e. booking map) @@ -869,6 +1038,7 @@ class ScheduleCog(LionCog): bookings = await booking_model.fetch_where( booking_model.slotid >= nowid, userid=userid, + **kwargs ).order_by('slotid', ORDER.ASC) return { diff --git a/src/modules/schedule/lib.py b/src/modules/schedule/lib.py index 1eb8b588..3951aef3 100644 --- a/src/modules/schedule/lib.py +++ b/src/modules/schedule/lib.py @@ -2,9 +2,11 @@ import asyncio import itertools import datetime as dt -from . import logger +from . import logger, babel from utils.ratelimits import Bucket +_p, _np = babel._p, babel._np + def time_to_slotid(time: dt.datetime) -> int: """ @@ -71,3 +73,18 @@ async def limit_concurrency(aws, limit): while done: yield done.pop() logger.debug(f"Completed {count} tasks") + + +def format_until(t, distance): + if distance: + return t(_np( + 'ui:schedule|format_until|positive', + "in <1 hour", + "in {number} hours", + distance + )).format(number=distance) + else: + return t(_p( + 'ui:schedule|format_until|now', + "right now!" + )) From 4828e7bf8b9cddc9d14d0665d6ed2cf267c55044 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 30 Sep 2023 21:38:28 +0300 Subject: [PATCH 14/31] feat(schedule): Add DM notifications. --- src/modules/schedule/core/session.py | 89 +++++++++++++++++++++++++++- src/tracking/voice/cog.py | 12 ++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/modules/schedule/core/session.py b/src/modules/schedule/core/session.py index da266a9b..20096e1b 100644 --- a/src/modules/schedule/core/session.py +++ b/src/modules/schedule/core/session.py @@ -1,3 +1,4 @@ +from random import random from typing import Optional import datetime as dt import asyncio @@ -335,12 +336,12 @@ class ScheduledSession: self.opened = True @log_wrap(action='Notify') - async def _notify(self, wait=60): + async def _notify(self, ping_wait=10, dm_wait=60): """ Ghost ping members who have not yet attended. """ try: - await asyncio.sleep(wait) + await asyncio.sleep(ping_wait) except asyncio.CancelledError: return missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None] @@ -349,6 +350,90 @@ class ScheduledSession: message = await self.send(ping) if message is not None: asyncio.create_task(message.delete()) + try: + # Random dither to spread out sharded notifications + dither = 30 * random() + await asyncio.sleep(dm_wait - ping_wait + dither) + except asyncio.CancelledError: + return + + if not self.guild: + # In case we somehow left the guild in the meantime + return + + missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None] + for mid in missing: + member = self.guild.get_member(mid) + if member: + args = await self._notify_dm(member) + try: + await member.send(**args.send_args) + except discord.HTTPException: + # Discord really doesn't like failed DM requests + # So take a moment of silence + await asyncio.sleep(1) + + async def _notify_dm(self, member: discord.Member) -> MessageArgs: + t = self.bot.translator.t + # Join line depends on guild setup + channels = self.channels_setting.value + room = self.room_channel + if room: + if room.type is discord.enums.ChannelType.category: + join_line = t(_p( + 'session|notify|dm|join_line:room_category', + "Please attend your session by joining a voice channel under **{room}**!" + )).format(room=room.name) + else: + join_line = t(_p( + 'session|notify|dm|join_line:room_voice', + "Please attend your session by joining {room}" + )).format(room=room.mention) + elif not channels: + join_line = t(_p( + 'session|notify|dm|join_line:all_channels', + "Please attend your session by joining a tracked voice channel!" + )) + else: + # Expand channels into a list of valid voice channels + voice_channels = set() + for channel in channels: + if channel.type is discord.enums.ChannelType.category: + voice_channels.update(channel.voice_channels) + elif channel.type is discord.enums.ChannelType.voice: + voice_channels.add(channel) + + # Now filter by connectivity and tracked + voice_tracker = self.bot.get_cog('VoiceTrackerCog') + valid = [] + for channel in voice_channels: + if voice_tracker.is_untracked(channel): + continue + if not channel.permissions_for(member).connect: + continue + valid.append(channel) + join_line = t(_p( + 'session|notify|dm|join_line:channels', + "Please attend your session by joining one of the following:" + )) + join_line = '\n'.join(join_line, *(channel.mention for channel in valid[:20])) + if len(valid) > 20: + join_line += '\n...' + + embed = discord.Embed( + colour=discord.Colour.orange(), + title=t(_p( + 'session|notify|dm|title', + "Your Scheduled Session has started!" + )), + description=t(_p( + 'session|notify|dm|description', + "Your scheduled session in {dest} has now begun!" + )).format( + dest=self.lobby_channel.mention if self.lobby_channel else f"**{self.guild.name}**" + ) + '\n' + join_line + ) + return MessageArgs(embed=embed) def notify(self): """ diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index 0392320e..d5ecb3c8 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -79,6 +79,18 @@ class VoiceTrackerCog(LionCog): """ return VoiceSession.get(self.bot, guildid, userid, **kwargs) + def is_untracked(self, channel) -> bool: + if not channel.guild: + raise ValueError("Untracked check invalid for private channels.") + untracked = self.untracked_channels.get(channel.guild.id, ()) + if channel.id in untracked: + untracked = True + elif channel.category_id and channel.category_id in untracked: + untracked = True + else: + untracked = False + return untracked + @LionCog.listener('on_ready') @log_wrap(action='Init Voice Sessions') async def initialise(self): From 898738afe0ba72f8db94d29797674b0462823b87 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 01:54:59 +0300 Subject: [PATCH 15/31] fix(text): Actually ignore unhandled msgs. --- src/tracking/text/cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tracking/text/cog.py b/src/tracking/text/cog.py index 21965e42..33a4cd1a 100644 --- a/src/tracking/text/cog.py +++ b/src/tracking/text/cog.py @@ -223,6 +223,7 @@ class TextTrackerCog(LionCog): channel.category_id except discord.ClientException: logger.debug(f"Ignoring message from channel with no parent: {message.channel}") + return # Untracked channel ward untracked = self.untracked_channels.get(guildid, []) From 308aa1d46e81a6d9bd0555b2ceb12aed20bfcde6 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 01:55:45 +0300 Subject: [PATCH 16/31] fix(core): Obscure message prefix. --- src/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot.py b/src/bot.py index 49dceb11..d643fa73 100644 --- a/src/bot.py +++ b/src/bot.py @@ -70,7 +70,7 @@ async def main(): async with aiohttp.ClientSession() as session: async with LionBot( - command_prefix=commands.when_mentioned, + command_prefix='!leo!', intents=intents, appname=appname, shardname=shardname, From 5c057278e79dd1c4961e4cf06a656b811e8eebc2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 02:03:21 +0300 Subject: [PATCH 17/31] ux(schedule): Add attendance guide. --- src/modules/schedule/cog.py | 12 +++ src/modules/schedule/settings.py | 32 ++++++ src/modules/schedule/ui/sessionui.py | 149 ++++++++++++++++++++++++++- 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index a0cb5082..c655a991 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -243,6 +243,18 @@ class ScheduleCog(LionCog): logger.debug(f"Getting slotlock (locked: {lock.locked()})") return lock + def get_active_session(self, guildid: int) -> Optional[ScheduledSession]: + """ + Get the current active session for the given guildid, or None if no session is running. + """ + slot = self.active_slots.get(self.nowid, None) + if slot is not None: + return slot.sessions.get(guildid, None) + + async def get_config(self, guildid: int) -> ScheduleConfig: + config_data = await self.data.ScheduleGuild.fetch_or_create(guildid) + return ScheduleConfig(guildid, config_data) + @log_wrap(action='Cancel Booking') async def cancel_bookings(self, *bookingids: tuple[int, int, int], refund=True): """ diff --git a/src/modules/schedule/settings.py b/src/modules/schedule/settings.py index 3faebea8..921ec8a0 100644 --- a/src/modules/schedule/settings.py +++ b/src/modules/schedule/settings.py @@ -25,6 +25,38 @@ class ScheduleConfig(ModelConfig): _model_settings = set() model = ScheduleData.ScheduleGuild + @property + def session_lobby(self): + return self.get(ScheduleSettings.SessionLobby.setting_id) + + @property + def session_room(self): + return self.get(ScheduleSettings.SessionRoom.setting_id) + + @property + def schedule_cost(self): + return self.get(ScheduleSettings.ScheduleCost.setting_id) + + @property + def attendance_reward(self): + return self.get(ScheduleSettings.AttendanceReward.setting_id) + + @property + def attendance_bonus(self): + return self.get(ScheduleSettings.AttendanceBonus.setting_id) + + @property + def min_attendance(self): + return self.get(ScheduleSettings.MinAttendance.setting_id) + + @property + def blacklist_role(self): + return self.get(ScheduleSettings.BlacklistRole.setting_id) + + @property + def blacklist_after(self): + return self.get(ScheduleSettings.BlacklistAfter.setting_id) + class ScheduleSettings(SettingGroup): @ScheduleConfig.register_model_setting diff --git a/src/modules/schedule/ui/sessionui.py b/src/modules/schedule/ui/sessionui.py index 4d2a737c..d8bfa4d4 100644 --- a/src/modules/schedule/ui/sessionui.py +++ b/src/modules/schedule/ui/sessionui.py @@ -18,7 +18,7 @@ from .scheduleui import ScheduleUI if TYPE_CHECKING: from ..cog import ScheduleCog -_p = babel._p +_p, _np = babel._p, babel._np class SessionUI(LeoUI): @@ -59,16 +59,21 @@ class SessionUI(LeoUI): 'ui:sessionui|button:schedule|label', 'Open Schedule' ), locale) + self.help_button.label = t(_p( + 'ui:sessionui|button:help|label', + "How to Attend" + )) # ----- API ----- async def reload(self): await self.init_components() if self.starting_soon: # Slot is about to start or slot has already started - self.set_layout((self.schedule_button,)) + self.set_layout((self.schedule_button, self.help_button)) else: self.set_layout( - (self.book_button, self.cancel_button, self.schedule_button), + (self.book_button, self.cancel_button,), + (self.schedule_button, self.help_button,), ) # ----- UI Components ----- @@ -178,3 +183,141 @@ class SessionUI(LeoUI): ui = ScheduleUI(self.bot, press.guild, press.user.id) await ui.run(press) await ui.wait() + + @button(label='HELP_PLACEHOLDER', style=ButtonStyle.grey, emoji=conf.emojis.question) + async def help_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + t = self.bot.translator.t + babel = self.bot.get_cog('BabelCog') + locale = await babel.get_user_locale(press.user.id) + ctx_locale.set(locale) + + schedule = await self.cog._fetch_schedule(press.user.id) + if self.slotid not in schedule: + # Tell them how to book + embed = discord.Embed( + colour=discord.Colour.brand_red(), + title=t(_p( + 'ui:session|button:help|embed:unbooked|title', + 'You have not booked this session!' + )), + description=t(_p( + 'ui:session|button:help|embed:unbooked|description', + "You need to book this scheduled session before you can attend it! " + "Press the **{book_label}** button to book the session." + )).format(book_label=self.book_button.label), + ) + else: + embed = discord.Embed( + colour=discord.Colour.orange(), + title=t(_p( + 'ui:session|button:help|embed:help|title', + "How to attend your scheduled session" + )) + ) + config = await self.cog.get_config(self.guildid) + + # Get required duration, and format it + duration = config.min_attendance.value + durstring = t(_np( + 'ui:session|button:help|embed:help|minimum_attendance', + "at least one minute", + "at least `{duration}` minutes", + duration + )).format(duration=duration) + + # Get session room + room = config.session_room.value + + if room is None: + room_line = '' + elif room.type is discord.enums.ChannelType.category: + room_line = t(_p( + 'ui:session|button:help|embed:help|room_line:category', + "The exclusive scheduled session category **{category}** " + "will also be open to you during your scheduled session." + )).format(category=room.name) + else: + room_line = t(_p( + 'ui:session|button:help|embed:help|room_line:voice', + "The exclusive scheduled session room {room} " + "will also be open to you during your scheduled session." + )).format(room=room.mention) + + # Get valid session channels, if set + channels = (await self.cog.settings.SessionChannels.get(self.guildid)).value + + attend_args = dict( + minimum=durstring, + start=discord.utils.format_dt(slotid_to_utc(self.slotid), 't'), + end=discord.utils.format_dt(slotid_to_utc(self.slotid + 3600), 't'), + ) + + if room is not None and len(channels) == 1 and channels[0].id == room.id: + # Special case where session room is the only allowed channel/category + room_line = '' + if room.type is discord.enums.ChannelType.category: + attend_line = t(_p( + 'ui:session|button:help|embed:help|attend_line:only_room_category', + "To attend your scheduled session, " + "join a voice channel in **{room}** for **{minimum}** " + "between {start} and {end}." + )).format( + **attend_args, + room=room.name + ) + else: + attend_line = t(_p( + 'ui:session|button:help|embed:help|attend_line:only_room_channel', + "To attend your scheduled session, " + "join {room} for **{minimum}** " + "between {start} and {end}." + )).format( + **attend_args, + room=room.mention + ) + elif channels: + attend_line = t(_p( + 'ui:session|button:help|embed:help|attend_line:with_channels', + "To attend your scheduled session, join a valid session voice channel for **{minimum}** " + "between {start} and {end}." + )).format(**attend_args) + channel_string = ', '.join( + f"**{channel.name}**" if (channel.type == discord.enums.ChannelType.category) else channel.mention + for channel in channels + ) + embed.add_field( + name=t(_p( + 'ui:session|button:help|embed:help|field:channels|name', + "Valid session channels" + )), + value=channel_string[:1024], + inline=False + ) + else: + attend_line = t(_p( + 'ui:session|button:help|embed:help|attend_line:all_channels', + "To attend your scheduled session, join any tracked voice channel " + "for **{minimum}** between {start} and {end}." + )).format(**attend_args) + + embed.description = '\n'.join((attend_line, room_line)) + embed.add_field( + name=t(_p( + 'ui:session|button:help|embed:help|field:rewards|name', + "Rewards" + )), + value=t(_p( + 'ui:session|button:help|embed:help|field:rewards|value', + "Everyone who attends the session will be rewarded with {coin}**{reward}**.\n" + "If *everyone* successfully attends, you will also be awarded a bonus of {coin}**{bonus}**.\n" + "Anyone who does *not* attend their booked session will have the rest of their schedule cancelled " + "**without refund**, so beware!" + )).format( + coin=conf.emojis.coin, + reward=config.attendance_reward.value, + bonus=config.attendance_bonus.value, + ), + inline=False + ) + await press.edit_original_response(embed=embed) From e2a2e7be8afb1b50843ce57fc6edeb57c1b36cae Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 02:22:09 +0300 Subject: [PATCH 18/31] fix(schedule): Remove members after session. --- src/modules/schedule/core/session.py | 38 +++++++++++--- src/modules/schedule/core/timeslot.py | 74 ++++++++++++++++++++++++++- src/modules/schedule/lib.py | 27 +++++++++- 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/modules/schedule/core/session.py b/src/modules/schedule/core/session.py index 20096e1b..537b7840 100644 --- a/src/modules/schedule/core/session.py +++ b/src/modules/schedule/core/session.py @@ -12,7 +12,7 @@ from utils.lib import MessageArgs from .. import babel, logger from ..data import ScheduleData as Data -from ..lib import slotid_to_utc +from ..lib import slotid_to_utc, vacuum_channel from ..settings import ScheduleSettings as Settings from ..settings import ScheduleConfig from ..ui.sessionui import SessionUI @@ -289,12 +289,16 @@ class ScheduledSession: Remove overwrites for non-members. """ async with self.lock: - if not (members := list(self.members.values())): - return if not (guild := self.guild): return if not (room := self.room_channel): + # Nothing to do + self.prepared = True + self.opened = True return + members = list(self.members.values()) + + t = self.bot.translator.t if room.permissions_for(guild.me) >= my_room_permissions: # Replace the member overwrites @@ -314,17 +318,36 @@ class ScheduledSession: if mobj: overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True) try: - await room.edit(overwrites=overwrites) + await room.edit( + overwrites=overwrites, + reason=t(_p( + 'session|open|update_perms|audit_reason', + "Opening configured scheduled session room." + )) + ) except discord.HTTPException: logger.exception( f"Unhandled discord exception received while opening schedule session room {self!r}" ) else: logger.debug( - f"Opened schedule session room for session {self!r}" + f"Opened schedule session room for session {self!r} with overwrites {overwrites}" + ) + + # Cleanup members who should not be in the channel(s) + if room.type is discord.enums.ChannelType.category: + channels = room.voice_channels + else: + channels = [room] + for channel in channels: + await vacuum_channel( + channel, + reason=t(_p( + 'session|open|clean_room|audit_reason', + "Removing extra member from scheduled session room." + )) ) else: - t = self.bot.translator.t await self.send( t(_p( 'session|open|error:room_permissions', @@ -344,6 +367,8 @@ class ScheduledSession: await asyncio.sleep(ping_wait) except asyncio.CancelledError: return + + # Ghost ping alert for missing members missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None] if missing: ping = ''.join(f"<@{mid}>" for mid in missing) @@ -361,6 +386,7 @@ class ScheduledSession: # In case we somehow left the guild in the meantime return + # DM alert for _still_ missing members missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None] for mid in missing: member = self.guild.get_member(mid) diff --git a/src/modules/schedule/core/timeslot.py b/src/modules/schedule/core/timeslot.py index 200c3e1b..75a32f5c 100644 --- a/src/modules/schedule/core/timeslot.py +++ b/src/modules/schedule/core/timeslot.py @@ -19,7 +19,7 @@ from modules.economy.data import EconomyData, TransactionType from .. import babel, logger from ..data import ScheduleData as Data -from ..lib import slotid_to_utc, batchrun_per_second, limit_concurrency +from ..lib import slotid_to_utc, batchrun_per_second, limit_concurrency, vacuum_channel from ..settings import ScheduleSettings from .session import ScheduledSession @@ -439,6 +439,75 @@ class TimeSlot: f"Closed {len(sessions)} for scheduled session timeslot: {self!r}" ) + @log_wrap(action='Tidy session rooms') + async def tidy_rooms(self, sessions: list[ScheduledSession]): + """ + 'Tidy Up' after sessions have been closed. + + This cleans up permissions for sessions which do not have another running session, + and vacuums the channel. + + Somewhat temporary measure, + workaround for the design flaw that channel permissions are only updated during open, + and hence are never cleared unless there is a next session. + Limitations include not clearing after a manual close. + """ + t = self.bot.translator.t + + for session in sessions: + if not session.guild: + # Can no longer access the session guild, nothing to clean up + logger.debug(f"Not tidying {session!r} because guild gone.") + continue + if not (room := session.room_channel): + # Session did not have a room to clean up + logger.debug(f"Not tidying {session!r} because room channel gone.") + continue + if not session.opened or session.cancelled: + # Not an active session, don't try to tidy up + logger.debug(f"Not tidying {session!r} because cancelled or not opened.") + continue + if (active := self.cog.get_active_session(session.guild.id)) is not None: + # Rely on the active session to set permissions and vacuum channel + logger.debug(f"Not tidying {session!r} because guild has active session {active!r}.") + continue + logger.debug(f"Tidying {session!r}.") + + me = session.guild.me + if room.permissions_for(me).manage_roles: + overwrites = { + target: overwrite for target, overwrite in room.overwrites.items() + if not isinstance(target, discord.Member) + } + try: + await room.edit( + overwrites=overwrites, + reason=t(_p( + "session|closing|audit_reason", + "Removing previous scheduled session member permissions." + )) + ) + except discord.HTTPException: + logger.warning( + f"Unexpected exception occurred while tidying after sessions {session!r}", + exc_info=True + ) + else: + logger.debug(f"Updated room permissions while tidying {session!r}.") + if room.type is discord.enums.ChannelType.category: + channels = room.voice_channels + else: + channels = [room] + for channel in channels: + await vacuum_channel( + channel, + reason=t(_p( + "session|closing|disconnecting|audit_reason", + "Disconnecting previous scheduled session members." + )) + ) + logger.debug(f"Finished tidying {session!r}.") + def launch(self) -> asyncio.Task: self.run_task = asyncio.create_task(self.run(), name=f"TimeSlot {self.slotid}") return self.run_task @@ -475,6 +544,9 @@ class TimeSlot: logger.info(f"Active timeslot closing. {self!r}") await self.close(list(self.sessions.values()), consequences=True) logger.info(f"Active timeslot closed. {self!r}") + await asyncio.sleep(30) + await self.tidy_rooms(list(self.sessions.values())) + logger.info(f"Previous active timeslot tidied up. {self!r}") except asyncio.CancelledError: logger.info( f"Deactivating active time slot: {self!r}" diff --git a/src/modules/schedule/lib.py b/src/modules/schedule/lib.py index 3951aef3..27595961 100644 --- a/src/modules/schedule/lib.py +++ b/src/modules/schedule/lib.py @@ -1,9 +1,13 @@ import asyncio import itertools import datetime as dt +from typing import Optional -from . import logger, babel +import discord + +from meta.logger import log_wrap from utils.ratelimits import Bucket +from . import logger, babel _p, _np = babel._p, babel._np @@ -88,3 +92,24 @@ def format_until(t, distance): 'ui:schedule|format_until|now', "right now!" )) + + +@log_wrap(action='Vacuum Channel') +async def vacuum_channel(channel: discord.VoiceChannel, reason: Optional[str] = None): + """ + Launch disconnect tasks for each voice channel member who does not have permission to connect. + """ + me = channel.guild.me + if not channel.permissions_for(me).move_members: + # Nothing we can do + return + + to_remove = [member for member in channel.members if not channel.permissions_for(member).connect] + for member in to_remove: + # Disconnect member from voice + # Extra check here since members may come and go while we are trying to remove + if member in channel.members: + try: + await member.edit(voice_channel=None, reason=reason) + except discord.HTTPException: + pass From bb9a099deab5e5c40a043dde30d4b82d8ed25dce Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 07:34:16 +0300 Subject: [PATCH 19/31] fix(settingui): Limit desc table length. --- src/babel/settings.py | 5 +++-- src/modules/member_admin/settings.py | 8 ++++---- src/settings/ui.py | 22 +++++++++++++++++++--- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/babel/settings.py b/src/babel/settings.py index 47da6afe..24c45914 100644 --- a/src/babel/settings.py +++ b/src/babel/settings.py @@ -1,3 +1,4 @@ +from typing import Optional from settings import ModelData from settings.setting_types import StringSetting, BoolSetting @@ -23,11 +24,11 @@ class LocaleSetting(StringSetting): "Enter a supported language (e.g. 'en-GB')." ) - def _desc_table(self) -> list[str]: + def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]: translator = ctx_translator.get() t = translator.t - lines = super()._desc_table() + lines = super()._desc_table(show_value=show_value) lines.append(( t(_p( 'settype:locale|summary_table|field:supported|key', diff --git a/src/modules/member_admin/settings.py b/src/modules/member_admin/settings.py index deaba142..95fb79e8 100644 --- a/src/modules/member_admin/settings.py +++ b/src/modules/member_admin/settings.py @@ -188,8 +188,8 @@ class MemberAdminSettings(SettingGroup): self.value = editor_data await self.write() - def _desc_table(self) -> list[str]: - lines = super()._desc_table() + def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]: + lines = super()._desc_table(show_value=show_value) t = ctx_translator.get().t keydescs = [ (key, t(value)) for key, value in self._subkey_desc.items() @@ -313,8 +313,8 @@ class MemberAdminSettings(SettingGroup): self.value = editor_data await self.write() - def _desc_table(self) -> list[str]: - lines = super()._desc_table() + def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]: + lines = super()._desc_table(show_value=show_value) t = ctx_translator.get().t keydescs = [ (key, t(value)) for key, value in self._subkey_desc_returning.items() diff --git a/src/settings/ui.py b/src/settings/ui.py index 046a961b..b804f73c 100644 --- a/src/settings/ui.py +++ b/src/settings/ui.py @@ -310,6 +310,22 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): """ name = self.display_name value = f"{self.long_desc}\n{self.desc_table}" + if len(value) > 1024: + t = ctx_translator.get().t + desc_table = '\n'.join( + tabulate( + *self._desc_table( + show_value=t(_p( + 'setting|embed_field|too_long', + "Too long to display here!" + )) + ) + ) + ) + value = f"{self.long_desc}\n{desc_table}" + if len(value) > 1024: + # Forcibly trim + value = value[:1020] + '...' return {'name': name, 'value': value} @property @@ -341,14 +357,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): embed.description = "{}\n{}".format(self.long_desc.format(self=self), self.desc_table) return embed - def _desc_table(self) -> list[str]: + def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]: t = ctx_translator.get().t lines = [] # Currently line lines.append(( t(_p('setting|summary_table|field:currently|key', "Currently")), - self.formatted or self.notset_str + show_value or (self.formatted or self.notset_str) )) # Default line @@ -380,7 +396,7 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): return TextInput( label=self.display_name, placeholder=self.accepts, - default=self.input_formatted, + default=self.input_formatted[:4000], required=self._required ) From 0bc8e912948663395de67dc61f8697854f7db235 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 07:35:53 +0300 Subject: [PATCH 20/31] fix(data): Fix typing typo. --- src/utils/data.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/data.py b/src/utils/data.py index 6be3680b..a590430f 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -1,7 +1,7 @@ """ Some useful pre-built Conditions for data queries. """ -from typing import Optional +from typing import Optional, Any from itertools import chain from psycopg import sql @@ -11,7 +11,7 @@ from data.base import Expression from constants import MAX_COINS -def MULTIVALUE_IN(columns: tuple[str, ...], *data: tuple[...]) -> Condition: +def MULTIVALUE_IN(columns: tuple[str, ...], *data: tuple[Any, ...]) -> Condition: """ Condition constructor for filtering by multiple column equalities. @@ -100,7 +100,7 @@ class TemporaryTable(Expression): ``` """ - def __init__(self, *columns: str, name: str = '_t', types: Optional[tuple[str]] = None): + def __init__(self, *columns: str, name: str = '_t', types: Optional[tuple[str, ...]] = None): self.name = name self.columns = columns self.types = types From ce4637f05afe77edc55e1f3482e4a0c497ed230b Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:20:24 +0300 Subject: [PATCH 21/31] fix(timers): Improve timer loading. --- src/modules/pomodoro/cog.py | 133 +++++++++++++++++++++++--------- src/modules/pomodoro/timer.py | 140 +++++++++++++++++++++++----------- 2 files changed, 195 insertions(+), 78 deletions(-) diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index fd25b318..12a6b3ae 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -12,6 +12,7 @@ from meta.logger import log_wrap from meta.sharding import THIS_SHARD from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel from utils.lib import utc_now +from utils.ratelimits import limit_concurrency from wards import low_management_ward @@ -48,16 +49,38 @@ class TimerCog(LionCog): self.timer_options = TimerOptions() self.ready = False - self.timers = defaultdict(dict) + self.timers: dict[int, dict[int, Timer]] = defaultdict(dict) async def _monitor(self): + timers = [timer for tguild in self.timers.values() for timer in tguild.values()] + state = ( + "" + ) + data = dict( + loaded=len(timers), + guilds=len(set(timer.data.guildid for timer in timers)), + members=sum(len(timer.members) for timer in timers), + running=sum(1 for timer in timers if timer.running), + launched=sum(1 for timer in timers if timer._run_task and not timer._run_task.done()), + looping=sum(1 for timer in timers if timer._loop_task and not timer._loop_task.done()), + locked=sum(1 for timer in timers if timer._lock.locked()), + voice_locked=sum(1 for timer in timers if timer._voice_update_lock.locked()), + ) if not self.ready: level = StatusLevel.STARTING - info = "(STARTING) Not ready. {timers} timers loaded." + info = f"(STARTING) Not ready. {state}" else: level = StatusLevel.OKAY - info = "(OK) {timers} timers loaded." - data = dict(timers=len(self.timers)) + info = f"(OK) Ready. {state}" return ComponentStatus(level, info, info, data) async def cog_load(self): @@ -79,15 +102,12 @@ class TimerCog(LionCog): Clears caches and stops run-tasks for each active timer. Does not exist until all timers have completed background tasks. """ - timers = (timer for tguild in self.timers.values() for timer in tguild.values()) - try: - await asyncio.gather(*(timer.unload() for timer in timers)) - except Exception: - logger.exception( - "Exception encountered while unloading `TimerCog`" - ) + timers = [timer for tguild in self.timers.values() for timer in tguild.values()] self.timers.clear() + if timers: + await self._unload_timers(timers) + async def cog_check(self, ctx: LionContext): if not self.ready: raise CheckFailure( @@ -101,6 +121,20 @@ class TimerCog(LionCog): else: return True + @log_wrap(action='Unload Timers') + async def _unload_timers(self, timers: list[Timer]): + """ + Unload all active timers. + """ + tasks = [asyncio.create_task(timer.unload()) for timer in timers] + for timer, task in zip(timers, tasks): + try: + await task + except Exception: + logger.exception( + f"Unexpected exception while unloading timer {timer!r}" + ) + async def _load_timers(self, timer_data: list[TimerData.Timer]): """ Factored method to load a list of timers from data rows. @@ -108,6 +142,7 @@ class TimerCog(LionCog): guildids = set() to_delete = [] to_create = [] + to_unload = [] for row in timer_data: channel = self.bot.get_channel(row.channelid) if not channel: @@ -115,6 +150,12 @@ class TimerCog(LionCog): else: guildids.add(row.guildid) to_create.append(row) + if row.guildid in self.timers: + if row.channelid in self.timers[row.guildid]: + to_unload.append(self.timers[row.guildid].pop(row.channelid)) + + if to_unload: + await self._unload_timers(to_unload) if guildids: lguilds = await self.bot.core.lions.fetch_guilds(*guildids) @@ -145,37 +186,57 @@ class TimerCog(LionCog): # Re-launch and update running timers for timer in to_launch: timer.launch() - tasks = [ - asyncio.create_task(timer.update_status_card()) for timer in to_launch - ] - if tasks: - try: - await asyncio.gather(*tasks) - except Exception: - logger.exception( - "Exception occurred updating timer status for running timers." - ) + + coros = [timer.update_status_card() for timer in to_launch] + if coros: + i = 0 + async for task in limit_concurrency(coros, 10): + try: + await task + except discord.HTTPException: + timer = to_launch[i] + logger.warning( + f"Unhandled discord exception while updating timer status for {timer!r}", + exc_info=True + ) + except Exception: + timer = to_launch[i] + logger.exception( + f"Unexpected exception while updating timer status for {timer!r}", + exc_info=True + ) + i += 1 logger.info( f"Updated and launched {len(to_launch)} running timers." ) # Update stopped timers - tasks = [ - asyncio.create_task(timer.update_status_card()) for timer in to_update - ] - if tasks: - try: - await asyncio.gather(*tasks) - except Exception: - logger.exception( - "Exception occurred updating timer status for stopped timers." - ) + coros = [timer.update_status_card(render=False) for timer in to_update] + if coros: + i = 0 + async for task in limit_concurrency(coros, 10): + try: + await task + except discord.HTTPException: + timer = to_update[i] + logger.warning( + f"Unhandled discord exception while updating timer status for {timer!r}", + exc_info=True + ) + except Exception: + timer = to_update[i] + logger.exception( + f"Unexpected exception while updating timer status for {timer!r}", + exc_info=True + ) + i += 1 logger.info( f"Updated {len(to_update)} stopped timers." ) # Update timer registry - self.timers.update(timer_reg) + for gid, gtimers in timer_reg.items(): + self.timers[gid].update(gtimers) @LionCog.listener('on_ready') @log_wrap(action='Init Timers') @@ -185,10 +246,14 @@ class TimerCog(LionCog): """ self.ready = False self.timers = defaultdict(dict) + if self.timers: + timers = [timer for tguild in self.timers.values() for timer in tguild.values()] + await self._unload_timers(timers) + self.timers.clear() # Fetch timers in guilds on this shard - # TODO: Join with guilds and filter by guilds we are still in - timer_data = await self.data.Timer.fetch_where(THIS_SHARD) + guildids = [guild.id for guild in self.bot.guilds] + timer_data = await self.data.Timer.fetch_where(guildid=guildids) await self._load_timers(timer_data) # Ready to handle events diff --git a/src/modules/pomodoro/timer.py b/src/modules/pomodoro/timer.py index 9da3b288..810f2946 100644 --- a/src/modules/pomodoro/timer.py +++ b/src/modules/pomodoro/timer.py @@ -7,7 +7,7 @@ from datetime import timedelta, datetime import discord from meta import LionBot -from meta.logger import log_wrap, log_context +from meta.logger import log_wrap, log_context, set_logging_context from utils.lib import MessageArgs, utc_now, replace_multiple from core.lion_guild import LionGuild from core.data import CoreData @@ -61,7 +61,7 @@ class Timer: log_context.set(f"tid: {self.data.channelid}") # State - self.last_seen: dict[int, int] = {} # memberid -> last seen timestamp + self.last_seen: dict[int, datetime] = {} # memberid -> last seen timestamp self.status_view: Optional[TimerStatusUI] = None # Current TimerStatusUI self.last_status_message: Optional[discord.Message] = None # Last deliever notification message self._hook: Optional[CoreData.LionHook] = None # Cached notification webhook @@ -384,7 +384,7 @@ class Timer: tasks = [] after_tasks = [] # Submit channel name update request - after_tasks.append(asyncio.create_task(self._update_channel_name())) + after_tasks.append(asyncio.create_task(self._update_channel_name(), name='Update-name')) if kick and (threshold := self.warning_threshold(from_stage)): now = utc_now() @@ -397,38 +397,65 @@ class Timer: elif last_seen < threshold: needs_kick.append(member) - for member in needs_kick: - tasks.append(member.edit(voice_channel=None)) + t = self.bot.translator.t + if self.channel and self.channel.permissions_for(self.channel.guild.me).move_members: + for member in needs_kick: + tasks.append( + asyncio.create_task( + member.edit( + voice_channel=None, + reason=t(_p( + 'timer|disconnect|audit_reason', + "Disconnecting inactive member from timer." + ), locale=self.locale.value) + ), + name="Disconnect-timer-member" + ) + ) notify_hook = await self.get_notification_webhook() - if needs_kick and notify_hook: - t = self.bot.translator.t - kick_message = t(_np( - 'timer|kicked_message', - "{mentions} was removed from {channel} because they were inactive! " - "Remember to press {tick} to register your presence every stage.", - "{mentions} were removed from {channel} because they were inactive! " - "Remember to press {tick} to register your presence every stage.", - len(needs_kick) - ), locale=self.locale.value).format( - channel=f"<#{self.data.channelid}>", - mentions=', '.join(member.mention for member in needs_kick), - tick=self.bot.config.emojis.tick - ) - tasks.append(notify_hook.send(kick_message)) + if needs_kick and notify_hook and self.channel: + if self.channel.permissions_for(self.channel.guild.me).move_members: + kick_message = t(_np( + 'timer|kicked_message', + "{mentions} was removed from {channel} because they were inactive! " + "Remember to press {tick} to register your presence every stage.", + "{mentions} were removed from {channel} because they were inactive! " + "Remember to press {tick} to register your presence every stage.", + len(needs_kick) + ), locale=self.locale.value).format( + channel=f"<#{self.data.channelid}>", + mentions=', '.join(member.mention for member in needs_kick), + tick=self.bot.config.emojis.tick + ) + else: + kick_message = t(_p( + 'timer|kick_failed', + "**Warning!** Timer {channel} is configured to disconnect on inactivity, " + "but I lack the 'Move Members' permission to do this!" + ), locale=self.locale.value).format( + channel=self.channel.mention + ) + tasks.append(asyncio.create_task(notify_hook.send(kick_message), name='kick-message')) if self.voice_alerts: - after_tasks.append(asyncio.create_task(self._voice_alert(to_stage))) + after_tasks.append(asyncio.create_task(self._voice_alert(to_stage), name='voice-alert')) - if tasks: + for task in tasks: try: - await asyncio.gather(*tasks) + await task + except discord.Forbidden: + logger.warning( + f"Unexpected forbidden during pre-task {task!r} for change stage in timer {self!r}" + ) + except discord.HTTPException: + logger.warning( + f"Unexpected API error during pre-task {task!r} for change stage in timer {self!r}" + ) except Exception: - logger.exception(f"Exception occurred during pre-tasks for change stage in timer {self!r}") + logger.exception(f"Exception occurred during pre-task {task!r} for change stage in timer {self!r}") - print("Sending Status") await self.send_status() - print("Sent Status") if after_tasks: try: @@ -444,7 +471,7 @@ class Timer: if not stage: return - if not self.channel or not self.channel.permissions_for(self.guild.me).speak: + if not self.guild or not self.channel or not self.channel.permissions_for(self.guild.me).speak: return async with self.lguild.voice_lock: @@ -480,15 +507,16 @@ class Timer: # Quit when we finish playing or after 10 seconds, whichever comes first sleep_task = asyncio.create_task(asyncio.sleep(10)) - wait_task = asyncio.create_task(finished.wait()) + wait_task = asyncio.create_task(finished.wait(), name='timer-voice-waiting') _, pending = await asyncio.wait([sleep_task, wait_task], return_when=asyncio.FIRST_COMPLETED) for task in pending: task.cancel() - await self.guild.voice_client.disconnect(force=True) + if self.guild and self.guild.voice_client: + await self.guild.voice_client.disconnect(force=True) except Exception: logger.exception( - "Exception occurred while playing voice alert for timer {self!r}" + f"Exception occurred while playing voice alert for timer {self!r}" ) def stageline(self, stage: Stage): @@ -511,7 +539,7 @@ class Timer: ) return stageline - async def current_status(self, with_notify=True, with_warnings=True) -> MessageArgs: + async def current_status(self, with_notify=True, with_warnings=True, render=True) -> MessageArgs: """ Message arguments for the current timer status message. """ @@ -520,7 +548,7 @@ class Timer: ctx_locale.set(self.locale.value) stage = self.current_stage - if self.running: + if self.running and stage is not None: stageline = self.stageline(stage) warningline = "" needs_warning = [] @@ -530,7 +558,7 @@ class Timer: last_seen = self.last_seen.get(member.id, None) if last_seen is None: last_seen = self.last_seen[member.id] = now - elif last_seen < threshold: + elif threshold and last_seen < threshold: needs_warning.append(member) if needs_warning: warningline = t(_p( @@ -567,13 +595,16 @@ class Timer: await ui.refresh() - card = await get_timer_card(self.bot, self, stage) - try: - await card.render() - file = card.as_file(f"pomodoro_{self.data.channelid}.png") - args = MessageArgs(content=content, file=file, view=ui) - except RenderingException: - args = MessageArgs(content=content, view=ui) + rawargs = dict(content=content, view=ui) + + if render: + try: + card = await get_timer_card(self.bot, self, stage) + await card.render() + rawargs['file'] = card.as_file(f"pomodoro_{self.data.channelid}.png") + except RenderingException: + pass + args = MessageArgs(**rawargs) return args @@ -764,12 +795,16 @@ class Timer: f"Timer deleted. Reason given: {reason!r}" ) - @log_wrap(action='Timer Loop') + @log_wrap(isolate=True, stack=()) async def _runloop(self): """ Main loop which controls the regular stage changes and status updates. """ + set_logging_context( + action=f"TimerLoop {self.data.channelid}", + context=f"tid: {self.data.channelid}", + ) # Allow updating with 10 seconds of drift to the next stage change drift = 10 @@ -785,6 +820,11 @@ class Timer: self._state = current = self.current_stage while True: + if current is None: + logger.exception( + f"Closing timer loop because current state is None. Timer {self!r}" + ) + break to_next_stage = (current.end - utc_now()).total_seconds() # TODO: Consider request rate and load @@ -812,12 +852,18 @@ class Timer: if current.end < utc_now(): self._state = self.current_stage - task = asyncio.create_task(self.notify_change_stage(current, self._state)) + task = asyncio.create_task( + self.notify_change_stage(current, self._state), + name='notify-change-stage' + ) background_tasks.add(task) task.add_done_callback(background_tasks.discard) current = self._state elif self.members: - task = asyncio.create_task(self._update_channel_name()) + task = asyncio.create_task( + self._update_channel_name(), + name='regular-channel-update' + ) background_tasks.add(task) task.add_done_callback(background_tasks.discard) task = asyncio.create_task(self.update_status_card()) @@ -825,7 +871,13 @@ class Timer: task.add_done_callback(background_tasks.discard) if background_tasks: - await asyncio.gather(*background_tasks) + try: + await asyncio.gather(*background_tasks) + except Exception: + logger.warning( + f"Unexpected error while finishing background tasks for timer {self!r}", + exc_info=True + ) def launch(self): """ From 89d236f0cb1a128e97a830013d11a97bd4897bb8 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:31:00 +0300 Subject: [PATCH 22/31] fix(utils): Fix off by one error in month start. --- src/utils/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/lib.py b/src/utils/lib.py index f084dde9..10babc85 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -765,7 +765,7 @@ class Timezoned: Return the start of the current month in the object's timezone """ today = self.today - return today - datetime.timedelta(days=today.day) + return today - datetime.timedelta(days=(today.day - 1)) def replace_multiple(format_string, mapping): From c8223725ad4d4311d222f7b0d8e97cf74fe9ffc1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:33:30 +0300 Subject: [PATCH 23/31] fix(utils): Fix delay issue in Bucket. --- src/utils/ratelimits.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/ratelimits.py b/src/utils/ratelimits.py index 4c050e94..7322336c 100644 --- a/src/utils/ratelimits.py +++ b/src/utils/ratelimits.py @@ -56,7 +56,10 @@ class Bucket: def delay(self): self._leak() if self._level + 1 > self.max_level: - return (self._level + 1 - self.max_level) * self.leak_rate + delay = (self._level + 1 - self.max_level) * self.leak_rate + else: + delay = 0 + return delay def _leak(self): if self._level: From e166ec3e4632448e4f08803b4aa657f1c3f753af Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:37:23 +0300 Subject: [PATCH 24/31] fix(babel): Fix typo on setting edit path. --- src/babel/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/babel/cog.py b/src/babel/cog.py index 8f18ddf9..0ca4bcd5 100644 --- a/src/babel/cog.py +++ b/src/babel/cog.py @@ -145,7 +145,7 @@ class BabelCog(LionCog): t(_p( 'cmd:configure_language|error', "You cannot enable `{force_setting}` without having a configured language!" - )).format(force_setting=t(LocaleSettings.ForceLocale.display_name)) + )).format(force_setting=t(LocaleSettings.ForceLocale._display_name)) ) # TODO: Really need simultaneous model writes, or batched writes lines = [] From 54aa0ea217ff07f0d0d52a7094494219b1aded75 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:40:33 +0300 Subject: [PATCH 25/31] chore: Update GUI submodule pointer. --- src/gui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui b/src/gui index ba9ace6c..0153ee25 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit ba9ace6ced300123c53287ff1ba4dbd106d1cc46 +Subproject commit 0153ee258be36fe77b81d88765e65be38406ecab From b5007c0df3c745f368500d52de22bfa48a839dda Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:46:06 +0300 Subject: [PATCH 26/31] fix(ranks): Remove guild assumption in events. --- src/modules/ranks/cog.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/modules/ranks/cog.py b/src/modules/ranks/cog.py index 76e615bf..4940d249 100644 --- a/src/modules/ranks/cog.py +++ b/src/modules/ranks/cog.py @@ -269,6 +269,9 @@ class RankCog(LionCog): Handle batch of completed message sessions. """ for guildid, userid, messages, guild_xp in session_data: + if not self.bot.get_guild(guildid): + # Ignore guilds we have left + continue lguild = await self.bot.core.lions.fetch_guild(guildid) rank_type = lguild.config.get('rank_type').value if rank_type in (RankType.MESSAGE, RankType.XP): @@ -542,6 +545,9 @@ class RankCog(LionCog): @log_wrap(action="Voice Rank Hook") async def on_voice_session_complete(self, *session_data): for guildid, userid, duration, guild_xp in session_data: + if not self.bot.get_guild(guildid): + # Ignore guilds we have left + continue lguild = await self.bot.core.lions.fetch_guild(guildid) unranked_role_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(guildid) unranked_roleids = set(unranked_role_setting.data) From 07ad2d0830b49b1e36b219743002fac3eba2bb79 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:49:11 +0300 Subject: [PATCH 27/31] fix(text): Fix process scope bug. --- src/tracking/text/session.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tracking/text/session.py b/src/tracking/text/session.py index 6f3f16ea..6921c88d 100644 --- a/src/tracking/text/session.py +++ b/src/tracking/text/session.py @@ -105,12 +105,15 @@ class TextSession: """ Process a message into the session. """ + if not message.guild: + return + if (message.author.id != self.userid) or (message.guild.id != self.guildid): raise ValueError("Invalid attempt to process message from a different member!") # Identify if we need to start a new period - tdiff = (message.created_at - self.this_period_start).total_seconds() - if self.this_period_start is not None and tdiff < self.period_length: + start = self.this_period_start + if start is not None and (message.created_at - start).total_seconds() < self.period_length: self.this_period_messages += 1 self.this_period_words += len(message.content.split()) else: From 5cb391eab32258cba3c17faf0990edab6d04a631 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 13:54:23 +0300 Subject: [PATCH 28/31] fix(text): Catch unloaded core modules. --- src/tracking/text/cog.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tracking/text/cog.py b/src/tracking/text/cog.py index 33a4cd1a..acfb6fe3 100644 --- a/src/tracking/text/cog.py +++ b/src/tracking/text/cog.py @@ -148,6 +148,12 @@ class TextTrackerCog(LionCog): logger.info( f"Saving batch of {len(batch)} completed text sessions." ) + if self.bot.core is None or self.bot.core.lions is None: + # Currently unloading, nothing we can do + logger.warning( + "Skipping text session batch due to unloaded modules." + ) + return # Batch-fetch lguilds lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch}) From 8a2c85113e3a396906fa1979e7c76ba4f5917fe1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 14:21:59 +0300 Subject: [PATCH 29/31] fix(statistics): Fix several display errors. --- src/gui | 2 +- src/modules/statistics/data.py | 6 +++--- src/modules/statistics/graphics/profile.py | 2 +- src/modules/statistics/graphics/stats.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/gui b/src/gui index 0153ee25..24e94d10 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit 0153ee258be36fe77b81d88765e65be38406ecab +Subproject commit 24e94d10e2ef2e34a6feb2bc8f9eca268260f512 diff --git a/src/modules/statistics/data.py b/src/modules/statistics/data.py index 30448701..76ba4cf6 100644 --- a/src/modules/statistics/data.py +++ b/src/modules/statistics/data.py @@ -126,7 +126,7 @@ class StatsData(Registry): @classmethod @log_wrap(action='study_times_between') - async def study_times_between(cls, guildid: int, userid: int, *points) -> list[int]: + async def study_times_between(cls, guildid: Optional[int], userid: int, *points) -> list[int]: if len(points) < 2: raise ValueError('Not enough block points given!') @@ -165,8 +165,8 @@ class StatsData(Registry): return (await cursor.fetchone()[0]) or 0 @classmethod - @log_wrap(action='study_times_between') - async def study_times_since(cls, guildid: int, userid: int, *starts) -> int: + @log_wrap(action='study_times_since') + async def study_times_since(cls, guildid: Optional[int], userid: int, *starts) -> int: if len(starts) < 1: raise ValueError('No starting points given!') diff --git a/src/modules/statistics/graphics/profile.py b/src/modules/statistics/graphics/profile.py index 3a413d4a..759a974b 100644 --- a/src/modules/statistics/graphics/profile.py +++ b/src/modules/statistics/graphics/profile.py @@ -76,7 +76,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int): else: next_rank = None - achievements = (0, 1) + achievements = (0, 1, 2, 3) card = ProfileCard( user=username, diff --git a/src/modules/statistics/graphics/stats.py b/src/modules/statistics/graphics/stats.py index b10607d8..ffa19bb0 100644 --- a/src/modules/statistics/graphics/stats.py +++ b/src/modules/statistics/graphics/stats.py @@ -34,7 +34,7 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode # Extract the study times for each period if mode in (CardMode.STUDY, CardMode.VOICE): model = data.VoiceSessionStats - refkey = (guildid, userid) + refkey = (guildid or None, userid) ref_since = model.study_times_since ref_between = model.study_times_between elif mode is CardMode.TEXT: From 5568339835ea47d34f394a6631273c4a06ba474f Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 1 Oct 2023 14:26:14 +0300 Subject: [PATCH 30/31] fix(stats): Limit stat commands to guilds. --- src/modules/statistics/cog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/statistics/cog.py b/src/modules/statistics/cog.py index 1ef512d5..899ed657 100644 --- a/src/modules/statistics/cog.py +++ b/src/modules/statistics/cog.py @@ -46,6 +46,7 @@ class StatsCog(LionCog): "Display your personal profile and summary statistics." ) ) + @appcmds.guild_only async def me_cmd(self, ctx: LionContext): await ctx.interaction.response.defer(thinking=True) ui = ProfileUI(self.bot, ctx.author, ctx.guild) @@ -59,6 +60,7 @@ class StatsCog(LionCog): "Weekly and monthly statistics for your recent activity." ) ) + @appcmds.guild_only async def stats_cmd(self, ctx: LionContext): """ Statistics command. From 2a3948420b17383b1d774d317da76b848db60659 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Mon, 2 Oct 2023 01:25:46 +1300 Subject: [PATCH 31/31] Prevent attempts to send greeting message if the set channel is an Object - Class `GreetingChannel` under class `MemberAdminSettings` no longer accepts Objects as a value. --- src/modules/member_admin/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/member_admin/settings.py b/src/modules/member_admin/settings.py index 95fb79e8..057a202c 100644 --- a/src/modules/member_admin/settings.py +++ b/src/modules/member_admin/settings.py @@ -55,6 +55,7 @@ class MemberAdminSettings(SettingGroup): _model = CoreData.Guild _column = CoreData.Guild.greeting_channel.name + _allow_object = False @property def update_message(self) -> str: