(Economy): Base shop system.
This commit is contained in:
@@ -2,3 +2,4 @@ from .module import module
|
|||||||
|
|
||||||
from . import cointop_cmd
|
from . import cointop_cmd
|
||||||
from . import send_cmd
|
from . import send_cmd
|
||||||
|
from . import shop_cmds
|
||||||
|
|||||||
215
bot/modules/economy/shop_cmds.py
Normal file
215
bot/modules/economy/shop_cmds.py
Normal file
@@ -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 <category>
|
||||||
|
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 <number>
|
||||||
|
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 <number>`, 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
|
||||||
173
bot/modules/economy/shop_core/ColourRole.py
Normal file
173
bot/modules/economy/shop_core/ColourRole.py
Normal file
@@ -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
|
||||||
265
bot/modules/economy/shop_core/ShopItem.py
Normal file
265
bot/modules/economy/shop_core/ShopItem.py
Normal file
@@ -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 <numbers>`, 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
|
||||||
4
bot/modules/economy/shop_core/__init__.py
Normal file
4
bot/modules/economy/shop_core/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import data
|
||||||
|
|
||||||
|
from .ShopItem import ShopItem
|
||||||
|
from .ColourRole import ColourRole
|
||||||
19
bot/modules/economy/shop_core/data.py
Normal file
19
bot/modules/economy/shop_core/data.py
Normal file
@@ -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)
|
||||||
|
)
|
||||||
0
bot/modules/economy/shopadmin_cmd.py
Normal file
0
bot/modules/economy/shopadmin_cmd.py
Normal file
@@ -1,8 +1,10 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import iso8601
|
import iso8601
|
||||||
import re
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
from psycopg2.extensions import QuotedString
|
||||||
|
|
||||||
from cmdClient.lib import SafeCancellation
|
from cmdClient.lib import SafeCancellation
|
||||||
|
|
||||||
@@ -487,3 +489,24 @@ class DotDict(dict):
|
|||||||
__getattr__ = dict.get
|
__getattr__ = dict.get
|
||||||
__setattr__ = dict.__setitem__
|
__setattr__ = dict.__setitem__
|
||||||
__delattr__ = dict.__delitem__
|
__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)
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ CREATE TABLE workout_sessions(
|
|||||||
CREATE INDEX workout_sessions_members ON workout_sessions (guildid, userid);
|
CREATE INDEX workout_sessions_members ON workout_sessions (guildid, userid);
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
|
|
||||||
-- Tasklist data {{{
|
-- Tasklist data {{{
|
||||||
CREATE TABLE tasklist(
|
CREATE TABLE tasklist(
|
||||||
taskid SERIAL PRIMARY KEY,
|
taskid SERIAL PRIMARY KEY,
|
||||||
@@ -132,6 +131,56 @@ CREATE VIEW study_badge_roles AS
|
|||||||
ORDER BY guildid, required_time ASC;
|
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 {{{
|
-- Moderation data {{{
|
||||||
CREATE TABLE video_channels(
|
CREATE TABLE video_channels(
|
||||||
guildid BIGINT NOT NULL,
|
guildid BIGINT NOT NULL,
|
||||||
@@ -234,4 +283,4 @@ CREATE VIEW new_study_badges AS
|
|||||||
ORDER BY guildid;
|
ORDER BY guildid;
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
-- vim: set fdm=marker
|
-- vim: set fdm=marker:
|
||||||
|
|||||||
Reference in New Issue
Block a user