diff --git a/bot/constants.py b/bot/constants.py index b20a6e41..5c85e741 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,4 @@ CONFIG_FILE = "config/bot.conf" DATA_VERSION = 13 + +MAX_COINS = 2147483647 - 1 diff --git a/bot/core/lion.py b/bot/core/lion.py index aa8da5a9..9adae3a3 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -100,6 +100,22 @@ class Lion: if member.display_name != self.data.display_name: await self.data.update(display_name=member.display_name) + async def get_member(self) -> Optional[discord.Member]: + """ + Retrieve the member object for this Lion, if possible. + + If the guild or member cannot be retrieved, returns None. + """ + guild = self.bot.get_guild(self.guildid) + if guild is not None: + member = guild.get_member(self.userid) + if member is None: + try: + member = await guild.fetch_member(self.userid) + except discord.HTTPException: + pass + return member + class Lions(LionCog): def __init__(self, bot: LionBot): diff --git a/bot/modules/economy/cog.py b/bot/modules/economy/cog.py index c805193e..6ce843ce 100644 --- a/bot/modules/economy/cog.py +++ b/bot/modules/economy/cog.py @@ -12,6 +12,8 @@ from meta import LionCog, LionBot, LionContext from meta.errors import ResponseTimedOut from babel import LocalBabel +from core.data import CoreData + from utils.ui import LeoUI, LeoModal, Confirm, Pager from utils.lib import error_embed, MessageArgs, utc_now @@ -109,13 +111,27 @@ class EconomyData(Registry, name='economy'): @classmethod async def execute_transaction( + cls, transaction_type: TransactionType, guildid: int, actorid: int, - from_account: int, to_account: int, amount: int, - description: str, - note: Optional[str] = None, reference: Optional[str] = None, reminding: Optional[int] = None + from_account: int, to_account: int, amount: int, bonus: int = 0, + refunds: int = None ): - ... + transaction = await cls.create( + transactiontype=transaction_type, + guildid=guildid, actorid=actorid, amount=amount, bonus=bonus, + from_account=from_account, to_account=to_account, + refunds=refunds + ) + if from_account is not None: + await CoreData.Member.table.update_where( + guildid=guildid, userid=from_account + ).set(coins=(CoreData.Member.coins - (amount + bonus))) + if to_account is not None: + await CoreData.Member.table.update_where( + guildid=guildid, userid=to_account + ).set(coins=(CoreData.Member.coins + (amount + bonus))) + return transaction class ShopTransaction(RowModel): """ @@ -131,6 +147,19 @@ class EconomyData(Registry, name='economy'): transactionid = Integer(primary=True) itemid = Integer() + @classmethod + async def purchase_transaction( + cls, + guildid: int, actorid: int, + userid: int, itemid: int, amount: int + ): + row = await EconomyData.Transaction.execute_transaction( + TransactionType.PURCHASE, + guildid=guildid, actorid=actorid, from_account=userid, to_account=None, + amount=amount + ) + return await cls.create(transactionid=row.transactionid, itemid=itemid) + class TaskTransaction(RowModel): """ Schema diff --git a/bot/modules/pending-rewrite/economy/__init__.py b/bot/modules/pending-rewrite/economy/__init__.py deleted file mode 100644 index 8784ca9e..00000000 --- a/bot/modules/pending-rewrite/economy/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .module import module - -from . import send_cmd -from . import shop_cmds diff --git a/bot/modules/pending-rewrite/economy/module.py b/bot/modules/pending-rewrite/economy/module.py deleted file mode 100644 index 880fce2f..00000000 --- a/bot/modules/pending-rewrite/economy/module.py +++ /dev/null @@ -1,4 +0,0 @@ -from LionModule import LionModule - - -module = LionModule("Economy") diff --git a/bot/modules/pending-rewrite/economy/send_cmd.py b/bot/modules/pending-rewrite/economy/send_cmd.py deleted file mode 100644 index 5086ee67..00000000 --- a/bot/modules/pending-rewrite/economy/send_cmd.py +++ /dev/null @@ -1,91 +0,0 @@ -import discord -import datetime -from cmdClient.checks import in_guild - -from settings import GuildSettings -from core import Lion - -from .module import module - - -@module.cmd( - "send", - group="Economy", - desc="Send some coins to another member." -) -@in_guild() -async def cmd_send(ctx): - """ - Usage``: - {prefix}send - Description: - Send the given number of coins to the mentioned user. - Example: - {prefix}send {ctx.author.mention} 1000000 - """ - # Extract target and amount - # Handle a slightly more flexible input than stated - splits = ctx.args.split() - digits = [split.isdigit() for split in splits[:2]] - mentions = ctx.msg.mentions - if len(splits) < 2 or not any(digits) or not (all(digits) or mentions): - return await _send_usage(ctx) - - if all(digits): - # Both are digits, hopefully one is a member id, and one is an amount. - target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1]) - if not target: - amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1])) - if not target: - return await _send_usage(ctx) - elif digits[0]: - amount, target = int(splits[0]), mentions[0] - elif digits[1]: - target, amount = mentions[0], int(splits[1]) - - # Fetch the associated lions - target_lion = Lion.fetch(ctx.guild.id, target.id) - source_lion = Lion.fetch(ctx.guild.id, ctx.author.id) - - # Check sanity conditions - if amount > source_lion.coins: - return await ctx.error_reply( - "Sorry {}, you do not have enough LionCoins to do that.".format(ctx.author.mention) - ) - if target == ctx.author: - return await ctx.embed_reply("What is this, tax evasion?") - if target == ctx.client.user: - return await ctx.embed_reply("Thanks, but Ari looks after all my needs!") - if target.bot: - return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention)) - - # Finally, send the amount and the ack message - target_lion.addCoins(amount) - source_lion.addCoins(-amount) - - embed = discord.Embed( - title="Funds transferred", - description="You have sent **{}** LionCoins to {}!".format(amount, target.mention), - colour=discord.Colour.orange(), - timestamp=datetime.datetime.utcnow() - ).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url) - - await ctx.reply(embed=embed, reference=ctx.msg) - GuildSettings(ctx.guild.id).event_log.log( - "{} sent {} `{}` LionCoins.".format( - ctx.author.mention, - target.mention, - amount - ), - title="Funds transferred" - ) - - -async def _send_usage(ctx): - return await ctx.error_reply( - "**Usage:** `{prefix}send `\n" - "**Example:** {prefix}send {ctx.author.mention} 1000000".format( - prefix=ctx.best_prefix, - ctx=ctx - ) - ) diff --git a/bot/modules/pending-rewrite/economy/shop_cmds.py b/bot/modules/pending-rewrite/economy/shop_cmds.py deleted file mode 100644 index 1d3a0242..00000000 --- a/bot/modules/pending-rewrite/economy/shop_cmds.py +++ /dev/null @@ -1,246 +0,0 @@ -import asyncio -import discord -from collections import defaultdict - -from cmdClient.checks import in_guild - -from .module import module -from .shop_core import ShopItem, ShopItemType, ColourRole -from wards import is_guild_admin - - -class ShopSession: - __slots__ = ( - 'key', 'response', - '_event', '_task' - ) - _sessions = {} - - def __init__(self, userid, channelid): - # Build unique context key for shop session - self.key = (userid, channelid) - self.response = None - - # Cancel any existing sessions - if self.key in self._sessions: - self._sessions[self.key].cancel() - - self._event = asyncio.Event() - self._task = None - - # Add self to the session list - self._sessions[self.key] = self - - @classmethod - def get(cls, userid, channelid) -> 'ShopSession': - """ - Get a ShopSession matching the given key, if it exists. - Otherwise, returns None. - """ - return cls._sessions.get((userid, channelid), None) - - async def wait(self, timeout=None): - """ - Wait for a buy response. Return the set response or raise an appropriate exception. - """ - self._task = asyncio.create_task(self._event.wait()) - try: - await asyncio.wait_for(self._task, timeout=timeout) - except asyncio.CancelledError: - # Session was cancelled, likely due to creation of a new session - raise - except asyncio.TimeoutError: - # Session timed out, likely due to reaching the timeout - raise - finally: - if self._sessions.get(self.key, None) == self: - self._sessions.pop(self.key, None) - - return self.response - - def set(self, response): - """ - Set response. - """ - self.response = response - self._event.set() - - def cancel(self): - """ - Cancel a session. - """ - if self._task: - if self._sessions.get(self.key, None) == self: - self._sessions.pop(self.key, None) - self._task.cancel() - else: - raise ValueError("Cancelling a ShopSession that is already completed!") - - -@module.cmd( - 'shop', - group="Economy", - desc="Open the guild shop.", - flags=('add==', 'remove==', 'clear') -) -@in_guild() -async def cmd_shop(ctx, flags): - """ - Usage``: - {prefix}shop - {prefix}shop --add , - {prefix}shop --remove - {prefix}shop --remove itemid, itemid, ... - {prefix}shop --remove itemname, itemname, ... - {prefix}shop --clear - Description: - Opens the guild shop. Visible items may be bought using `{prefix}buy`. - - *Modifying the guild shop requires administrator permissions.* - """ - # TODO: (FUTURE) Register session (and cancel previous sessions) so we can track for buy - - # Whether we are modifying the shop - modifying = any(value is not False for value in flags.values()) - if modifying and not is_guild_admin(ctx.author): - return await ctx.error_reply( - "You need to be a guild admin to modify the shop!" - ) - - # Fetch all purchasable elements, this also populates the cache - shop_items = ShopItem.fetch_where(guildid=ctx.guild.id, deleted=False, purchasable=True) - - if not shop_items and not modifying: - # TODO: (FUTURE) Add tip for guild admins about setting up - return await ctx.error_reply( - "Nothing to buy! Please come back later." - ) - - # Categorise items - item_cats = defaultdict(list) - for item in shop_items: - item_cats[item.item_type].append(item) - - # If there is more than one category, ask for which category they want - # All FUTURE TODO stuff, to be refactored into a shop widget - # item_type = None - # if ctx.args: - # # Assume category has been entered - # ... - # elif len(item_cats) > 1: - # # TODO: Show selection menu - # item_type = ... - # ... - # else: - # # Pick the next one automatically - # item_type = next(iter(item_cats)) - - item_type = ShopItemType.COLOUR_ROLE - item_class = ColourRole - - if item_type is not None: - items = [item for item in item_cats[item_type]] - embeds = item_class.cat_shop_embeds( - ctx.guild.id, - [item.itemid for item in items] - ) - - if modifying: - if flags['add']: - await item_class.parse_add(ctx, flags['add']) - elif flags['remove'] is not False: - await item_class.parse_remove(ctx, flags['remove'], items) - elif flags['clear']: - await item_class.parse_clear(ctx) - return - - # Present shop pages - out_msg = await ctx.pager(embeds, add_cancel=True) - await ctx.cancellable(out_msg, add_reaction=False) - while True: - try: - response = await ShopSession(ctx.author.id, ctx.ch.id).wait(timeout=300) - except asyncio.CancelledError: - # User opened a new session - break - except asyncio.TimeoutError: - # Session timed out. Close the shop. - # TODO: (FUTURE) time out shop session by removing hint. - try: - embed = discord.Embed( - colour=discord.Colour.red(), - description="Shop closed! (Session timed out.)" - ) - await out_msg.edit( - embed=embed - ) - except discord.HTTPException: - pass - break - - # Selection was made - - # Sanity checks - # TODO: (FUTURE) Handle more flexible ways of selecting items - if int(response.args) >= len(items): - await response.error_reply( - "No item with this number exists! Please try again." - ) - continue - - item = items[int(response.args)] - if item.price > ctx.alion.coins: - await response.error_reply( - "Sorry, {} costs `{}` LionCoins, but you only have `{}`!".format( - item.display_name, - item.price, - ctx.alion.coins - ) - ) - continue - - # Run the selection and keep the shop open in case they want to buy more. - # TODO: (FUTURE) The item may no longer exist - success = await item.buy(response) - if success and not item.allow_multi_select: - try: - await out_msg.delete() - except discord.HTTPException: - pass - break - - -@module.cmd( - 'buy', - group="Hidden", - desc="Buy an item from the guild shop." -) -@in_guild() -async def cmd_buy(ctx): - """ - Usage``: - {prefix}buy - Description: - Only usable while you have a shop open (see `{prefix}shop`). - - Buys the selected item from the shop. - """ - # Check relevant session exists - session = ShopSession.get(ctx.author.id, ctx.ch.id) - if session is None: - return await ctx.error_reply( - "No shop open, nothing to buy!\n" - "Please open a shop with `{prefix}shop` first.".format(prefix=ctx.best_prefix) - ) - # Check input is an integer - if not ctx.args.isdigit(): - return await ctx.error_reply( - "**Usage:** `{prefix}buy `, for example `{prefix}buy 1`.".format(prefix=ctx.best_prefix) - ) - - # Pass context back to session - session.set(ctx) - - -# TODO: (FUTURE) Buy command short-circuiting the shop command and acting on shop sessions -# TODO: (FUTURE) Inventory command showing the user's purchases diff --git a/bot/modules/pending-rewrite/economy/shop_core/ColourRole.py b/bot/modules/pending-rewrite/economy/shop_core/ColourRole.py deleted file mode 100644 index 19153923..00000000 --- a/bot/modules/pending-rewrite/economy/shop_core/ColourRole.py +++ /dev/null @@ -1,377 +0,0 @@ -import re -import asyncio -from typing import List -import datetime -import discord - -from cmdClient.lib import SafeCancellation -from meta import client -from utils.lib import multiselect_regex, parse_ranges - -from .ShopItem import ShopItem, ShopItemType -from .data import shop_items, shop_item_info, colour_roles - - -@ShopItem.register_item_class -class ColourRole(ShopItem): - item_type = ShopItemType.COLOUR_ROLE - - allow_multi_select = False - buy_hint = ( - "Buy a colour by typing, e.g.,`{prefix}buy 0`.\n" - "**Note: You may only own one colour at a time!**" - ).format(prefix=client.prefix) - - @property - def display_name(self): - return "<@&{}>".format(self.data.roleid) - - @property - def roleid(self) -> int: - return self.data.roleid - - @property - def role(self) -> discord.Role: - return self.guild.get_role(self.roleid) - - @classmethod - async def create(cls, guildid, price, roleid, **kwargs): - """ - Create a new ColourRole item. - """ - shop_row = shop_items.insert( - guildid=guildid, - item_type=cls.item_type, - price=price - ) - colour_roles.insert( - itemid=shop_row['itemid'], - roleid=roleid - ) - return cls(shop_row['itemid']) - - # Shop interface - @classmethod - def _cat_shop_embed_items(cls, items: List['ColourRole'], hint: str = buy_hint, **kwargs) -> List[discord.Embed]: - """ - Embed a list of items specifically for displaying in the shop. - Subclasses will usually extend or override this, if only to add metadata. - """ - if items: - # TODO: prefix = items[0].guild_settings.prefix.value - - embeds = cls._cat_embed_items(items, **kwargs) - for embed in embeds: - embed.title = "{} shop!".format(cls.item_type.desc) - embed.description += "\n\n" + hint - else: - embed = discord.Embed( - title="{} shop!".format(cls.item_type.desc), - description="No colours available at the moment! Please come back later." - ) - embeds = [embed] - return embeds - - async def buy(self, ctx): - """ - Action when a member buys a color role. - - Uses Discord as the source of truth (rather than the future inventory). - Removes any existing colour roles, and adds the purchased role. - Also notifies the user and logs the transaction. - """ - # TODO: Exclusivity should be handled at a higher level - # TODO: All sorts of error handling - member = ctx.author - - # Fetch the set colour roles - colour_rows = shop_item_info.fetch_rows_where( - guildid=self.guildid, - item_type=self.item_type - ) - roleids = (row.roleid for row in colour_rows) - roles = (self.guild.get_role(roleid) for roleid in roleids) - roles = set(role for role in roles if role) - - # Compute the roles to add and remove - to_add = self.guild.get_role(self.roleid) - member_has = roles.intersection(member.roles) - if to_add in member_has: - await ctx.error_reply("You already have this colour!") - return False - - to_remove = list(member_has) - - # Role operations - if to_add: - try: - await member.add_roles(to_add, reason="Updating purchased colour role") - except discord.HTTPException: - # TODO: Add to log - to_add = None - pass - - if to_remove: - try: - await member.remove_roles(*to_remove, reason="Updating purchased colour role") - except discord.HTTPException: - # TODO: Add to log - pass - - # Only charge the member if everything went well - if to_add: - ctx.alion.addCoins(-self.price) - - # Build strings for logging and response - desc = None # Description of reply message to the member - log_str = None # Description of event log message - log_error = False # Whether to log an error - - if to_add: - if to_remove: - if len(to_remove) > 1: - rem_str = ', '.join(role.mention for role in to_remove[:-1]) + 'and' + to_remove[-1].mention - else: - rem_str = to_remove[0].mention - desc = "You have exchanged {} for {}. Enjoy!".format(rem_str, to_add.mention) - log_str = "{} exchanged {} for {}.".format( - member.mention, - rem_str, - to_add.mention - ) - else: - desc = "You have bought {}. Enjoy!".format(to_add.mention) - log_str = "{} bought {}.".format(member.mention, to_add.mention) - else: - desc = ( - "Something went wrong! Please try again later.\n" - "(I don't have the server permissions to give this role to you!)" - ) - log_str = ( - "{} bought `{}`, but I couldn't add the role!\n" - "Please ensure that I have permission to manage this role.\n" - "(I need to have the `manage_roles` permission, " - "and my top role needs to be above the colour roles.)" - ).format(member.mention, self.roleid) - log_error = True - - # Build and send embeds - reply_embed = discord.Embed( - colour=to_add.colour if to_add else discord.Colour.red(), - description=desc, - timestamp=datetime.datetime.utcnow() - ) - if to_add: - reply_embed.set_footer( - text="New Balance: {} LC".format(ctx.alion.coins) - ) - log_embed = discord.Embed( - title="Colour Role Purchased" if not log_error else "Error purchasing colour role.", - colour=discord.Colour.red() if log_error else discord.Colour.orange(), - description=log_str - ) - try: - await ctx.reply(embed=reply_embed) - except discord.HTTPException: - pass - - event_log = ctx.guild_settings.event_log.value - if event_log: - try: - await event_log.send(embed=log_embed) - except discord.HTTPException: - pass - - if not to_add: - return False - - return True - - # Shop admin interface - @classmethod - async def parse_add(cls, ctx, args): - """ - Parse a request to add colour roles. - Syntax: `, `, with different lines treated as different entries. - - Assumes the author is an admin. - """ - if not args: - raise SafeCancellation("No arguments given, nothing to do!") - - lines = args.splitlines() - to_add = [] # List of (price, role) tuples to add - for line in lines: - # Syntax: , - splits = line.split(',') - splits = [split.strip() for split in splits] - if len(splits) < 2 or not splits[0].isdigit(): - raise SafeCancellation("**Syntax:** `--add , `") - price = int(splits[0]) - role = await ctx.find_role(splits[1], create=True, interactive=True, allow_notfound=False) - to_add.append((price, role)) - - # Add the roles to data - for price, role in to_add: - # TODO: Batch update would be best - await cls.create(ctx.guild.id, price, role.id) - - # Report back - if len(to_add) > 1: - await ctx.reply( - embed=discord.Embed( - title="Shop Updated", - description="Added `{}` new colours to the shop!".format(len(to_add)) - ) - ) - else: - await ctx.reply( - embed=discord.Embed( - title="Shop Updated", - description="Added {} to the shop for `{}` coins.".format(to_add[0][1].mention, to_add[0][0]) - ) - ) - - @classmethod - async def parse_remove(cls, ctx, args, items): - """ - Parse a request to remove colour roles. - Syntax: `` or `` - - Assumes the author is an admin. - """ - if not items: - raise SafeCancellation("Colour shop is empty, nothing to delete!") - - to_delete = [] - if args: - if re.search(multiselect_regex, args): - # ids were selected - indexes = parse_ranges(args) - to_delete = [items[index] for index in indexes if index < len(items)] - - if not to_delete: - # Assume comma separated list of roles - splits = args.split(',') - splits = [split.strip() for split in splits] - available_roles = (item.role for item in items) - available_roles = [role for role in available_roles if role] - roles = [ - await ctx.find_role(rolestr, collection=available_roles, interactive=True, allow_notfound=False) - for rolestr in splits - ] - roleids = set(role.id for role in roles) - to_delete = [item for item in items if item.roleid in roleids] - else: - # Interactive multi-selector - itemids = [item.itemid for item in items] - embeds = cls.cat_shop_embeds( - ctx.guild.id, - itemids, - hint=("Please select colour(s) to remove, or `c` to cancel.\n" - "(Respond with e.g. `1, 2, 3` or `1-3`.)") - ) - out_msg = await ctx.pager(embeds) - - def check(msg): - valid = msg.channel == ctx.ch and msg.author == ctx.author - valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c') - return valid - - try: - message = await ctx.client.wait_for('message', check=check, timeout=60) - except asyncio.TimeoutError: - await out_msg.delete() - await ctx.error_reply("Session timed out. No colour roles were removed.") - return - - try: - await out_msg.delete() - await message.delete() - except discord.HTTPException: - pass - - if message.content.lower() == 'c': - return - - to_delete = [ - items[index] - for index in parse_ranges(message.content) if index < len(items) - ] - if not to_delete: - raise SafeCancellation("Nothing to delete!") - - # Build an ack string before we delete - rolestr = to_delete[0].role.mention if to_delete[0].role else "`{}`".format(to_delete[0].roleid) - - # Delete the items - shop_items.delete_where(itemid=[item.itemid for item in to_delete]) - - # Update the info cache - [shop_item_info.row_cache.pop(item.itemid, None) for item in to_delete] - - # Ack and log - if len(to_delete) > 1: - try: - await ctx.reply( - embed=discord.Embed( - title="Colour Roles removed", - description="You have removed `{}` colour roles.".format(len(to_delete)), - colour=discord.Colour.orange() - ) - ) - except discord.HTTPException: - pass - event_log = ctx.guild_settings.event_log.value - if event_log: - try: - await event_log.send( - embed=discord.Embed( - title="Colour Roles deleted", - description="{} removed `{}` colour roles from the shop.".format( - ctx.author.mention, - len(to_delete) - ), - timestamp=datetime.datetime.utcnow() - ) - ) - except discord.HTTPException: - pass - else: - try: - await ctx.reply( - embed=discord.Embed( - title="Colour Role removed", - description="You have removed the colour role {}.".format(rolestr), - colour=discord.Colour.orange() - ) - ) - except discord.HTTPException: - pass - event_log = ctx.guild_settings.event_log.value - if event_log: - try: - await event_log.send( - embed=discord.Embed( - title="Colour Role deleted", - description="{} removed the colour role {} from the shop.".format( - ctx.author.mention, - rolestr - ), - timestamp=datetime.datetime.utcnow() - ) - ) - except discord.HTTPException: - pass - - @classmethod - async def parse_clear(cls, ctx): - """ - Parse a request to clear colour roles. - - Assumes the author is an admin. - """ - if await ctx.ask("Are you sure you want to remove all colour roles from the shop?"): - shop_items.delete_where(guildid=ctx.guild.id, item_type=cls.item_type) - await ctx.reply("All colour roles removed from the shop.") - ctx.guild_settings.event_log.log("{} cleared the colour role shop.".format(ctx.author.mention)) diff --git a/bot/modules/pending-rewrite/economy/shop_core/ShopItem.py b/bot/modules/pending-rewrite/economy/shop_core/ShopItem.py deleted file mode 100644 index fe505ec8..00000000 --- a/bot/modules/pending-rewrite/economy/shop_core/ShopItem.py +++ /dev/null @@ -1,265 +0,0 @@ -import discord -import datetime -from typing import List - -from meta import client -from utils.lib import FieldEnum -from data import Row -from settings import GuildSettings - -from .data import shop_items, shop_item_info - - -class ShopItemType(FieldEnum): - COLOUR_ROLE = 'COLOUR_ROLE', 'Colour' - - -class ShopItem: - """ - Abstract base class representing an item in a guild shop. - """ - __slots__ = ('itemid', '_guild') - - # Mapping of item types to class handlers - _item_classes = {} # ShopItemType -> ShopItem subclass - - # Item type handled by the current subclass - item_type = None # type: ShopItemType - - # Format string to use for each item of this type in the shop embed - shop_fmt = "`[{num:<{num_len}}]` | `{item.price:<{price_len}} LC` {item.display_name}" - - # Shop input modifiers - allow_multi_select = True - buy_hint = None - - def __init__(self, itemid, *args, **kwargs): - self.itemid = itemid # itemid in shop_items representing this info - self._guild = None # cached Guild - - # Meta - @classmethod - def register_item_class(cls, itemcls): - """ - Decorator to register a class as a handler for a given item type. - The item type must be set as the `item_type` class attribute. - """ - cls._item_classes[itemcls.item_type] = itemcls - return itemcls - - @classmethod - async def create(cls, guildid, price, *args, **kwargs): - """ - Create a new ShopItem of this type. - Must be implemented by each item class. - """ - raise NotImplementedError - - @classmethod - def fetch_where(cls, **kwargs): - """ - Fetch ShopItems matching the given conditions. - Automatically filters by `item_type` if set in class, and not provided. - """ - if cls.item_type is not None and 'item_type' not in kwargs: - kwargs['item_type'] = cls.item_type - rows = shop_item_info.fetch_rows_where(**kwargs) - return [ - cls._item_classes[ShopItemType(row.item_type)](row.itemid) - for row in rows - ] - - @classmethod - def fetch(cls, itemid): - """ - Fetch a single ShopItem by itemid. - """ - row = shop_item_info.fetch(itemid) - if row: - return cls._item_classes[ShopItemType(row.item_type)](row.itemid) - else: - return None - - # Data and transparent data properties - @property - def data(self) -> Row: - """ - Return the cached data row for this item. - This is not guaranteed to be up to date. - This is also not guaranteed to keep existing during a session. - """ - return shop_item_info.fetch(self.itemid) - - @property - def guildid(self) -> int: - return self.data.guildid - - @property - def price(self) -> int: - return self.data.price - - @property - def purchasable(self) -> bool: - return self.data.purchasable - - # Computed properties - @property - def guild(self) -> discord.Guild: - if not self._guild: - self._guild = client.get_guild(self.guildid) - return self._guild - - @property - def guild_settings(self) -> GuildSettings: - return GuildSettings(self.guildid) - - # Display properties - @property - def display_name(self) -> str: - """ - Short name to display after purchasing the item, and by default in the shop. - """ - raise NotImplementedError - - # Data manipulation methods - def refresh(self) -> Row: - """ - Refresh the stored data row. - """ - shop_item_info.row_cache.pop(self.itemid, None) - return self.data - - def _update(self, **kwargs): - """ - Updates the data with the provided kwargs. - Subclasses are expected to override this is they provide their own updatable data. - - This method does *not* refresh the data row. This is expect to be handled by `update`. - """ - handled = ('price', 'purchasable') - - update = {key: kwargs[key] for key in handled} - if update: - shop_items.update_where( - update, - itemid=self.itemid - ) - - async def update(self, **kwargs): - """ - Update the shop item with the given kwargs. - """ - self._update() - self.refresh() - - # Formatting - @classmethod - def _cat_embed_items(cls, items: List['ShopItem'], blocksize: int = 20, - fmt: str = shop_fmt, **kwargs) -> List[discord.Embed]: - """ - Build a list of embeds for the current item type from a list of items. - These embeds may be used anywhere multiple items may be shown, - including confirmations and shop pages. - Subclasses may extend or override. - """ - embeds = [] - if items: - # Cut into blocks - item_blocks = [items[i:i+blocksize] for i in range(0, len(items), blocksize)] - for i, item_block in enumerate(item_blocks): - # Compute lengths - num_len = len(str((i * blocksize + len(item_block) - 1))) - max_price = max(item.price for item in item_block) - price_len = len(str(max_price)) - - # Format items - string_block = '\n'.join( - fmt.format( - item=item, - num=i * blocksize + j, - num_len=num_len, - price_len=price_len - ) for j, item in enumerate(item_block) - ) - - # Build embed - embed = discord.Embed( - description=string_block, - timestamp=datetime.datetime.utcnow() - ) - if len(item_blocks) > 1: - embed.set_footer(text="Page {}/{}".format(i+1, len(item_blocks))) - - embeds.append(embed) - else: - # Empty shop case, should generally be avoided - embed = discord.Embed( - description="Nothing to show!" - ) - embeds.append(embed) - - return embeds - - # Shop interface - @classmethod - def _cat_shop_embed_items(cls, items: List['ShopItem'], **kwargs) -> List[discord.Embed]: - """ - Embed a list of items specifically for displaying in the shop. - Subclasses will usually extend or override this, if only to add metadata. - """ - if items: - # TODO: prefix = items[0].guild_settings.prefix.value - prefix = client.prefix - - embeds = cls._cat_embed_items(items, **kwargs) - for embed in embeds: - embed.title = "{} shop!".format(cls.item_type.desc) - embed.description = "{}\n\n{}".format( - embed.description, - "Buy items with `{prefix}buy `, e.g. `{prefix}buy 1, 2, 3`.".format( - prefix=prefix - ) - ) - else: - embed = discord.Embed( - title="{} shop!".format(cls.item_type.desc), - description="This shop is empty! Please come back later." - ) - embeds = [embed] - return embeds - - @classmethod - def cat_shop_embeds(cls, guildid: int, itemids: List[int] = None, **kwargs) -> List[discord.Embed]: - """ - Format the items of this type (i.e. this category) as one or more embeds. - Subclasses may extend or override. - """ - if itemids is None: - # Get itemids if not provided - # TODO: Not using the row cache here, make sure we don't need an extended caching form - rows = shop_item_info.fetch_rows_where( - guildid=guildid, - item_type=cls.item_type, - purchasable=True, - deleted=False - ) - itemids = [row.itemid for row in rows] - elif not all(itemid in shop_item_info.row_cache for itemid in itemids): - # Ensure cache is populated - shop_item_info.fetch_rows_where(itemid=itemids) - - return cls._cat_shop_embed_items([cls(itemid) for itemid in itemids], **kwargs) - - async def buy(self, ctx): - """ - Action to trigger when a member buys this item. - """ - raise NotImplementedError - - # Shop admin interface - @classmethod - async def parse_new(self, ctx): - """ - Parse new shop items. - """ - raise NotImplementedError diff --git a/bot/modules/pending-rewrite/economy/shop_core/__init__.py b/bot/modules/pending-rewrite/economy/shop_core/__init__.py deleted file mode 100644 index 62767d8e..00000000 --- a/bot/modules/pending-rewrite/economy/shop_core/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import data - -from .ShopItem import ShopItem, ShopItemType -from .ColourRole import ColourRole diff --git a/bot/modules/pending-rewrite/economy/shop_core/data.py b/bot/modules/pending-rewrite/economy/shop_core/data.py deleted file mode 100644 index 25b7882a..00000000 --- a/bot/modules/pending-rewrite/economy/shop_core/data.py +++ /dev/null @@ -1,19 +0,0 @@ -from cachetools import LRUCache - -from data import Table, RowTable - - -shop_items = Table('shop_items') - -colour_roles = Table('shop_items_colour_roles', attach_as='colour_roles') - - -shop_item_info = RowTable( - 'shop_item_info', - ('itemid', - 'guildid', 'item_type', 'price', 'purchasable', 'deleted', 'created_at', - 'roleid', # Colour roles - ), - 'itemid', - cache=LRUCache(1000) -) diff --git a/bot/modules/pending-rewrite/economy/shopadmin_cmd.py b/bot/modules/pending-rewrite/economy/shopadmin_cmd.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/modules/shop/cog.py b/bot/modules/shop/cog.py index c72aca27..e932f15f 100644 --- a/bot/modules/shop/cog.py +++ b/bot/modules/shop/cog.py @@ -1,3 +1,66 @@ +""" +The initial Shopping Cog acts as the entry point to the shopping system. +It provides the commands: + /shop open [type:] + /shop buy > +It also provides the "/editshop" group, +which each shop type should hook into (via placeholder groups) +to provide a shop creation and edit interface. + +A "Shop" represents a colour shop, role shop, boost shop, etc. +Each Shop has a different editing interface (the ShopCog), +and a different user interface page (the Store). + +When a user runs /shop open, the "StoreManager" opens. +This PagedUI serves as a top-level manager for each shop store, +even though it has no UI elements itself. + +The user sees the first Store page, and can freely use the store UI +to interact with that Shop. +They may also use the "shop row" buttons to switch between shops. + +Now, onto the individual shop systems. + +When the user first opens a shop, a Customer is created. +The Customer represents the member themselves (i.e. the lion), +along with their inventory (a list of raw items that they own). +For fast access (for e.g. autocomplete), the Customer may be +cached, as long as it has a unique registry map. + +Each initialised Shop has a collection of ShopItems. +The ShopItems represent individual objects of that shop, +and usually contain state particular to that shop. +The ShopItems shouldn't need to remember their origin shop, +or the Customer. + +The Shop itself handles purchases, checking whether a customer can purchase +a given item, and running the purchase logic for an item. + +Note: Timing out Acmpl state to handle caching shared states between acmpl? + +Note: shop_item_info table which joins the others? Include guild_item_id, +and cache the items. + +/shop open: + - Create the Customer + - Initialise the Shops, with the current Guild and the Customer + - Pass the shop stores to the StoreManager, along with the command interaction + - Run display on the StoreManager, which displays the first Store UI + - The StoreManager gets a store button from each store, then passes those back to the individuals + - This would be best done with a UILayout modification, but we don't have that yet + - The Store displays its UI, which is Customer-dependent, relying on the Shop for most computations. + - (The Store might not even need to keep the Customer, just using Shop methods to access them.) + - The Customer may make a purchase/refund/etc, which gets mapped back to the Shop. + - After the Customer has made an action, the Store refreshes its UI, from the Shop data. +/shop buy : + - Instantiates the Customer and Shops (via acmpl state?), which should _not_ require any data lookups. + - Acmpl shows an intelligent list of matching items, nicely formatted + - (Shopitem itself can be responsible for formatting?) + - (Shop can be responsible for showing which items the user can purchase?) + - Command gets the shopitemid (possibly partitioned by guild), gets that item, gets the item Shop. + - Command then gets the Shop to purchase that item. +""" + import asyncio import logging from typing import Optional @@ -7,12 +70,13 @@ from discord.ext import commands as cmds from discord import app_commands as appcmds from meta import LionBot, LionCog, LionContext - from utils import ui +from utils.lib import error_embed from . import babel -from .shops.colours import ColourShop +from .shops.base import Customer, ShopCog +from .data import ShopData logger = logging.getLogger(__name__) @@ -20,90 +84,163 @@ _p = babel._p class Shopping(LionCog): - Shops = [ColourShop] + # List of active Shop cogs + ShopCogs = ShopCog.active def __init__(self, bot: LionBot): self.bot = bot - self.data = None - self.shops = [] + self.data = bot.db.load_registry(ShopData()) + self.active_cogs = [] async def cog_load(self): - for Shop in self.Shops: - shop = Shop(self.bot, self.data) - await shop.load_into(self) - self.shops.append(shop) + await self.data.init() + for SCog in self.ShopCogs: + shop_cog = SCog(self.bot, self.data) + await shop_cog.load_into(self) + self.active_cogs.append(shop_cog) async def cog_unload(self): for shop in self.shops: await shop.unload() - @cmds.hybrid_group(name='editshop') + @cmds.hybrid_group( + name=_p('group:editshop', 'editshop') + ) async def editshop_group(self, ctx: LionContext): return - @cmds.hybrid_group(name='shop') + @cmds.hybrid_group( + name=_p('group:shop', 'shop') + ) async def shop_group(self, ctx: LionContext): return - @shop_group.command(name='open') + @shop_group.command( + name=_p('cmd:shop_open', 'open'), + description=_p('cmd:shop_open|desc', "Open the server shop.") + ) async def shop_open_cmd(self, ctx: LionContext): """ Opens the shop UI for the current guild. """ - ... + t = self.bot.translator.t + + # Typechecker guards + if not ctx.guild: + return + if not ctx.interaction: + return + + await ctx.interaction.response.defer(ephemeral=True, thinking=True) + + # Create the Customer + customer = await Customer.fetch(self.bot, self.data, ctx.guild.id, ctx.author.id) + + # Create the Shops + shops = [await cog.make_shop_for(customer) for cog in self.active_cogs] + + # TODO: Filter by shops which actually have items + if not shops: + await ctx.reply( + embed=error_embed( + t(_p('cmd:shop_open|error:no_shops', "There is nothing to buy!")) + ), + ephemeral=True + ) + return + + # Extract the Stores + stores = [shop.make_store(ctx.interaction) for shop in shops] + + # Build the StoreManager from the Stores + manager = StoreManager(self.bot, self.data, stores) + + # Display the StoreManager + await manager.run(ctx.interaction) + + await manager.wait() + + # TODO: shortcut shop buy command class StoreManager(ui.LeoUI): - def __init__(self, bot, data, shops): + def __init__(self, bot, data, stores, **kwargs): + super().__init__(**kwargs) + self.bot = bot self.data = data - self.shops = shops + self.stores = stores self.page_num = 0 + # Original interaction that opened this shop self._original: Optional[discord.Interaction] = None + # tuple of Buttons to each active store self._store_row = self.make_buttons() - self._widgets = self.prepare_widgets() async def redraw(self): """ Ask the current shop widget to redraw. """ - ... + self.page_num %= len(self.stores) + await self.stores[self.page_num].refresh() + await self.stores[self.page_num].redraw() def make_buttons(self): """ Make a tuple of shop buttons. - If there is only one shop, return an empty tuple. + If there is only one shop, returns an empty tuple. """ - if len(self.shops) <= 1: - return () - buttons = [] - for i, shop in enumerate(self.shops): - @ui.AButton(label=shop.name) - async def pressed_switch_shop(press: discord.Interaction, pressed): - await press.response.defer() - await self.change_page(i) - buttons.append(pressed_switch_shop) - return tuple(buttons) + t = self.bot.translator.t - def prepare_widgets(self): - widgets = [] - for shop in self.shops: - widget = shop.make_widget() - # TODO: Update this when we have a UILayout class - # widget.layout.set_row('shops', self._store_row, affinity=1) - widget.shop_row = self._store_row - widgets.append(widget) - return widgets + buttons = [] + if len(self.stores) > 1: + for i, store in enumerate(self.stores): + @ui.AButton(label=store.shop.name) + async def pressed_switch_shop(press: discord.Interaction, pressed): + await press.response.defer() + await self.change_page(i) + buttons.append(pressed_switch_shop) + + @ui.AButton( + label=_p('ui:stores|button:close|label', "Close"), + emoji=self.bot.config.emojis.getemoji('cancel') + ) + async def pressed_close(press: discord.Interaction, pressed): + await press.response.defer() + if not self._original.is_expired(): + embed = discord.Embed( + title=t(_p('ui:stores|button:close|response|title', "Shop Closed")), + colour=discord.Colour.orange() + ) + await self._original.edit_original_response(embed=embed, view=None) + await self.close() + buttons.append(pressed_close) + for button in buttons: + self.add_item(button) + return tuple(buttons) async def change_page(self, i): """ Change to the given page number. """ - ... + self.page_num = i + self.page_num %= len(self.stores) + await self.redraw() async def run(self, interaction): + self._original = interaction + for store in self.stores: + self.children.append(store) + store.set_store_row(self._store_row) + await self.redraw() + + async def monitor(self): + """ + When one of the stores closes, we want all the stores to close, + along with this parent UI. + """ + # TODO ... diff --git a/bot/modules/shop/data.py b/bot/modules/shop/data.py index 053a090a..c6ac9231 100644 --- a/bot/modules/shop/data.py +++ b/bot/modules/shop/data.py @@ -1,4 +1,4 @@ -from enums import Enum +from enum import Enum from cachetools import TTLCache @@ -59,6 +59,35 @@ class ShopData(Registry, name='shop'): itemid = Integer(primary=True) roleid = Integer() + class ShopItemInfo(RowModel): + """ + A view joining the shop item sub-type information, + and including the guild id. + + Schema + ------ + CREATE VIEW shop_item_info AS + SELECT + *, + row_number() OVER (PARTITION BY guildid ORDER BY itemid) AS guild_itemid + FROM + shop_items + LEFT JOIN shop_items_colour_roles USING (itemid) + ORDER BY itemid ASC; + """ + _tablename_ = 'shop_item_info' + _readonly_ = True + + itemid = Integer(primary=True) + guild_itemid = Integer() + guildid = Integer() + item_type: Column[ShopItemType] = Column() + price = Integer() + purchasable = Bool() + deleted = Bool() + created_at = Timestamp() + roleid = Integer() + class MemberInventory(RowModel): """ Schema @@ -80,8 +109,46 @@ class ShopData(Registry, name='shop'): transactionid = Integer() itemid = Integer() - async def fetch_inventory(self, guildid, userid) -> list['ShopData.MemberInventory']: + class MemberInventoryInfo(RowModel): + """ + Composite view joining the member inventory with shop item information. + + Schema + ------ + CREATE VIEW member_inventory_info AS + SELECT + inv.inventoryid AS inventoryid, + inv.guildid AS guildid, + inv.userid AS userid, + inv.transactionid AS transactionid, + items.itemid AS itemid, + items.item_type AS item_type, + items.price AS price, + items.purchasable AS purchasable, + items.deleted AS deleted + FROM + member_inventory inv + LEFT JOIN shop_item_info items USING (itemid) + ORDER BY itemid ASC; + """ + _tablename_ = 'member_inventory_info' + _readonly_ = True + + inventoryid = Integer(primary=True) + guildid = Integer() + userid = Integer() + transactionid = Integer() + itemid = Integer() + guild_itemid = Integer() + item_type: Column[ShopItemType] = Column() + price = Integer() + purchasable = Bool() + deleted = Bool() + roleid = Integer() + + @classmethod + async def fetch_inventory_info(cls, guildid, userid) -> list['ShopData.MemberInventoryInfo']: """ - Fetch the given member's inventory. + Fetch the information rows for the given members inventory. """ - return await self.fetch_where(guildid=guildid, userid=userid) + return await cls.fetch_where(guildid=guildid, userid=userid) diff --git a/bot/modules/shop/shops/__init__.py b/bot/modules/shop/shops/__init__.py index e69de29b..f72ac15a 100644 --- a/bot/modules/shop/shops/__init__.py +++ b/bot/modules/shop/shops/__init__.py @@ -0,0 +1,2 @@ +from . import base +from . import colours diff --git a/bot/modules/shop/shops/base.py b/bot/modules/shop/shops/base.py index 90ca7fee..b682d234 100644 --- a/bot/modules/shop/shops/base.py +++ b/bot/modules/shop/shops/base.py @@ -1,81 +1,225 @@ +from typing import Type, TYPE_CHECKING +from weakref import WeakValueDictionary + import discord from discord.ui.button import Button -from meta import LionBot - +from meta import LionBot, LionCog from utils import ui +from babel.translator import LazyStr from ..data import ShopData +if TYPE_CHECKING: + from core.lion import Lion -class MemberInventory: + +class ShopCog(LionCog): + """ + Minimal base class for a ShopCog. + """ + _shop_cls_: Type['Shop'] + + active: list[Type['ShopCog']] = [] + + def __init__(self, bot: LionBot, data: ShopData): + self.bot = bot + self.data = data + + async def load_into(self, cog: LionCog): + """ + Load this ShopCog into the parent Shopping Cog. + + Usually just attaches the editshop placeholder group, if applicable. + May also load the cog itself into the client, + if the ShopCog needs to provide global features + or commands. + """ + raise NotImplementedError + + async def make_shop_for(self, customer: 'Customer'): + """ + Make a Shop instance for the provided Customer. + """ + shop = self._shop_cls_(self.bot, self.data, customer) + await shop.refresh() + return shop + + @classmethod + def register(self, shop): + """ + Helper decorator to register a given ShopCog as active. + """ + self.active.append(shop) + return shop + + +class Customer: """ An interface to the member's inventory. """ - def __init__(self, bot, shop_data, lion, inventory): - self.bot = bot - self.lion = lion - self.guildid = lion.guildid - self.userid = lion.userid - # A list of InventoryItems held by this user + _cache_ = WeakValueDictionary() + + def __init__(self, bot: LionBot, shop_data: ShopData, lion, inventory: ShopData.MemberInventory): + self.bot = bot + self.data = shop_data + + self.lion: 'Lion' = lion + + # A list of InventoryItems held by this customer self.inventory = inventory + @property + def guildid(self): + return self.lion.guildid + + @property + def userid(self): + return self.lion.userid + + @property + def balance(self): + return self.lion.data['coins'] + @classmethod async def fetch(cls, bot: LionBot, shop_data: ShopData, guildid: int, userid: int): lion = await bot.core.lions.fetch(guildid, userid) - inventory = await shop_data.fetch_where(guildid=guildid, userid=userid) + inventory = await shop_data.MemberInventoryInfo.fetch_inventory_info(guildid, userid) return cls(bot, shop_data, lion, inventory) async def refresh(self): """ Refresh the data for this member. """ - self.lion = self.bot.core.lions.fetch(self.guild.id, self.user.id) - - data = self.bot.get_cog('Shopping').data - self.inventory_items = await data.InventoryItem.fetch_where(userid=self.userid, guildid=self.guildid) + self.lion = await self.bot.core.lions.fetch(self.guildid, self.userid) + await self.lion.data.refresh() + self.inventory = await self.data.MemberInventoryInfo.fetch_inventory_info(self.guildid, self.userid) + return self class ShopItem: """ - ABC representing a purchasable guild shop item. - """ - def __init__(self, data): - self.data = data + Base class representing a purchasable guild shop item. - async def purchase(self, userid): - """ - Called when a member purchases this item. - """ - ... + In its most basic form, this is just a direct interface to the data, + with some formatting methods. + """ + def __init__(self, bot: LionBot, data: ShopData.ShopItemInfo): + self.bot = bot + self.data = data class Shop: """ - Base class representing a Shop for a particular member. + Base class representing a Shop for a particular Customer. """ - def __init__(self, bot: LionBot, shop_data: ShopData, member: discord.Member): + # The name of this shop class, as a lazystring + _name_: LazyStr + + # Store class describing the shop UI. + _store_cls_: Type['Store'] + + def __init__(self, bot: LionBot, shop_data: ShopData, customer: Customer): self.bot = bot self.data = shop_data - self.member = member - self.guild = member.guild + self.customer = customer - # A list of ShopItems that are currently visible to the member - self.items = [] + # A map itemid: ShopItem of items viewable by the customer + self.items = {} - # Current inventory for the member - self.inventory = None + def purchasable(self): + """ + Retrieve a list of items purchasable by the customer. + """ + raise NotImplementedError async def refresh(self): - ... + """ + Refresh the shop and customer data. + """ + raise NotImplementedError + + @property + def name(self): + """ + The localised name of this shop. + + Usually just a context-aware translated version of cls._name_ + """ + t = self.bot.translator.t + return t(self._name_) + + async def purchase(self, itemid): + """ + Have the shop customer purchase the given (global) itemid. + Checks that the item is actually purchasable by the customer. + This method must be overridden in base classes. + """ + raise NotImplementedError + + def make_store(self): + """ + Initialise and return a new Store UI for this shop. + """ + return self._store_cls_(self) class Store(ui.LeoUI): """ - Base UI for the different shops. + ABC for the UI used to interact with a given shop. + + This must always be an ephemeral UI, + so extra permission checks are not required. + (Note that each Shop instance is specific to a single customer.) """ - def __init__(self, bot: LionBot, data, shops): - self.bot = bot - self.data = data - self.shops = shops + def __init__(self, shop: Shop, interaction: discord.Interaction, **kwargs): + super().__init__(**kwargs) + + # The shop this Store is an interface for + # Client, shop, and customer data is taken from here + # The Shop also manages most Customer object interaction, including purchases. + self.shop = shop + + # The row of Buttons used to access different shops, if any + # Transient, will be deprecated by direct access to UILayout. + self.store_row = () + + # Current embed page + self.embed: Optional[discord.Embed] = None + + # Current interaction to use + self.interaction: discord.Interaction = interaction + + def set_store_row(self, row): + self.store_row = row + for item in row: + self.add_item(item) + + async def refresh(self): + """ + Refresh all UI elements. + """ + raise NotImplementedError + + async def redraw(self): + """ + Redraw the store UI. + """ + if self.interaction.is_expired(): + # This is actually possible, + # If the user keeps using the UI, + # but never closes it until the origin interaction expires + raise ValueError("This interaction has expired!") + return + + if self.embed is None: + await self.refresh() + + await self.interaction.edit_original_response(embed=self.embed, view=self) + + async def make_embed(self): + """ + Embed page for this shop. + """ + raise NotImplementedError diff --git a/bot/modules/shop/shops/colours.py b/bot/modules/shop/shops/colours.py index a4bd5745..58b7cbe1 100644 --- a/bot/modules/shop/shops/colours.py +++ b/bot/modules/shop/shops/colours.py @@ -1,124 +1,1103 @@ -from typing import TYPE_CHECKING +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 +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 utils import ui +from utils.lib import error_embed +from constants import MAX_COINS -from .data import ShopData +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 -class ColourShopping(LionCog): +_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) + ] + + 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 + conn = await self.bot.db.get_connection() + 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.get_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. """ - - def __init__(self, bot: LionBot, data): - self.bot = bot - self.data = data + _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) - async def unload(self): - pass - @LionCog.placeholder_group - @cmds.hybrid_group('editshopp', with_app_command=False) + @cmds.hybrid_group('editshop', with_app_command=False) async def editshop_group(self, ctx: LionContext): pass - @editshop_group.group('colours') - async def editshop_colour_group(self, ctx: LionContext): - ... + @editshop_group.group(_p('grp:editshop_colours', 'colours')) + async def editshop_colours_group(self, ctx: LionContext): + pass - @editshop_colour_group.command('edit') - async def editshop_colours_edit_cmd(self, ctx: LionContext): - await ctx.reply(f"I am a {self.__class__.__name__} version 2") - ... - - def make_widget(self): + @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]): """ - Instantiate and return a new UI for this shop. + Create a new colour role with the specified attributes. """ - return ColourStore(self.bot, self.data) + t = self.bot.translator.t + if not ctx.interaction: + return + if not ctx.guild: + return - def make_shop_for(self, member: discord.Member): - return ColourShop(member, self.data) + 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." + )) + ) -class ColourShop: - """ - A Shop representing a colour shop for a particular member. + # 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." + )) + ) - Parameters - ---------- - bot: LionBot - The current LionBot. + # 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 - member: discord.Member - The member this particular shop is for. + # 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 - data: ShopData - An initialised ShopData registry. - """ - def __init__(self, bot, member, data): - self.bot = bot - self.user = member - self.guild = member.guild - self.data = data + 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 - # List of items in this shop. Initialised in refresh() - self.items = [] + 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 - # Current inventory for this member - self.inventory = None + # 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 + ) - def make_store(self): + # 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]]): """ - Initialise and return a new Store UI for this shop. + Edit the provided colour role with the given attributes. """ - return ColourStore(self) + 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) + ) + + # 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: +class ColourStore(Store): """ Ephemeral UI providing access to the colour store. """ + shop: ColourShop - def __init__(self, shop: ColourShop): - self.bot = shop.bot - self.data = shop.data - self.shop = shop - - self.shop_row = () - - async def refresh(self): - """ - Refresh the data. - """ - # Refresh current item list - # Refresh user's current item - ... - - async def redraw(self): - ... - - @select(placeholder="Select to Buy") + @select(placeholder=_p("ui:colourstore|menu:buycolours|placeholder", "Select to Buy")) async def select_colour(self, interaction: discord.Interaction, selection: Select): + t = self.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): """ @@ -127,21 +1106,75 @@ class ColourStore: For an item to be purchasable, it needs to be affordable and not currently owned by the member. """ - ... + t = self.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. """ - lines = [] - for i, item in enumerate(self.shop.items): - line = f"[{i+1}] | `{item.price} LC` | <@&{item.data.roleid}>" - if item.itemid in self.shop.member_inventory.itemids: - line += " (You own this!)" + t = self.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="Colour Role Shop", - description="" + title=t(_p('ui:colourstore|embed|title', "Colour Role Shop")), + description=description ) - ... - - + return embed diff --git a/config/emojis.conf b/config/emojis.conf index f3a5e101..d5bdadbe 100644 --- a/config/emojis.conf +++ b/config/emojis.conf @@ -42,6 +42,9 @@ person = <:person01:975880828481581096> question = <:questionmarkbigger:975880828645167154> cancel = <:xbigger:975880828653568012> refresh = <:cyclebigger:975880828611600404> +loading = <:cyclebigger:975880828611600404> tick = :✅: clock = :⏱️: + +coin = <:coin:975880967485022239> diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index 8175be45..753812ae 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -159,7 +159,7 @@ CREATE TABLE coin_transactions( guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, actorid BIGINT NOT NULL, amount INTEGER NOT NULL, - bonus INTEGER NOT NULL, + bonus INTEGER NOT NULL DEFAULT 0, from_account BIGINT, to_account BIGINT, refunds INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL, @@ -222,6 +222,25 @@ ALTER TABLE member_inventory DROP COLUMN count; CREATE INDEX member_inventory_members ON member_inventory(guildid, userid); + + +CREATE VIEW member_inventory_info AS + SELECT + inv.inventoryid AS inventoryid, + inv.guildid AS guildid, + inv.userid AS userid, + inv.transactionid AS transactionid, + items.itemid AS itemid, + items.item_type AS item_type, + items.price AS price, + items.purchasable AS purchasable, + items.deleted AS deleted, + items.guild_itemid AS guild_itemid, + items.roleid AS roleid + FROM + member_inventory inv + LEFT JOIN shop_item_info items USING (itemid) + ORDER BY itemid ASC; -- }}} INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration'); diff --git a/data/schema.sql b/data/schema.sql index 7c8d83c0..64bde777 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -50,6 +50,7 @@ CREATE TABLE user_config( timezone TEXT, topgg_vote_reminder BOOLEAN, avatar_hash TEXT, + name TEXT, API_timestamp BIGINT, gems INTEGER DEFAULT 0 ); @@ -230,12 +231,13 @@ CREATE TABLE shop_items_colour_roles( ); CREATE TABLE member_inventory( + inventoryid SERIAL PRIMARY KEY, guildid BIGINT NOT NULL, userid BIGINT NOT NULL, - itemid INTEGER NOT NULL REFERENCES shop_items(itemid) ON DELETE CASCADE, - count INTEGER DEFAULT 1, - PRIMARY KEY(guildid, userid) + transactionid INTEGER REFERENCES coin_transactions(transactionid) ON DELETE SET NULL, + itemid INTEGER NOT NULL REFERENCES shop_items(itemid) ON DELETE CASCADE ); +CREATE INDEX member_inventory_members ON member_inventory(guildid, userid); CREATE VIEW shop_item_info AS