From bac72194a394e76f0d7b37cb66d62087c64134ef Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 15 Sep 2021 09:24:37 +0300 Subject: [PATCH] (Economy): Completed base shop UI. --- bot/modules/economy/shop_cmds.py | 57 ++++-- bot/modules/economy/shop_core/ColourRole.py | 200 +++++++++++++++++++- bot/modules/economy/shop_core/ShopItem.py | 4 +- bot/modules/economy/shop_core/__init__.py | 2 +- bot/utils/lib.py | 6 + 5 files changed, 251 insertions(+), 18 deletions(-) diff --git a/bot/modules/economy/shop_cmds.py b/bot/modules/economy/shop_cmds.py index 1750fc83..1d3a0242 100644 --- a/bot/modules/economy/shop_cmds.py +++ b/bot/modules/economy/shop_cmds.py @@ -5,7 +5,8 @@ from collections import defaultdict from cmdClient.checks import in_guild from .module import module -from .shop_core import ShopItem +from .shop_core import ShopItem, ShopItemType, ColourRole +from wards import is_guild_admin class ShopSession: @@ -80,23 +81,36 @@ class ShopSession: 'shop', group="Economy", desc="Open the guild shop.", - flags=('add', 'remove', 'clear') + flags=('add==', 'remove==', 'clear') ) @in_guild() async def cmd_shop(ctx, flags): """ Usage``: {prefix}shop - {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. + 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: + 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." @@ -108,21 +122,38 @@ async def cmd_shop(ctx, flags): item_cats[item.item_type].append(item) # If there is more than one category, ask for which category they want - item_type = None - if len(item_cats) > 1: - # TODO - item_type = ... - ... - else: - item_type = next(iter(item_cats)) + # 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 = items[0].cat_shop_embeds( + 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) diff --git a/bot/modules/economy/shop_core/ColourRole.py b/bot/modules/economy/shop_core/ColourRole.py index cc25f716..c3c72c24 100644 --- a/bot/modules/economy/shop_core/ColourRole.py +++ b/bot/modules/economy/shop_core/ColourRole.py @@ -1,9 +1,12 @@ +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 @@ -27,6 +30,10 @@ class ColourRole(ShopItem): 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): """ @@ -45,7 +52,7 @@ class ColourRole(ShopItem): # Shop interface @classmethod - def _cat_shop_embed_items(cls, items: List['ColourRole'], **kwargs) -> List[discord.Embed]: + 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. @@ -56,7 +63,7 @@ class ColourRole(ShopItem): embeds = cls._cat_embed_items(items, **kwargs) for embed in embeds: embed.title = "{} shop!".format(cls.item_type.desc) - embed.description += "\n\n" + cls.buy_hint + embed.description += "\n\n" + hint else: embed = discord.Embed( title="{} shop!".format(cls.item_type.desc), @@ -171,3 +178,192 @@ class ColourRole(ShopItem): 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) ids 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 deleted.") + await ctx.guild_settings.event_log.log("{} cleared the colour role shop.".format(ctx.author.mention)) diff --git a/bot/modules/economy/shop_core/ShopItem.py b/bot/modules/economy/shop_core/ShopItem.py index 073f304e..fe505ec8 100644 --- a/bot/modules/economy/shop_core/ShopItem.py +++ b/bot/modules/economy/shop_core/ShopItem.py @@ -27,7 +27,7 @@ class ShopItem: 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}}` {item.display_name}" + shop_fmt = "`[{num:<{num_len}}]` | `{item.price:<{price_len}} LC` {item.display_name}" # Shop input modifiers allow_multi_select = True @@ -248,7 +248,7 @@ class ShopItem: # Ensure cache is populated shop_item_info.fetch_rows_where(itemid=itemids) - return cls._cat_shop_embed_items([cls(itemid) for itemid in itemids]) + return cls._cat_shop_embed_items([cls(itemid) for itemid in itemids], **kwargs) async def buy(self, ctx): """ diff --git a/bot/modules/economy/shop_core/__init__.py b/bot/modules/economy/shop_core/__init__.py index 6b3b2dee..62767d8e 100644 --- a/bot/modules/economy/shop_core/__init__.py +++ b/bot/modules/economy/shop_core/__init__.py @@ -1,4 +1,4 @@ from . import data -from .ShopItem import ShopItem +from .ShopItem import ShopItem, ShopItemType from .ColourRole import ColourRole diff --git a/bot/utils/lib.py b/bot/utils/lib.py index eb098b0b..925d9c14 100644 --- a/bot/utils/lib.py +++ b/bot/utils/lib.py @@ -510,3 +510,9 @@ class FieldEnum(str, Enum): def __conform__(self, proto): return QuotedString(self.value) + + +multiselect_regex = re.compile( + r"^([0-9, -]+)$", + re.DOTALL | re.IGNORECASE | re.VERBOSE +)