diff --git a/bot/modules/economy/__init__.py b/bot/modules/economy/__init__.py index 39ef68ad..0dc48d5f 100644 --- a/bot/modules/economy/__init__.py +++ b/bot/modules/economy/__init__.py @@ -2,3 +2,4 @@ from .module import module from . import cointop_cmd from . import send_cmd +from . import shop_cmds diff --git a/bot/modules/economy/shop_cmds.py b/bot/modules/economy/shop_cmds.py new file mode 100644 index 00000000..1750fc83 --- /dev/null +++ b/bot/modules/economy/shop_cmds.py @@ -0,0 +1,215 @@ +import asyncio +import discord +from collections import defaultdict + +from cmdClient.checks import in_guild + +from .module import module +from .shop_core import ShopItem + + +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 + Description: + Opens the guild shop. + """ + # TODO: (FUTURE) Register session (and cancel previous sessions) so we can track for buy + + # 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: + # 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 + item_type = None + if len(item_cats) > 1: + # TODO + item_type = ... + ... + else: + item_type = next(iter(item_cats)) + + if item_type is not None: + items = [item for item in item_cats[item_type]] + embeds = items[0].cat_shop_embeds( + ctx.guild.id, + [item.itemid for item in items] + ) + + # 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/economy/shop_core/ColourRole.py b/bot/modules/economy/shop_core/ColourRole.py new file mode 100644 index 00000000..cc25f716 --- /dev/null +++ b/bot/modules/economy/shop_core/ColourRole.py @@ -0,0 +1,173 @@ +from typing import List +import datetime +import discord + +from cmdClient.lib import SafeCancellation +from meta import client + +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 + + @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'], **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" + cls.buy_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." + log_str = "{} bought `{}`, but I couldn't add the role!".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 diff --git a/bot/modules/economy/shop_core/ShopItem.py b/bot/modules/economy/shop_core/ShopItem.py new file mode 100644 index 00000000..073f304e --- /dev/null +++ b/bot/modules/economy/shop_core/ShopItem.py @@ -0,0 +1,265 @@ +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}}` {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]) + + 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/economy/shop_core/__init__.py b/bot/modules/economy/shop_core/__init__.py new file mode 100644 index 00000000..6b3b2dee --- /dev/null +++ b/bot/modules/economy/shop_core/__init__.py @@ -0,0 +1,4 @@ +from . import data + +from .ShopItem import ShopItem +from .ColourRole import ColourRole diff --git a/bot/modules/economy/shop_core/data.py b/bot/modules/economy/shop_core/data.py new file mode 100644 index 00000000..25b7882a --- /dev/null +++ b/bot/modules/economy/shop_core/data.py @@ -0,0 +1,19 @@ +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/economy/shopadmin_cmd.py b/bot/modules/economy/shopadmin_cmd.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/utils/lib.py b/bot/utils/lib.py index eb2416fe..eb098b0b 100644 --- a/bot/utils/lib.py +++ b/bot/utils/lib.py @@ -1,8 +1,10 @@ import datetime import iso8601 import re +from enum import Enum import discord +from psycopg2.extensions import QuotedString from cmdClient.lib import SafeCancellation @@ -487,3 +489,24 @@ class DotDict(dict): __getattr__ = dict.get __setattr__ = dict.__setitem__ __delattr__ = dict.__delitem__ + + +class FieldEnum(str, Enum): + """ + String enum with description conforming to the ISQLQuote protocol. + Allows processing by psycog + """ + def __new__(cls, value, desc): + obj = str.__new__(cls, value) + obj._value_ = value + obj.desc = desc + return obj + + def __repr__(self): + return '<%s.%s>' % (self.__class__.__name__, self.name) + + def __bool__(self): + return True + + def __conform__(self, proto): + return QuotedString(self.value) diff --git a/data/schema.sql b/data/schema.sql index 54658631..b9886df1 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -79,7 +79,6 @@ CREATE TABLE workout_sessions( CREATE INDEX workout_sessions_members ON workout_sessions (guildid, userid); -- }}} - -- Tasklist data {{{ CREATE TABLE tasklist( taskid SERIAL PRIMARY KEY, @@ -132,6 +131,56 @@ CREATE VIEW study_badge_roles AS ORDER BY guildid, required_time ASC; -- }}} +-- Shop data {{{ +CREATE TYPE ShopItemType AS ENUM ( + 'COLOUR_ROLE' +); + +CREATE TABLE shop_items( + itemid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + item_type ShopItemType NOT NULL, + price INTEGER NOT NULL, + purchasable BOOLEAN DEFAULT TRUE, + deleted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT (now() at time zone 'utc') +); +CREATE INDEX guild_shop_items ON shop_items (guildid); + +CREATE TABLE shop_items_colour_roles( + itemid INTEGER PRIMARY KEY REFERENCES shop_items(itemid) ON DELETE CASCADE, + roleid BIGINT NOT NULL +); + +CREATE TABLE member_inventory( + 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) +); + + +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; + +/* +-- Shop config, not implemented +CREATE TABLE guild_shop_config( + guildid BIGINT PRIMARY KEY +); + +CREATE TABLE guild_colourroles_config( +); +*/ +-- }}} + -- Moderation data {{{ CREATE TABLE video_channels( guildid BIGINT NOT NULL, @@ -234,4 +283,4 @@ CREATE VIEW new_study_badges AS ORDER BY guildid; -- }}} --- vim: set fdm=marker +-- vim: set fdm=marker: