from typing import TYPE_CHECKING, Optional import logging import asyncio import discord from discord.ext import commands as cmds from discord import app_commands as appcmds from discord.ui.select import select, Select, SelectOption from discord.ui.button import button, Button from meta import LionCog, LionContext, LionBot from meta.errors import SafeCancellation from meta.logger import log_wrap from utils import ui from utils.lib import error_embed from constants import MAX_COINS from wards import equippable_role from .. import babel from ..data import ShopData, ShopItemType from .base import ShopCog, Shop, Customer, Store, ShopItem if TYPE_CHECKING: from ..cog import Shopping from modules.economy.cog import Economy _p = babel._p logger = logging.getLogger(__name__) class ColourRoleItem(ShopItem): """ ShopItem representing an equippable Colour Role. """ @property def role(self) -> Optional[discord.Role]: """ Retrieves the discord Role corresponding to this colour role, if it exists. """ guild = self.bot.get_guild(self.data.guildid) if guild is not None: return guild.get_role(self.data.roleid) @property def name(self): """ An appropriate name for this Colour Role. Tries to use the role name if it is available. If it doesn't exist, instead uses the saved role id. """ if (role := self.role) is not None: return role.name else: return str(self.data.roleid) @property def mention(self): """ Helper method to mention the contained role. Avoids using the role object, in case it is not cached or no longer exists. """ return f"<@&{self.data.roleid}>" @property def price(self): """ The price of the item. """ return self.data.price @property def itemid(self): """ The global itemid for this item. """ return self.data.itemid @property def colour(self) -> Optional[discord.Colour]: """ Returns the colour of the linked role. If the linked role does not exist or cannot be found, returns None. """ if (role := self.role) is not None: return role.colour else: return None def select_option_for(self, customer: Customer, owned=False) -> SelectOption: t = customer.bot.translator.t value = str(self.itemid) if not owned: label = t(_p( 'ui:colourstore|menu:buycolours|label', "{name} ({price} LC)" )).format(name=self.name, price=self.price) else: label = t(_p( 'ui:colourstore|menu:buycolours|label', "{name} (This is your colour!)" )).format(name=self.name) if (colour := self.colour) is not None: description = t(_p( 'ui:colourstore|menu:buycolours|desc', "Colour: {colour}" )).format(colour=str(colour)) else: description = t(_p( 'ui:colourstore|menu:buycolours|desc', "Colour: Unknown" )) return SelectOption( label=label, value=value, description=description, default=owned ) class ColourShop(Shop): """ A Shop class representing a Colour shop for a given customer. """ _name_ = _p("shop:colours|name", 'Colour Shop') _item_type_ = ShopItemType.COLOUR def purchasable(self): """ Returns a list of ColourRoleItems that the customer can afford, and does not own. """ owned = self.owned() balance = self.customer.balance return [ item for item in self.items if (owned is None or item.itemid != owned.itemid) and (item.price <= balance) ] @log_wrap(action='purchase') async def purchase(self, itemid) -> ColourRoleItem: """ Atomically handle a purchase of a ColourRoleItem. Various errors may occur here, from the user not actually having enough funds, to the ColourRole not being purchasable (because of e.g. permissions), or even the `itemid` somehow not referring to a colour role in the correct guild. If the purchase completes successfully, returns the purchased ColourRoleItem. If the purchase fails for a known reason, raises SafeCancellation, with the error information. """ t = self.bot.translator.t async with self.bot.db.connection() as conn: self.bot.db.conn = conn async with conn.transaction(): # Retrieve the item to purchase from data item = await self.data.ShopItemInfo.table.select_one_where(itemid=itemid) # Ensure the item is purchasable and not deleted if not item['purchasable'] or item['deleted']: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:not_purchasable', "This item may not be purchased!" )) ) # Refresh the customer await self.customer.refresh() # Ensure the guild exists in cache guild = self.bot.get_guild(self.customer.guildid) if guild is None: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:no_guild', "Could not retrieve the server from Discord!" )) ) # Ensure the customer member actually exists member = await self.customer.lion.fetch_member() if member is None: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:no_member', "Could not retrieve the member from Discord." )) ) # Ensure the purchased role actually exists role = guild.get_role(item['roleid']) if role is None: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:no_role', "This colour role could not be found in the server." )) ) # Ensure the customer has enough coins for the item if self.customer.balance < item['price']: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:low_balance', "This item costs {coin}{amount}!\nYour balance is {coin}{balance}" )).format( coin=self.bot.config.emojis.getemoji('coin'), amount=item['price'], balance=self.customer.balance ) ) owned = self.owned() if owned is not None: # Ensure the customer does not already own the item if owned.itemid == item['itemid']: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:owned', "You already own this item!" )) ) # Charge the customer for the item economy_cog: Economy = self.bot.get_cog('Economy') economy_data = economy_cog.data transaction = await economy_data.ShopTransaction.purchase_transaction( guild.id, member.id, member.id, itemid, item['price'] ) # Add the item to the customer's inventory await self.data.MemberInventory.create( guildid=guild.id, userid=member.id, transactionid=transaction.transactionid, itemid=itemid ) # Give the customer the role (do rollback if this fails) try: await member.add_roles( role, atomic=True, reason="Purchased colour role" ) except discord.NotFound: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:failed_no_role', "This colour role no longer exists!" )) ) except discord.Forbidden: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:failed_permissions', "I do not have enough permissions to give you this colour role!" )) ) except discord.HTTPException: raise SafeCancellation( t(_p( 'shop:colour|purchase|error:failed_unknown', "An unknown error occurred while giving you this colour role!" )) ) # At this point, the purchase has succeeded and the user has obtained the colour role # Now, remove their previous colour role (if applicable) # TODO: We should probably add an on_role_delete event to clear defunct colour roles if owned is not None: owned_role = owned.role if owned_role is not None: try: await member.remove_roles( owned_role, reason="Removing old colour role.", atomic=True ) except discord.HTTPException: # Possibly Forbidden, or the role doesn't actually exist anymore (cache failure) pass await self.data.MemberInventory.table.delete_where(inventoryid=owned.data.inventoryid) # Purchase complete, update the shop and customer await self.refresh() return self.owned() async def refresh(self): """ Refresh the customer and item data. """ data = await self.data.ShopItemInfo.table.select_where( item_type=self._item_type_, deleted=False, guildid=self.customer.guildid ).order_by('itemid') self.items = [ColourRoleItem(self.bot, self.data.ShopItemInfo(row)) for row in data] await self.customer.refresh() def owned(self) -> Optional[ColourRoleItem]: """ Returns the ColourRoleItem currently owned by the Customer, if any. Since this item may have been deleted, it may not appear in the shop inventory! """ for item in self.customer.inventory: if item.item_type is self._item_type_: return ColourRoleItem(self.bot, item) def make_store(self, interaction: discord.Interaction): return ColourStore(self, interaction) @ShopCog.register class ColourShopping(ShopCog): """ Cog in charge of colour shopping. Registers colour shop related commands and methods. """ _shop_cls_ = ColourShop async def load_into(self, cog: 'Shopping'): self.crossload_group(self.editshop_group, cog.editshop_group) await cog.bot.add_cog(self) @LionCog.placeholder_group @cmds.hybrid_group('editshop', with_app_command=False) async def editshop_group(self, ctx: LionContext): pass @editshop_group.group(_p('grp:editshop_colours', 'colours')) async def editshop_colours_group(self, ctx: LionContext): pass @editshop_colours_group.command( name=_p('cmd:editshop_colours_create', 'create'), description=_p( 'cmd:editshop_colours_create|desc', "Create a new colour role with the given colour." ) ) @appcmds.rename( colour=_p('cmd:editshop_colours_create|param:colour', "colour"), name=_p('cmd:editshop_colours_create|param:name', "name"), price=_p('cmd:editshop_colours_create|param:price', "price") ) @appcmds.describe( colour=_p( 'cmd:editshop_colours_create|param:colour|desc', "What colour should the role be? (As a hex code, e.g. #AB22AB)" ), name=_p( 'cmd:editshop_colours_create|param:name|desc', "What should the colour role be called?" ), price=_p( 'cmd:editshop_colours_create|param:price|desc', "How much should the colour role cost?" ) ) async def editshop_colours_create_cmd(self, ctx: LionContext, colour: appcmds.Range[str, 3, 100], name: appcmds.Range[str, 1, 100], price: appcmds.Range[int, 0, MAX_COINS]): """ Create a new colour role with the specified attributes. """ t = self.bot.translator.t if not ctx.interaction: return if not ctx.guild: return try: actual_colour = discord.Colour.from_str(colour) except ValueError: raise SafeCancellation( t(_p( 'cmd:editshop_colours_create|error:parse_colour', "I could not extract a colour value from `{colour}`!\n" "Please enter the colour as a hex string, e.g. `#FA0BC1`" )).format(colour=colour) ) # Check we actually have permissions to create the role if not ctx.guild.me.guild_permissions.manage_roles: raise SafeCancellation( t(_p( 'cmd:editshop_colours_create|error:perms', "I do not have permission to create server roles!\n" "Please either give me this permission, " "or create the role manually and use `/editshop colours add` instead." )) ) # Check our current colour roles, make sure we don't have 25 already current = await self.data.ShopItemInfo.fetch_where( guildid=ctx.guild.id, item_type=self._shop_cls_._item_type_, deleted=False ) if len(current) >= 25: raise SafeCancellation( t(_p( 'cmd:editshop_colours_create|error:max_colours', "This server already has the maximum of `25` colour roles!\n" "Please remove some before adding or creating more." )) ) # Create the role try: role = await ctx.guild.create_role( name=name, colour=actual_colour, hoist=False, mentionable=False, reason="Creating Colour Role (/editshop colours create)" ) except discord.HTTPException: await ctx.reply( embed=error_embed( t(_p( 'cmd:editshop_colours_create|error:failed_unknown', "An unknown Discord error occurred while creating your colour role!\n" "Please try again in a few minutes." )) ), ephemeral=True ) await logger.warning( "Unexpected Discord exception occurred while creating a colour role.", exc_info=True ) return # Identify where we should put the role # If the server already has colour roles, we put it underneath those, # as long as that is below our own top role. # Otherwise, we leave them alone current_roles = (ctx.guild.get_role(item.roleid) for item in current) current_roles = [role for role in current_roles if role is not None] if current_roles: position = min(*current_roles, ctx.guild.me.top_role).position position -= 1 else: position = 0 if position > 0: # Due to the imprecise nature of Discord role ordering, this may fail. try: role = await role.edit(position=position) except discord.Forbidden: position = 0 # Now that the role is set up, add it to data item = await self.data.ShopItem.create( guildid=ctx.guild.id, item_type=self._shop_cls_._item_type_, price=price, purchasable=True ) await self.data.ColourRole.create( itemid=item.itemid, roleid=role.id ) # And finally ack the request embed = discord.Embed( colour=actual_colour, ) embed.title = t(_p( 'cmd:editshop_colours_create|resp:done|title', "Colour Role Created" )) embed.description = t(_p( 'cmd:editshop_colours_create|resp:done|desc', "You have created the role {mention}, " "and added it to the colour shop for {coin}**{price}**!" )).format(mention=role.mention, coin=self.bot.config.emojis.getemoji('coin'), price=price) if position == 0: note = t(_p( 'cmd:editshop_colours_create|resp:done|field:position_note|value', "The new colour role was added below all other roles. " "Remember a member's active colour is determined by their highest coloured role!" )) embed.add_field( name=t(_p('cmd:editshop_colours_create|resp:done|field:position_note|name', "Note")), value=note ) await ctx.reply( embed=embed, ) @editshop_colours_group.command( name=_p('cmd:editshop_colours_edit', 'edit'), description=_p( 'cmd:editshop_colours_edit|desc', "Edit the name, colour, or price of a colour role." ) ) @appcmds.rename( role=_p('cmd:editshop_colours_edit|param:role', "role"), name=_p('cmd:editshop_colours_edit|param:name', "name"), colour=_p('cmd:editshop_colours_edit|param:colour', "colour"), price=_p('cmd:editshop_colours_edit|param:price', "price"), ) @appcmds.describe( role=_p( 'cmd:editshop_colours_edit|param:role|desc', "Select a colour role to edit." ), name=_p( 'cmd:editshop_colours_edit|param:name|desc', "New name to give the colour role." ), colour=_p( 'cmd:editshop_colours_edit|param:colour|desc', "New colour for the colour role (as hex, e.g. #AB12AB)." ), price=_p( 'cmd:editshop_colours_edit|param:price|desc', "New price for the colour role." ), ) async def editshop_colours_edit_cmd(self, ctx: LionContext, role: discord.Role, name: Optional[appcmds.Range[str, 1, 100]], colour: Optional[appcmds.Range[str, 3, 100]], price: Optional[appcmds.Range[int, 0, MAX_COINS]]): """ Edit the provided colour role with the given attributes. """ t = self.bot.translator.t if not ctx.guild: return if not ctx.interaction: return # First check the provided role is actually a colour role items = await self.data.ShopItemInfo.fetch_where( guildid=ctx.guild.id, deleted=False, item_type=self._shop_cls_._item_type_, roleid=role.id ) if not items: await ctx.reply( embed=error_embed( t(_p( 'cmd:editshop_colours_edit|error:invalid_role', "{mention} is not in the colour role shop!" )).format(mention=role.mention) ), ephemeral=True ) return item = items[0] # Check that we actually have something to edit if name is None and colour is None and price is None: await ctx.reply( embed=error_embed( t(_p( 'cmd:editshop_colours_edit|error:no_args', "You must give me one of `name`, `colour`, or `price` to update!" )) ), ephemeral=True ) return # Check the colour works if colour is not None: try: actual_colour = discord.Colour.from_str(colour) except ValueError: await ctx.reply( embed=error_embed( t(_p( 'cmd:editshop_colours_edit|error:parse_colour', "I could not extract a colour value from `{colour}`!\n" "Please enter the colour as a hex string, e.g. `#FA0BC1`" )).format(colour=colour) ), ephemeral=True ) return if name is not None or colour is not None: # Check we actually have permissions to update the role if needed if not ctx.guild.me.guild_permissions.manage_roles or not ctx.guild.me.top_role > role: await ctx.reply( embed=error_embed( t(_p( 'cmd:editshop_colours_edit|error:perms', "I do not have sufficient server permissions to edit this role!" )) ), ephemeral=True ) return # Now update the information lines = [] if price is not None: await self.data.ShopItem.table.update_where( itemid=item.itemid ).set(price=price) lines.append( t(_p( 'cmd:editshop_colours_edit|resp:done|line:price', "{tick} Set price to {coin}**{price}**" )).format( tick=self.bot.config.emojis.getemoji('tick'), coin=self.bot.config.emojis.getemoji('coin'), price=price ) ) if name is not None or colour is not None: args = {} if name is not None: args['name'] = name if colour is not None: args['colour'] = actual_colour role = await role.edit(**args) lines.append( t(_p( 'cmd:editshop_colours_edit|resp:done|line:role', "{tick} Updated role to {mention}" )).format( tick=self.bot.config.emojis.getemoji('tick'), mention=role.mention ) ) description = '\n'.join(lines) await ctx.reply( embed=discord.Embed( title=t(_p('cmd:editshop_colours_edit|resp:done|embed:title', "Colour Role Updated")), description=description ) ) @editshop_colours_group.command( name=_p('cmd:editshop_colours_auto', 'auto'), description=_p('cmd:editshop_colours_auto|desc', "Automatically create a set of colour roles.") ) async def editshop_colours_auto_cmd(self, ctx: LionContext): """ Automatically create a set of colour roles. """ await ctx.reply("Not Implemented Yet") @editshop_colours_group.command( name=_p('cmd:editshop_colours_add', 'add'), description=_p( 'cmd:editshop_colours_add|desc', "Add an existing role to the colour shop." ) ) @appcmds.rename( role=_p('cmd:editshop_colours_add|param:role', "role"), price=_p('cmd:editshop_colours_add|param:price', "price") ) @appcmds.describe( role=_p( 'cmd:editshop_colours_add|param:role|desc', "Select a role to add to the colour shop." ), price=_p( 'cmd:editshop_colours_add|param:price|desc', "How much should this role cost?" ) ) async def editshop_colours_add_cmd(self, ctx: LionContext, role: discord.Role, price: appcmds.Range[int, 0, MAX_COINS]): """ Add a new colour role from an existing role. """ t = self.bot.translator.t if not ctx.interaction: return if not ctx.guild: return # Check our current colour roles, make sure we don't have 25 already current = await self.data.ShopItemInfo.fetch_where( guildid=ctx.guild.id, item_type=self._shop_cls_._item_type_, deleted=False ) if len(current) >= 25: raise SafeCancellation( t(_p( 'cmd:editshop_colours_add|error:max_colours', "This server already has the maximum of `25` colour roles!\n" "Please remove some before adding or creating more." )) ) # Also check the role isn't currently in the role list if role.id in [item.roleid for item in current]: raise SafeCancellation( t(_p( 'cmd:editshop_colours_add|error:role_exists', "The role {mention} is already registered as a colour role!" )).format(mention=role.mention) ) # Check that I have permission and ability to manage this role if not (ctx.guild.me.guild_permissions.manage_roles and role.is_assignable()): raise SafeCancellation( t(_p( 'cmd:editshop_colours_add|error:role_perms', "I do not have enough permissions to assign the role {mention}! " "Please ensure I have the `MANAGE_ROLES` permission, and that " "my top role is above this role." )).format(mention=role.mention) ) # Check that the author has permission to manage this role if not (ctx.author.guild_permissions.manage_roles): raise SafeCancellation( t(_p( 'cmd:editshop_colours_add|error:caller_perms', "You do not have sufficient server permissions to assign {mention} to the shop! " "You must have `MANAGE_ROLES`, and your top role must be above this role." )).format(mention=role.mention) ) # Final catch-all with more general error messages await equippable_role(self.bot, role, ctx.author) if role.permissions.administrator: raise SafeCancellation( t(_p( 'cmd:editshop_colours_add|error:role_has_admin', "I refuse to add an administrator role to the LionCoin shop. " "That is a really bad idea." )) ) # Add the role to data item = await self.data.ShopItem.create( guildid=ctx.guild.id, item_type=self._shop_cls_._item_type_, price=price, purchasable=True ) await self.data.ColourRole.create( itemid=item.itemid, roleid=role.id ) # And finally ack the request embed = discord.Embed( colour=role.colour, ) embed.title = t(_p('cmd:editshop_colours_add|resp:done|embed:title', "Colour Role Created")) embed.description = t(_p( 'cmd:editshop_colours_add|resp:done|embed:desc', "You have added {mention} to the colour shop for {coin}**{price}**!" )).format(mention=role.mention, coin=self.bot.config.emojis.getemoji('coin'), price=price) await ctx.reply( embed=embed, ) @editshop_colours_group.command( name=_p('cmd:editshop_colours_clear', 'clear'), description=_p( 'cmd:editshop_colours_clear|desc', "Remove all the colour roles from the shop, and optionally delete the roles." ) ) @appcmds.rename( delete=_p('cmd:editshop_colours_clear|param:delete', "delete_roles") ) @appcmds.rename( delete=_p( 'cmd:editshop_colours_clear|param:delete|desc', "Also delete the associated roles." ) ) async def editshop_colours_clear_cmd(self, ctx: LionContext, delete: Optional[bool]): """ Remove all of the colour roles. Optionally refund and/or delete the roles themselves. """ t = self.bot.translator.t if not ctx.guild: return if not ctx.interaction: return # TODO: Implement refund refund = False # Fetch our current colour roles current = await self.data.ShopItemInfo.fetch_where( guildid=ctx.guild.id, item_type=self._shop_cls_._item_type_, deleted=False ) itemids = [item.itemid for item in current] roles = (ctx.guild.get_role(item.roleid) for item in current) roles = [role for role in roles if role is not None] if refund: inventory_items = await self.data.MemberInventory.fetch_where( guildid=ctx.guild.id, itemid=itemids ) else: inventory_items = None # If we don't have any, error out gracefully if not current: await ctx.reply( embed=error_embed( t(_p( 'cmd:editshop_colours_clear|error:no_colours', "There are no coloured roles to remove!" )) ), ephemeral=True ) return deleted = [] delete_failed = [] refunded = [] async def delete_roles(): for role in roles: try: await role.delete(reason="Clearing colour role shop") deleted.append(role) except discord.HTTPException: delete_failed.append(role) await asyncio.sleep(0.2) async def refund_members(): """ Refunds the members with the colour roles. """ ... async def status_loop(): while True: try: done = await status_update() if done: break else: await asyncio.sleep(2) except asyncio.CancelledError: return async def status_update(): tick = self.bot.config.emojis.getemoji('tick') loading = self.bot.config.emojis.getemoji('loading') lines = [] cleared_line = t(_p( 'cmd:editshop_colours_clear|resp:done|line:clear', "{tick} Colour shop cleared." )).format(tick=tick) lines.append(cleared_line) done = True if refund: count = len(refunded) total = len(inventory_items) if count < total: refund_line = t(_p( 'cmd:editshop_colours_clear|resp:done|line:refunding', "{loading} Refunded **{count}/{total}** members." )) done = False else: refund_line = t(_p( 'cmd:editshop_colours_clear|resp:done|line:refunded', "{tick} Refunded **{total}/{total}** members." )) lines.append( refund_line.format(tick=tick, loading=loading, count=count, total=total) ) if delete: count = len(deleted) failed = len(delete_failed) total = len(roles) if failed: delete_line = t(_p( 'cmd:editshop_colours_clear|resp:done|line:deleted_failed', "{emoji} Deleted **{count}/{total}** colour roles. (**{failed}** failed!)" )) else: delete_line = t(_p( 'cmd:editshop_colours_clear|resp:done|line:deleted', "{emoji} Deleted **{count}/{total}** colour roles." )) if count + failed < total: done = False lines.append( delete_line.format( emoji=loading if count + failed < total else tick, count=count, total=total, failed=failed ) ) description = '\n'.join(lines) embed = discord.Embed( colour=discord.Colour.light_grey() if not done else discord.Colour.brand_green(), description=description ) await ctx.interaction.edit_original_response(embed=embed) return done await ctx.interaction.response.defer(thinking=True) # Run the clear await self.data.ShopItem.table.update_where(itemid=itemids).set(deleted=True) tasks = [] # Refund the members (slowly) if refund: tasks.append(asyncio.create_task(refund_members())) # Delete the roles (slowly) if delete: tasks.append(asyncio.create_task(delete_roles())) loop_task = None try: if tasks: loop_task = asyncio.create_task(status_loop()) tasks.append(loop_task) await asyncio.gather(*tasks) else: await status_update() finally: if loop_task is not None and not loop_task.done() and not loop_task.cancelled(): loop_task.cancel() await status_update() @editshop_colours_group.command( name=_p('cmd:editshop_colours_remove', 'remove'), description=_p( 'cmd:editshop_colours_remove|desc', "Remove a specific colour role from the shop." ) ) @appcmds.rename( role=_p('cmd:editshop_colours_remove|param:role', "role"), delete_role=_p('cmd:editshop_colours_remove', "delete_role") ) @appcmds.describe( role=_p( 'cmd:editshop_colours_remove|param:role|desc', "Select the colour role to remove." ), delete_role=_p( 'cmd:editshop_colours_remove|param:delete_role|desc', "Whether to delete the associated role." ) ) async def editshop_colours_remove_cmd(self, ctx: LionContext, role: discord.Role, delete_role: Optional[bool]): """ Remove a specific colour role. """ t = self.bot.translator.t if not ctx.guild: return if not ctx.interaction: return # First check the provided role is actually a colour role items = await self.data.ShopItemInfo.fetch_where( guildid=ctx.guild.id, deleted=False, item_type=self._shop_cls_._item_type_, roleid=role.id ) if not items: await ctx.reply( embed=error_embed( t(_p( 'cmd:editshop_colours_remove|error:not_colour', "{mention} is not in the colour role shop!" )).format(mention=role.mention) ), ephemeral=True ) return item = items[0] # Delete the item, respecting the delete setting. await self.data.ShopItem.table.update_where(itemid=item.itemid, deleted=True) if delete_role: role = ctx.guild.get_role(item.roleid) if role: try: await role.delete() role_msg = t(_p( 'cmd:editshop_colours_remove|resp:done|line:delete', "Successfully deleted the role." )) except discord.Forbidden: role_msg = t(_p( 'cmd:editshop_colours_remove|resp:done|line:delete', "I do not have sufficient permissions to delete the role." )) except discord.HTTPException: role_msg = t(_p( 'cmd:editshop_colours_remove|resp:done|line:delete', "Failed to delete the role for an unknown reason." )) else: role_msg = t(_p( 'cmd:editshop_colours_remove|resp:done|line:delete', "Could not find the role in order to delete it." )) else: role_msg = "" # Ack the action await ctx.reply( embed=discord.Embed( colour=discord.Colour.brand_green(), description=t(_p( 'cmd:editshop_colours_remove|resp:done|embed:desc', "Removed {mention} from the colour shop.\n{delete_line}" )).format(mention=role.mention, delete_line=role_msg) ) ) async def editshop_colours_remove_acmpl_item(self, interaction: discord.Interaction, partial: str): """ This is not currently in use. Intended to be transferred to `/shop buy` autocomplete. """ items = await self.data.ShopItemInfo.fetch_where( guildid=interaction.guild.id, deleted=False, item_type=self._shop_cls_._item_type_ ).order_by('itemid') if not items: return [ appcmds.Choice( name="The colour role shop is empty!", value=partial ) ] else: options = [ (str(i), "[{itemid:02}] | {price} LC | {colour} | @{name}".format( itemid=i, price=item.price, colour=role.colour if (role := interaction.guild.get_role(item.roleid)) is not None else "#??????", name=role.name if role is not None else "deleted-role" )) for i, item in enumerate(items, start=1) ] options = [option for option in options if partial.lower() in option[1].lower()] return [appcmds.Choice(name=option[1], value=option[0]) for option in options] class ColourStore(Store): """ Ephemeral UI providing access to the colour store. """ shop: ColourShop @select(placeholder="SELECT_PLACEHOLDER") async def select_colour(self, interaction: discord.Interaction, selection: Select): t = self.shop.bot.translator.t # User selected a colour from the list # Run purchase pathway for that item # The selection value should be the global itemid # However, if the selection is currently owned, do absolutely nothing. itemid = int(selection.values[0]) if (owned := self.shop.owned()) and owned.itemid == itemid: await interaction.response.defer() else: # Run purchase pathway await interaction.response.defer(ephemeral=True, thinking=True) try: item = await self.shop.purchase(itemid) except SafeCancellation as exc: embed = discord.Embed( title=t(_p('ui:colourstore|menu:buycolours|embed:error|title', "Purchase Failed!")), colour=discord.Colour.brand_red(), description=exc.msg ) await interaction.edit_original_response(embed=embed) else: # Ack purchase embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_p( 'ui:colourstore|menu:buycolours|resp:done|desc', "{tick} You have purchased {mention}" )).format( mention=item.mention, tick=self.shop.bot.config.emojis.getemoji('tick') ) ) await interaction.edit_original_response(embed=embed) await self.refresh() await self.redraw() async def select_colour_refresh(self): """ Refresh the select colour menu. For an item to be purchasable, it needs to be affordable and not currently owned by the member. """ t = self.shop.bot.translator.t selector = self.select_colour # Get the list of ColourRoleItems that may be purchased purchasable = self.shop.purchasable() owned = self.shop.owned() option_map: dict[int, SelectOption] = {} for item in purchasable: option_map[item.itemid] = item.select_option_for(self.shop.customer) if owned is not None and owned.role is not None: option_map[owned.itemid] = owned.select_option_for(self.shop.customer, owned=True) if not option_map: selector.placeholder = t(_p( 'ui:colourstore|menu:buycolours|placeholder', "There are no colour roles available to purchase!" )) selector.disabled = True else: selector.placeholder = t(_p( 'ui:colourstore|menu:buycolours|placeholder', "Select a colour role to purchase!" )) selector.disabled = False selector.options = list(option_map.values()) async def refresh(self): """ Refresh the UI elements """ await self.select_colour_refresh() if not self.select_colour.options: self._layout = [self.store_row] else: self._layout = [(self.select_colour,), self.store_row] self.embed = self.make_embed() def make_embed(self): """ Embed for this shop. """ t = self.shop.bot.translator.t if self.shop.items: owned = self.shop.owned() lines = [] for i, item in enumerate(self.shop.items): if owned is not None and item.itemid == owned.itemid: line = t(_p( 'ui:colourstore|embed|line:owned_item', "`[{j:02}]` | `{price} LC` | {mention} (You own this!)" )).format(j=i+1, price=item.price, mention=item.mention) else: line = t(_p( 'ui:colourstore|embed|line:item', "`[{j:02}]` | `{price} LC` | {mention}" )).format(j=i+1, price=item.price, mention=item.mention) lines.append(line) description = '\n'.join(lines) else: description = t(_p( 'ui:colourstore|embed|desc', "No colour roles available for purchase!" )) embed = discord.Embed( title=t(_p('ui:colourstore|embed|title', "Colour Role Shop")), description=description ) return embed