From 7fc2053951d1868d8335251f256aabd042e031b8 Mon Sep 17 00:00:00 2001 From: Valerie Date: Sat, 24 May 2025 04:09:52 -0400 Subject: [PATCH] Add Shop Cog with inventory and trading features. Implement shop management commands for creating, editing, and deleting shops and items. Introduce inventory display and item redemption functionalities. Include example CSV for bulk item addition and enhance user experience with structured menus and checks. --- README.md | 1 + shop/__init__.py | 8 + shop/checks.py | 54 ++ shop/data/Example.csv | 11 + shop/info.json | 12 + shop/inventory.py | 97 +++ shop/menu.py | 246 +++++++ shop/shop.py | 1423 ++++++++++++++++++++++++++++++++++++++ translator/menus.py | 19 + translator/translator.py | 2 +- 10 files changed, 1872 insertions(+), 1 deletion(-) create mode 100644 shop/__init__.py create mode 100644 shop/checks.py create mode 100644 shop/data/Example.csv create mode 100644 shop/info.json create mode 100644 shop/inventory.py create mode 100644 shop/menu.py create mode 100644 shop/shop.py create mode 100644 translator/menus.py diff --git a/README.md b/README.md index 816dece..b40ed56 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ We extend our gratitude to the following developers and their amazing contributi - [Toxic Cogs](https://github.com/NeuroAssassin/Toxic-Cogs) - [Karlo Cogs](https://github.com/karlsbjorn/karlo-cogs) - [Mister-42 Cogs](https://github.com/Mister-42/mr42-cogs) +- [Jumper Plugins](https://github.com/Redjumpman/Jumper-Plugins/tree/V3_dpy2) (V3_dpy2) ## 📜 Licenses This repository includes cogs that may be licensed under either: diff --git a/shop/__init__.py b/shop/__init__.py new file mode 100644 index 0000000..5c682ff --- /dev/null +++ b/shop/__init__.py @@ -0,0 +1,8 @@ +from .shop import Shop + +__red_end_user_data_statement__ = "This cog stores discord IDs as needed for operation." + + +async def setup(bot): + cog = Shop() + await bot.add_cog(cog) diff --git a/shop/checks.py b/shop/checks.py new file mode 100644 index 0000000..447c862 --- /dev/null +++ b/shop/checks.py @@ -0,0 +1,54 @@ +from collections.abc import Iterable + + +class Checks: + """Class of predicates for waiting events. + + This class requires you to pass ctx so that the person who + invocated the command can be determined. + + You may pass an optional iterable in custom to check if the + content is a member of it. + """ + + def __init__(self, ctx, custom: Iterable = None, length: int = None): + self.ctx = ctx + self.custom = custom + self.length = length + + def same(self, m): + return self.ctx.author == m.author + + def confirm(self, m): + return self.same(m) and m.content.lower() in ("yes", "no") + + def valid_int(self, m): + return self.same(m) and m.content.isdigit() + + def valid_float(self, m): + try: + return self.same(m) and float(m.content) >= 1 + except ValueError: + return False + + def positive(self, m): + return self.same(m) and m.content.isdigit() and int(m.content) > 0 + + def role(self, m): + roles = [r.name for r in self.ctx.guild.roles if r.name != "Bot"] + return self.same(m) and m.content in roles + + def member(self, m): + return self.same(m) and m.content in [x.name for x in self.ctx.guild.members] + + def length_under(self, m): + try: + return self.same(m) and len(m.content) <= self.length + except TypeError: + raise ValueError("Length was not specified in Checks.") + + def content(self, m): + try: + return self.same(m) and m.content in self.custom + except TypeError: + raise ValueError("A custom iterable was not set in Checks.") diff --git a/shop/data/Example.csv b/shop/data/Example.csv new file mode 100644 index 0000000..e533a8a --- /dev/null +++ b/shop/data/Example.csv @@ -0,0 +1,11 @@ +Shop,Item,Type,Cost,Qty,Info,Role,Messages +Holy Temple,Bread,basic,25,12,Hard as a rock.,, +Holy Temple,Holy Water,basic,30,8,Strong against Demons.,, +Holy Temple,Divine Training,role,500,5,Grants the Priest role.,Priest, +Holy Temple,Healing Potion,basic,85,10,Recovers some health.,, +Holy Temple,Enchanted Stave,basic,900,1,Divine blunt weapon.,, +Holy Temple,Box of faith,random,100,0,Pray for prosperity.,, +Holy Temple,Angelic Whisper,auto,5,6,Your prayers answered.,,"msg1, msg2, msg3, msg4, msg5, msg6" +Holy Temple,Robes,basic,25,18,Simple cloth robes.,, +Mage Tower,Wand of Inferno,basic,9000,1,Fiery magic implement.,, +Mage Tower,Magic Beans,basic,5,3,These might be useful.,, diff --git a/shop/info.json b/shop/info.json new file mode 100644 index 0000000..bdfeec4 --- /dev/null +++ b/shop/info.json @@ -0,0 +1,12 @@ +{ + "author" : ["Redjumpman (Redjumpman#1337)"], + "install_msg" : "Thank you for installing shop. Be sure to check out the wiki here: https://github.com/Redjumpman/Jumper-Plugins/wiki/Shop-Red-V3\nThis cog comes with a bundled CSV file as an example for adding bulk shops and items.", + "name" : "Shop", + "short" : "Create, buy, trade, and redeem items.", + "requirements" : ["tabulate"], + "description" : "Shop system that allows for multiple shops with their own list of items for sale. Players can purchase these items with economy currency and redeem them for roles, or other server defined value.", + "permissions" : ["Manage Messages", "Embed Links", "Add Reactions", "Manage Roles"], + "tags" : ["Economy", "Fun", "Shop"], + "min_python_version": [3, 6, 0], + "end_user_data_statement": "This cog stores discord IDs as needed for operation." +} diff --git a/shop/inventory.py b/shop/inventory.py new file mode 100644 index 0000000..40d3817 --- /dev/null +++ b/shop/inventory.py @@ -0,0 +1,97 @@ +import asyncio +import discord +from redbot.core.utils.chat_formatting import box +from .menu import MenuCheck + + +class Inventory: + def __init__(self, ctx, data): + self.ctx = ctx + self.data = data + + async def display(self): + msg, groups = await self.setup() + try: + return await self.inv_loop(groups, msg) + except asyncio.TimeoutError: + await msg.delete() + await self.ctx.send("Menu timed out.") + raise RuntimeError + except ExitMenu: + await self.ctx.send("Exited inventory.") + raise RuntimeError + + async def setup(self, groups=None, page=0, msg=None): + if not groups: + groups = self.splitter() + options = self.update(groups, page) + embed = self.build_embed(options, page, groups) + if msg: + await msg.edit(embed=embed) + else: + msg = await self.ctx.send(embed=embed) + return msg, groups + + async def inv_loop(self, groups, msg): + page = 0 + maximum = len(groups) - 1 + while True: + check = MenuCheck(self.ctx, groups, page, maximum) + choice = await self.ctx.bot.wait_for("message", timeout=35.0, check=check.predicate) + if choice.content.isdigit() and int(choice.content) in range(1, len(groups[page]) + 1): + try: + await choice.delete() + await msg.delete() + except (discord.NotFound, discord.Forbidden): + pass + return groups[page][int(choice.content) - 1][0] + elif choice.content.lower() in (">", "n", "next"): + page += 1 + elif choice.content.lower() in ("b", "<", "back"): + page -= 1 + elif choice.content.lower() in ("e", "x", "exit"): + try: + await choice.delete() + await msg.delete() + except (discord.NotFound, discord.Forbidden): + pass + raise ExitMenu + elif choice.content.lower() in ("p", "prev"): + continue + else: + msg, _ = await self.setup(groups=groups, page=page, msg=msg) + await msg.edit(embed=msg) + msg, _ = await self.setup(groups=groups, page=page, msg=msg) + + def splitter(self): + return [self.data[i : i + 5] if len(self.data) > 5 else self.data for i in range(0, len(self.data), 5)] + + def update(self, groups, page=0): + header = f"{'#':<3} {'Items':<29} {'Qty':<7} {'Type':<8}\n{'--':<3} {'-'*29:<29} {'-'*4:<7} {'-'*8:<8}" + fmt = [header] + for idx, x in enumerate(groups[page], 1): + line_one = f"{f'{idx}.': <{3}} {x[0]: <{28}s} {x[1]['Qty']: < {9}}{x[1]['Type']: <{7}s}" + fmt.append(line_one) + fmt.append(f'< {x[1]["Info"][:50]} >' if len(x[1]["Info"]) < 50 else f'< {x[1]["Info"][:47]}... >') + fmt.append("",) + return box("\n".join(fmt), lang="md") + + def build_embed(self, options, page, groups): + title = "{}'s Inventory".format(self.ctx.author.name) + footer = "You are viewing page {} of {}.".format(page + 1 if page > 0 else 1, len(groups)) + instructions = ( + "Type the number for your selection or one of the words below " + "for page navigation if there are multiple pages available.\n" + "Next page: Type n, next, or >\n" + "Previous page: Type b, back, or <\n" + "Exit menu system: Type e, x, or exit" + ) + embed = discord.Embed(color=0x5EC6FF) + embed.add_field(name=title, value=options, inline=False) + embed.set_footer(text="\n".join((instructions, footer))) + + return embed + + +class ExitMenu(Exception): + pass diff --git a/shop/menu.py b/shop/menu.py new file mode 100644 index 0000000..def10b9 --- /dev/null +++ b/shop/menu.py @@ -0,0 +1,246 @@ +import asyncio +import discord +from tabulate import tabulate +from redbot.core.utils.chat_formatting import box + + +class ShopMenu: + def __init__(self, ctx, origin, mode=0, sorting="price"): + self.ctx = ctx + self.origin = origin + self.shop = None + self.user = None + self.enabled = True + self.mode = mode + self.sorting = sorting + + async def display(self): + data = self.origin + msg, groups, page, maximum = await self.setup(data) + try: + item = await self.menu_loop(data, groups, page, maximum, msg) + except asyncio.TimeoutError: + await msg.delete() + await self.ctx.send("No response. Menu exited.") + raise RuntimeError + except MenuExit: + await msg.delete() + await self.ctx.send("Exited menu.") + raise RuntimeError + else: + if self.mode == 0: + return self.shop, item + else: + return self.user, item + + async def setup(self, data=None, msg=None): + if data is None: + data = self.origin + if (self.shop is None and self.mode == 0) or (self.user is None and self.mode == 1): + data = await self.parse_data(data) + + groups = self.group_data(data) + page, maximum = 0, len(groups) - 1 + e = await self.build_menu(groups, page) + + if msg is None: + msg = await self.ctx.send(self.ctx.author.mention, embed=e) + else: + await msg.edit(embed=e) + return msg, groups, page, maximum + + async def menu_loop(self, data, groups, page, maximum, msg): + while True: + check = MenuCheck(self.ctx, groups, page, maximum) + choice = await self.ctx.bot.wait_for("message", timeout=35.0, check=check.predicate) + if choice.content.isdigit() and int(choice.content) in range(1, len(groups[page]) + 1): + selection = groups[page][int(choice.content) - 1] + try: + await choice.delete() + except (discord.NotFound, discord.Forbidden): + pass + if self.mode == 0: + item = await self.next_menu(data, selection, msg) + if not self.enabled: + try: + await msg.delete() + except (discord.NotFound, discord.Forbidden): + pass + return item + else: + pending_id = await self.pending_menu(data, selection, msg) + if not self.enabled: + try: + await msg.delete() + except (discord.NotFound, discord.Forbidden): + pass + return pending_id + if choice.content.lower() in (">", "n", "next"): + page += 1 + elif choice.content.lower() in ("b", "<", "back"): + page -= 1 + elif choice.content.lower() in ("p", "prev"): + if (self.shop and self.mode == 0) or (self.user and self.mode == 1): + try: + await choice.delete() + except (discord.NotFound, discord.Forbidden): + pass + if self.mode == 0: + self.shop = None + else: + self.user = None + break + pass + elif choice.content.lower() in ("e", "x", "exit"): + try: + await choice.delete() + except (discord.NotFound, discord.Forbidden): + pass + raise MenuExit + + try: + await choice.delete() + except discord.NotFound: + msg, groups, page, maximum = await self.setup(msg=msg) + except discord.Forbidden: + pass + embed = await self.build_menu(groups, page=page) + await msg.edit(embed=embed) + + async def parse_data(self, data): + if self.shop is None and self.mode == 0: + perms = self.ctx.author.guild_permissions.administrator + author_roles = [r.name for r in self.ctx.author.roles] + return [x for x, y in data.items() if (y["Role"] in author_roles or perms) and y["Items"]] + else: + try: + return list(data.items()) + except AttributeError: + return data + + async def build_menu(self, groups, page): + footer = "You are viewing page {} of {}.".format(page + 1 if page > 0 else 1, len(groups)) + if self.shop is None and self.mode == 0: + output = ["{} - {}".format(idx, ele) for idx, ele in enumerate(groups[page], 1)] + elif self.mode == 0: + header = f"{'#':<3} {'Name':<29} {'Qty':<7} {'Cost':<8}\n{'--':<3} {'-'*29:<29} {'-'*4:<7} {'-'*8:<8}" + fmt = [header] + for idx, x in enumerate(groups[page], 1): + line_one = f"{f'{idx}.': <{3}} {x[0]: <{29}s} {x[1]['Qty']:<{8}}{x[1]['Cost']: < {7}}" + fmt.append(line_one) + fmt.append(f'< {x[1]["Info"][:50]} >' if len(x[1]["Info"]) < 50 else f'< {x[1]["Info"][:47]}... >') + fmt.append("",) + output = box("\n".join(fmt), "md") + elif self.mode == 1 and self.user is None: + headers = ("#", "User", "Pending Items") + fmt = [ + (idx, discord.utils.get(self.ctx.bot.users, id=int(x[0])).name, len(x[1])) + for idx, x in enumerate(groups[page], 1) + ] + output = box(tabulate(fmt, headers=headers, numalign="left"), lang="md") + elif self.mode == 1: + headers = ("#", "Item", "Order ID", "Timestamp") + fmt = [(idx, x[1]["Item"], x[0], x[1]["Timestamp"]) for idx, x in enumerate(groups[page], 1)] + + output = box(tabulate(fmt, headers=headers, numalign="left"), lang="md") + else: + output = None + return self.build_embed(output, footer) + + def sorter(self, groups): + if self.sorting == "name": + return sorted(groups, key=lambda x: x[0]) + elif self.sorting == "price": + return sorted(groups, key=lambda x: x[1]["Cost"], reverse=True) + else: + return sorted(groups, key=lambda x: x[1]["Quantity"], reverse=True) + + def group_data(self, data): + grouped = [] + for idx in range(0, len(data), 5): + if len(data) > 5: + grouped.append(self.sorter(data[idx : idx + 5])) + else: + if not isinstance(data, dict): + grouped.append(data) + else: + grouped.append([self.sorter(data)]) + return grouped + + def build_embed(self, options, footer): + instructions = ( + "Type the number for your selection or one of the words below " + "for page navigation if there are multiple pages available.\n" + "Next page: Type n, next, or >\n" + "Previous page: Type b, back, or <\n" + "Return to previous menu: Type p or prev\n" + "Exit menu system: Type e, x, or exit" + ) + + if self.shop is None and self.mode == 0: + options = "\n".join(options) + + if self.mode == 0: + title = "{}".format(self.shop if self.shop else "List of shops") + else: + title = "{} Pending".format(self.user.name if self.user else "Items") + + embed = discord.Embed(color=0x5EC6FF) + embed.add_field(name=title, value=options, inline=False) + embed.set_footer(text="\n".join([instructions, footer])) + + return embed + + async def next_menu(self, data, selection, msg): + try: + items = data[selection]["Items"] + except TypeError: + self.enabled = False + return selection[0] + else: + self.shop = selection + new_data = await self.parse_data(items) + msg, groups, page, maximum = await self.setup(data=new_data, msg=msg) + return await self.menu_loop(new_data, groups, page, maximum, msg) + + async def pending_menu(self, data, selection, msg): + try: + items = data[selection[0]] + except TypeError: + self.enabled = False + return selection[0] + else: + self.user = discord.utils.get(self.ctx.bot.users, id=int(selection[0])) + new_data = await self.parse_data(items) + msg, groups, page, maximum = await self.setup(data=new_data, msg=msg) + return await self.menu_loop(new_data, groups, page, maximum, msg) + + +class MenuCheck: + """Special check class for menu.py""" + + def __init__(self, ctx, data, page, maximum): + self.ctx = ctx + self.page = page + self.maximum = maximum + self.data = data + + def predicate(self, m): + choices = list(map(str, range(1, len(self.data[self.page]) + 1))) + if self.ctx.author == m.author: + if m.content in choices: + return True + elif m.content.lower() in ("exit", "prev", "p", "x", "e"): + return True + elif m.content.lower() in ("n", ">", "next") and (self.page + 1) <= self.maximum: + return True + elif m.content.lower() in ("b", "<", "back") and (self.page - 1) >= 0: + return True + else: + return False + else: + return False + + +class MenuExit(Exception): + pass diff --git a/shop/shop.py b/shop/shop.py new file mode 100644 index 0000000..8ccf2a9 --- /dev/null +++ b/shop/shop.py @@ -0,0 +1,1423 @@ +# Shop was made by Redjumpman for Red Bot. + +# Standard Library +import asyncio +import csv +import logging +import random +import textwrap +import uuid +from bisect import bisect +from copy import deepcopy +from itertools import zip_longest +from typing import Literal + +# Shop +from .menu import ShopMenu +from .inventory import Inventory +from .checks import Checks + +# Discord.py +import discord + +# Red +from redbot.core import Config, bank, commands +from redbot.core.utils import AsyncIter +from redbot.core.utils.chat_formatting import humanize_list +from redbot.core.data_manager import bundled_data_path +from redbot.core.errors import BalanceTooHigh + +log = logging.getLogger("red.shop") + +__version__ = "3.1.13" +__author__ = "Redjumpman" + + +def global_permissions(): + async def pred(ctx: commands.Context): + is_owner = await ctx.bot.is_owner(ctx.author) + if is_owner: + return True + if not await Shop().shop_is_global() and ctx.guild: + permissions = ctx.channel.permissions_for(ctx.author) + admin_roles = await ctx.bot._config.guild(ctx.guild).admin_role() + author_roles = [role.id for role in ctx.author.roles] + admin_role_check = check_if_role_in_roles(admin_roles, author_roles) + return admin_role_check or (ctx.author == ctx.guild.owner) or permissions.administrator + + return commands.check(pred) + +def check_if_role_in_roles(admin_roles, user_roles): + intersection = list(set(admin_roles).intersection(user_roles)) + if not intersection: + return False + return True + + +class Shop(commands.Cog): + shop_defaults = { + "Shops": {}, + "Settings": {"Alerts": False, "Alert_Role": "Admin", "Closed": False, "Gifting": True, "Sorting": "price",}, + "Pending": {}, + } + member_defaults = { + "Inventory": {}, + "Trading": True, + } + user_defaults = member_defaults + global_defaults = deepcopy(shop_defaults) + global_defaults["Global"] = False + + def __init__(self): + self.config = Config.get_conf(self, 5074395003, force_registration=True) + self.config.register_guild(**self.shop_defaults) + self.config.register_global(**self.global_defaults) + self.config.register_member(**self.member_defaults) + self.config.register_user(**self.user_defaults) + + async def red_delete_data_for_user( + self, *, requester: Literal["discord", "owner", "user", "user_strict"], user_id: int + ): + await self.config.user_from_id(user_id).clear() + all_members = await self.config.all_members() + async for guild_id, guild_data in AsyncIter(all_members.items(), steps=100): + if user_id in guild_data: + await self.config.member_from_ids(guild_id, user_id).clear() + + # -----------------------COMMANDS------------------------------------- + + @commands.command() + @commands.max_concurrency(1, commands.BucketType.user) + async def inventory(self, ctx): + """Displays your purchased items.""" + try: + instance = await self.get_instance(ctx, user=ctx.author) + except AttributeError: + return await ctx.send("You can't use this command in DMs when not in global mode.") + if not await instance.Inventory(): + return await ctx.send("You don't have any items to display.") + data = await instance.Inventory.all() + menu = Inventory(ctx, list(data.items())) + + try: + item = await menu.display() + except RuntimeError: + return + await self.pending_prompt(ctx, instance, data, item) + + async def inv_hook(self, user): + """Inventory Hook for outside cogs + + Parameters + ---------- + user : discord.Member or discord.User + + Returns + ------- + dict + Returns a dict of the user's inventory or empty if the user + is not found. + """ + try: + instance = await self._inv_hook_instance(user) + except AttributeError: + return {} + else: + return await instance.Inventory.all() + + async def _inv_hook_instance(self, user): + if await self.config.Global(): + return self.config.user(user) + else: + return self.config.member(user) + + @commands.group(autohelp=True) + async def shop(self, ctx): + """Shop group command""" + pass + + @shop.command() + @commands.max_concurrency(1, commands.BucketType.user) + async def buy(self, ctx, *purchase): + """Shop menu appears with no purchase order. + + When no argument is specified for purchase, it will bring up the + shop menu. + + Using the purchase argument allows direct purchases from a shop. + The order is "Shop Name" "Item Name" and names with spaces + must include quotes. + + Examples + -------- + [p]shop buy \"Secret Shop\" oil + [p]shop buy Junkyard tire + [p]shop buy \"Holy Temple\" \"Healing Potion\" + """ + try: + instance = await self.get_instance(ctx, settings=True) + except AttributeError: + return await ctx.send("You can't use this command in DMs when not in global mode.") + if not await instance.Shops(): + return await ctx.send("No shops have been created yet.") + if await instance.Settings.Closed(): + return await ctx.send("The shop system is currently closed.") + + shops = await instance.Shops.all() + col = await self.check_availability(ctx, shops) + if not col: + return await ctx.send( + "Either no items have been created, you need a higher role, " + "or this command should be used in a server and not DMs." + ) + if purchase: + try: + shop, item = purchase + except ValueError: + return await ctx.send("Too many parameters passed. Use help on this command for more information.") + if shop not in shops: + return await ctx.send("Either that shop does not exist, or you don't have access to it.") + + else: + style = await instance.Settings.Sorting() + menu = ShopMenu(ctx, shops, sorting=style) + try: + shop, item = await menu.display() + except RuntimeError: + return + + user_data = await self.get_instance(ctx, user=ctx.author) + sm = ShopManager(ctx, instance, user_data) + try: + await sm.order(shop, item) + except asyncio.TimeoutError: + await ctx.send("Request timed out.") + except ExitProcess: + await ctx.send("Transaction canceled.") + + @commands.max_concurrency(1, commands.BucketType.user) + @shop.command() + async def redeem(self, ctx, *, item: str): + """Redeems an item in your inventory.""" + try: + instance = await self.get_instance(ctx, user=ctx.author) + except AttributeError: + return await ctx.send("You can't use this command in DMs when not in global mode.") + data = await instance.Inventory.all() + if data is None: + return await ctx.send("Your inventory is empty.") + + if item not in data: + return await ctx.send("You don't own this item.") + + await self.pending_prompt(ctx, instance, data, item) + + @shop.command() + @commands.guild_only() + @commands.cooldown(1, 5, commands.BucketType.user) + async def trade(self, ctx, user: discord.Member, quantity: int, *, item: str): + """Attempts to trade an item with another user. + + Cooldown is a static 60 seconds to prevent abuse. + Cooldown will trigger regardless of the outcome. + """ + cancel = ctx.prefix + "cancel" + author_instance = await self.get_instance(ctx, user=ctx.author) + author_inventory = await author_instance.Inventory.all() + user_instance = await self.get_instance(ctx, user=user) + user_inv = await user_instance.Inventory.all() + + if not await user_instance.Trading(): + return await ctx.send("This user has trading turned off.") + + if item not in author_inventory: + return await ctx.send("You don't own that item.") + + if 0 < author_inventory[item]["Qty"] < quantity: + return await ctx.send("You don't have that many {}".format(item)) + + await ctx.send( + "{} has requested a trade with {}.\n" + "They are offering {}x {}.\n Do wish to trade?\n" + "*This trade can be canceled at anytime by typing `{}`.*" + "".format(ctx.author.mention, user.mention, quantity, item, cancel) + ) + + def check(m): + return (m.author == user and m.content.lower() in ("yes", "no", cancel)) or ( + m.author == ctx.author and m.content.lower() == cancel + ) + + try: + decision = await ctx.bot.wait_for("message", timeout=25, check=check) + except asyncio.TimeoutError: + return await ctx.send("Trade request timed out. Canceled trade.") + + if decision.content.lower() in ("no", cancel): + return await ctx.send("Trade canceled.") + await ctx.send("{} What is your counter offer?\n" '*Example: 3 "Healing Potions"*'.format(user.mention)) + + def predicate(m): + if m.author in (user, ctx.author) and m.content == cancel: + return True + if m.author != user: + return False + try: + q, i = [x.strip() for x in m.content.split('"')[:2] if x] + except ValueError: + return False + else: + if i not in user_inv: + return False + return 0 < user_inv[i]["Qty"] <= int(q) + + try: + offer = await ctx.bot.wait_for("message", timeout=25, check=predicate) + except asyncio.TimeoutError: + return await ctx.send("Trade request timed out. Canceled trade.") + if offer.content.lower() == cancel: + return await ctx.send("Trade canceled.") + qty, item2 = [x.strip() for x in offer.content.split('"')[:2] if x] + await ctx.send( + "{} Do you wish to trade {}x {} for {}'s {}x {}?" + "".format(ctx.author.mention, quantity, item, user.mention, qty, item2) + ) + + def check2(m): + return (m.author == ctx.author and m.content.lower() in ("yes", "no", cancel)) or ( + m.author == user and m.content.lower() == cancel + ) + + try: + final = await ctx.bot.wait_for("message", timeout=25, check=check2) + except asyncio.TimeoutError: + return await ctx.send("Trade request timed out. Canceled trade.") + + if final.content.lower() in ("no", cancel): + return await ctx.send("Trade canceled.") + + sm1 = ShopManager(ctx, instance=None, user_data=author_instance) + await sm1.add(item2, user_inv[item2], int(qty)) + await sm1.remove(item, number=quantity) + + sm2 = ShopManager(ctx, instance=None, user_data=user_instance) + await sm2.add(item, author_inventory[item], quantity) + await sm2.remove(item2, number=int(qty)) + + await ctx.send("Trade complete.") + + @shop.command() + async def tradetoggle(self, ctx): + """Disables or enables trading with you.""" + try: + instance = await self.get_instance(ctx, user=ctx.author) + except AttributeError: + return await ctx.send("You can't use this command in DMs when not in global mode.") + status = await instance.Trading() + await instance.Trading.set(not status) + await ctx.send("Trading with you is now {}.".format("disabled" if status else "enabled")) + + @shop.command() + async def version(self, ctx): + """Shows the current Shop version.""" + await ctx.send("Shop is running version {}.".format(__version__)) + + @shop.command() + @commands.is_owner() + async def wipe(self, ctx): + """Wipes all shop cog data.""" + await ctx.send( + "You are about to delete all shop and user data from the bot. Are you sure this is what you wish to do?" + ) + + try: + choice = await ctx.bot.wait_for("message", timeout=25.0, check=Checks(ctx).confirm) + except asyncio.TimeoutError: + return await ctx.send("No Response. Action canceled.") + + if choice.content.lower() == "yes": + await self.config.clear_all() + msg = "{0.name} ({0.id}) wiped all shop data.".format(ctx.author) + log.info(msg) + await ctx.send(msg) + else: + return await ctx.send("Wipe canceled.") + + @shop.command() + @global_permissions() + @commands.guild_only() + @commands.max_concurrency(1, commands.BucketType.user) + async def pending(self, ctx): + """Displays the pending menu.""" + instance = await self.get_instance(ctx, settings=True) + if not await instance.Pending(): + return await ctx.send("There are not any pending items.") + data = await instance.Pending.all() + menu = ShopMenu(ctx, data, mode=1, sorting="name") + + try: + user, item, = await menu.display() + except RuntimeError: + return + + try: + await self.clear_single_pending(ctx, instance, data, item, user) + except asyncio.TimeoutError: + await ctx.send("Request timed out.") + + @shop.command() + @commands.guild_only() + async def gift(self, ctx, user: discord.Member, quantity: int, *, item): + """Gift another user a set number of one of your items. + + The item must be in your inventory and have enough to cover the quantity. + + Examples + -------- + [p]shop gift Redjumpman 3 Healing Potion + [p]shop give @Navi 1 Demon Sword + """ + if quantity < 1: + return await ctx.send(":facepalm: How would that work genius?") + if user == ctx.author: + return await ctx.send("Really? Maybe you should find some friends.") + settings = await self.get_instance(ctx, settings=True) + if not await settings.Settings.Gifting(): + return await ctx.send("Gifting is turned off.") + author_instance = await self.get_instance(ctx, user=ctx.author) + author_inv = await author_instance.Inventory.all() + if item not in author_inv: + return await ctx.send(f"You don't own any `{item}`.") + if author_inv[item]["Qty"] < quantity: + return await ctx.send(f"You don't have that many `{item}` to give.") + + sm1 = ShopManager(ctx, instance=None, user_data=author_instance) + await sm1.remove(item, number=quantity) + + user_instance = await self.get_instance(ctx, user=user) + sm2 = ShopManager(ctx, instance=None, user_data=user_instance) + await sm2.add(item, author_inv[item], quantity) + + await ctx.send(f"{ctx.author.mention} gifted {user.mention} {quantity}x {item}.") + + @shop.command() + @global_permissions() + @commands.guild_only() + async def give(self, ctx, user: discord.Member, quantity: int, *shopitem): + """Administratively gives a user an item. + + Shopitem argument must be a \"Shop Name\" \"Item Name\" format. + + The item must be in the shop in order for this item to be given. + Only basic and role items can be given. + Giving a user an item does not affect the stock in the shop. + + Examples + -------- + [p]shop give Redjumpman 1 "Holy Temple" "Healing Potion" + [p]shop give Redjumpman 1 Junkyard Scrap + """ + if quantity < 1: + return await ctx.send(":facepalm: You can't do that.") + + if shopitem is None: + return await ctx.send_help() + + try: + shop, item = shopitem + except ValueError: + return await ctx.send('Must be a `"Shop Name" "Item Name"` format.') + + instance = await self.get_instance(ctx, settings=True) + shops = await instance.Shops.all() + if shop not in shops: + return await ctx.send("Invalid shop name.") + elif item not in shops[shop]["Items"]: + return await ctx.send("That item in not in the {} shop.".format(shop)) + elif shops[shop]["Items"][item]["Type"] not in ("basic", "role"): + return await ctx.send("You can only give basic or role type items.") + else: + data = deepcopy(shops[shop]["Items"][item]) + user_instance = await self.get_instance(ctx, user=user) + sm = ShopManager(ctx, None, user_instance) + await sm.add(item, data, quantity) + await ctx.send("{} just gave {} a {}.".format(ctx.author.mention, user.mention, item)) + + @shop.command() + @global_permissions() + @commands.guild_only() + async def clearinv(self, ctx, user: discord.Member): + """Completely clears a user's inventory.""" + await ctx.send("Are you sure you want to completely wipe {}'s inventory?".format(user.name)) + choice = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx).confirm) + if choice.content.lower() != "yes": + return await ctx.send("Canceled inventory wipe.") + instance = await self.get_instance(ctx=ctx, user=user) + await instance.Inventory.clear() + await ctx.send("Done. Inventory wiped for {}.".format(user.name)) + + @shop.command() + @global_permissions() + @commands.guild_only() + async def manager(self, ctx, action: str): + """Creates edits, or deletes a shop.""" + if action.lower() not in ("create", "edit", "delete"): + return await ctx.send("Action must be create, edit, or delete.") + instance = await self.get_instance(ctx, settings=True) + try: + if action.lower() == "create": + await self.create_shop(ctx, instance) + elif action.lower() == "edit": + await self.edit_shop(ctx, instance) + else: + await self.delete_shop(ctx, instance) + except asyncio.TimeoutError: + await ctx.send("Shop manager timed out.") + + @shop.command() + @global_permissions() + @commands.guild_only() + async def item(self, ctx, action: str): + """Creates, Deletes, and Edits items.""" + if action.lower() not in ("create", "edit", "delete"): + return await ctx.send("Must pick create, edit, or delete.") + instance = await self.get_instance(ctx, settings=True) + im = ItemManager(ctx, instance) + try: + await im.run(action) + except asyncio.TimeoutError: + return await ctx.send("Request timed out. Process canceled.") + + @shop.command() + @global_permissions() + @commands.guild_only() + async def restock(self, ctx, amount: int, *, shop_name: str): + """Restocks all items in a shop by a specified amount. + + This command will not restock auto items, because they are required + to have an equal number of messages and stock. + """ + instance = await self.get_instance(ctx, settings=True) + shop = shop_name + if shop not in await instance.Shops(): + return await ctx.send("That shop does not exist.") + await ctx.send( + "Are you sure you wish to increase the quantity of all " + "items in {} by {}?\n*Note, this won't affect auto items.*" + "".format(shop, amount) + ) + try: + choice = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx).confirm) + except asyncio.TimeoutError: + return await ctx.send("Response timed out.") + + if choice.content.lower() == "yes": + async with instance.Shops() as shops: + for item in shops[shop]["Items"].values(): + if item["Type"] != "auto": + try: + item["Qty"] += amount + except TypeError: + continue + await ctx.send("All items in {} have had their quantities increased by {}.".format(shop, amount)) + else: + await ctx.send("Restock canceled.") + + @shop.command() + @global_permissions() + @commands.guild_only() + async def bulkadd(self, ctx, style: str, *, entry: str): + """Add multiple items and shops. + + Bulk accepts two styles: text or a file. If you choose + file, then the next argument is your file name. + + Files should be saved in your CogManager/cogs/shop/data path. + + If you choose text, then each line will be parsed. + Each entry begins as a new line. All parameters MUST + be separated by a comma. + + Parameters: + ---------- + Shop Name, Item Name, Type, Quantity, Cost, Info, Role + Role is only required if the type is set to role. + + Examples + ------- + Holy Temple, Torch, basic, 20, 5, Provides some light. + Holy Temple, Divine Training, role, 20, 500, Gives Priest role., Priest + Junkyard, Mystery Box, random, 20, 500, Random piece of junk. + + For more information on the parameters visit the shop wiki. + """ + if style.lower() not in ("file", "text"): + return await ctx.send("Invalid style type. Must be file or text.") + + msg = await ctx.send("Beginning bulk upload process for shop. This may take a while...") + instance = await self.get_instance(ctx, settings=True) + parser = Parser(ctx, instance, msg) + if style.lower() == "file": + if not ctx.bot.is_owner(ctx.author): + return await ctx.send("Only the owner can add items via csv files.") + fp = bundled_data_path(self) / f"{entry}.csv" + await parser.search_csv(fp) + else: + await parser.parse_text_entry(entry) + + # ----------------------------------------------------------------------------- + + @commands.group(autohelp=True) + async def setshop(self, ctx): + """Shop Settings group command""" + pass + + @setshop.command() + @commands.is_owner() + async def mode(self, ctx): + """Toggles Shop between global and local modes. + + When shop is set to local mode, each server will have its own + unique data, and admin level commands can be used on that server. + + When shop is set to global mode, data is linked between all servers + the bot is connected to. In addition, admin level commands can only be + used by the owner or co-owners. + """ + author = ctx.author + mode = "global" if await self.shop_is_global() else "local" + alt = "local" if mode == "global" else "global" + await ctx.send( + "Shop is currently set to {} mode. Would you like to change to {} mode instead?".format(mode, alt) + ) + checks = Checks(ctx) + try: + choice = await ctx.bot.wait_for("message", timeout=25.0, check=checks.confirm) + except asyncio.TimeoutError: + return await ctx.send("No response. Action canceled.") + + if choice.content.lower() != "yes": + return await ctx.send("Shop will remain {}.".format(mode)) + await ctx.send( + "Changing shop to {0} will **DELETE ALL** current shop data. Are " + "you sure you wish to make shop {0}?".format(alt) + ) + try: + final = await ctx.bot.wait_for("message", timeout=25.0, check=checks.confirm) + except asyncio.TimeoutError: + return await ctx.send("No response. Action canceled.") + + if final.content.lower() == "yes": + await self.change_mode(alt) + log.info("{} ({}) changed the shop mode to {}.".format(author.name, author.id, alt)) + await ctx.send("Shop data deleted! Shop mode is now set to {}.".format(alt)) + else: + await ctx.send("Shop will remain {}.".format(mode)) + + @setshop.command() + @global_permissions() + @commands.guild_only() + async def alertrole(self, ctx, role: discord.Role): + """Sets the role that will receive alerts. + + Alerts will be sent to any user who has this role on the server. If + the shop is global, then the owner will receive alerts regardless + of their role, until they turn off alerts. + """ + if role.name == "Bot": + return + instance = await self.get_instance(ctx, settings=True) + await instance.Settings.Alert_Role.set(role.name) + await ctx.send("Alert role has been set to {}.".format(role.name)) + + @setshop.command() + @global_permissions() + @commands.guild_only() + async def alerts(self, ctx): + """Toggles alerts when users redeem items.""" + instance = await self.get_instance(ctx, settings=True) + status = await instance.Settings.Alerts() + await instance.Settings.Alerts.set(not status) + await ctx.send("Alert role will {} messages.".format("no longer" if status else "receive")) + + @setshop.command() + @global_permissions() + @commands.guild_only() + async def sorting(self, ctx, style: str): + """Set how shop items are sorted. + + Options: price, quantity, or name (alphabetical) + By default shops are ordered by price.""" + instance = await self.get_instance(ctx, settings=True) + if style not in ("price", "quantity", "name"): + return await ctx.send("You must pick a valid sorting option: `price`, `quantity`, or `name`.") + await instance.Settings.Sorting.set(style.lower()) + await ctx.send(f"Shops will now be sorted by {style}.") + + @setshop.command() + @global_permissions() + @commands.guild_only() + async def gifting(self, ctx): + """Toggles if users can gift items.""" + instance = await self.get_instance(ctx, settings=True) + status = await instance.Settings.Gifting() + await instance.Settings.Gifting.set(not status) + await ctx.send(f"Gifting is now {'OFF'} if status else {'ON'}.") + + @setshop.command() + @global_permissions() + @commands.guild_only() + async def toggle(self, ctx): + """Closes/opens all shops.""" + instance = await self.get_instance(ctx, settings=True) + status = await instance.Settings.Closed() + await instance.Settings.Closed.set(not status) + await ctx.send("Shops are now {}.".format("open" if status else "closed")) + + # ------------------------------------------------------------------------------- + + @staticmethod + async def check_availability(ctx, shops): + if ctx.guild: + perms = ctx.author.guild_permissions.administrator + author_roles = [r.name for r in ctx.author.roles] + return [x for x, y in shops.items() if (y["Role"] in author_roles or perms) and y["Items"]] + + @staticmethod + async def clear_single_pending(ctx, instance, data, item, user): + item_name = data[str(user.id)][item]["Item"] + await ctx.send( + "You are about to clear a pending {} for {}.\nAre you sure " + "you wish to clear this item?".format(item_name, user.name) + ) + choice = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx).confirm) + if choice.content.lower() == "yes": + async with instance.Pending() as p: + del p[str(user.id)][item] + if not p[str(user.id)]: + del p[str(user.id)] + await ctx.send("{} was cleared from {}'s pending by {}.".format(item_name, user.name, ctx.author.name)) + await user.send("{} cleared your pending {}!".format(ctx.author.name, item_name)) + else: + await ctx.send("Action canceled.") + + @staticmethod + async def clear_all_pending(ctx, instance, user): + await ctx.send("You are about to clear all pending items from {}.\nAre you sure you wish to do this?") + choice = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx).confirm) + if choice.content.lower() == "yes": + async with instance.Pending() as p: + del p[user.id] + await ctx.send("All pending items have been cleared for {}.".format(user.name)) + await user.send("{} cleared **ALL** of your pending items.".format(ctx.author.name)) + else: + await ctx.send("Action canceled.") + + async def get_instance(self, ctx, settings=False, user=None): + if not user: + user = ctx.author + + if await self.config.Global(): + if settings: + return self.config + else: + return self.config.user(user) + else: + if settings: + return self.config.guild(ctx.guild) + else: + return self.config.member(user) + + async def assign_role(self, ctx, instance, item, role_name): + if await self.config.Global(): + if not ctx.guild: + return await ctx.send( + "Unable to assign role, because shop is in global mode." + "Try redeeming your item in a server instead of in DMs." + ) + + role = discord.utils.get(ctx.message.guild.roles, name=role_name) + if role is None: + return await ctx.send( + "Could not assign the role, {}, because it does not exist on the server.".format(role_name) + ) + try: + await ctx.author.add_roles(role, reason="Shop role token was redeemed.") + except discord.Forbidden: + await ctx.send( + "The bot could not add this role because it does not have the " + "permission to do so. Make sure the bot has the permissions enabled and " + "its role is higher than the role that needs to be assigned." + ) + return False + sm = ShopManager(ctx, None, instance) + await sm.remove(item) + await ctx.send("{} was granted the {} role.".format(ctx.author.mention, role.name)) + + async def pending_add(self, ctx, item): + instance = await self.get_instance(ctx, settings=True) + unique_id = str(uuid.uuid4())[:17] + timestamp = ctx.message.created_at.now().strftime("%Y-%m-%d %H:%M:%S") + async with instance.Pending() as p: + if str(ctx.author.id) in p: + p[str(ctx.author.id)][unique_id] = {"Item": item, "Timestamp": timestamp} + else: + p[str(ctx.author.id)] = {unique_id: {"Item": item, "Timestamp": timestamp}} + msg = "{} added {} to your pending list.".format(ctx.author.mention, item) + if await instance.Settings.Alerts(): + alert_role = await instance.Settings.Alert_Role() + role = discord.utils.get(ctx.guild.roles, name=alert_role) + if role: + msg = "{}\n{}".format(role.mention, msg) + await ctx.send(msg) + + async def change_mode(self, mode): + await self.config.clear_all() + if mode == "global": + await self.config.Global.set(True) + + async def shop_is_global(self): + return await self.config.Global() + + async def edit_shop(self, ctx, instance): + shops = await instance.Shops.all() + await ctx.send("What shop would you like to edit?") + name = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx, custom=shops).content) + + await ctx.send("Would you like to change the shop's `name` or `role` requirement?") + choice = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx, custom=("name", "role")).content) + if choice.content.lower() == "name": + await ctx.send("What is the new name for this shop?") + new_name = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx, length=25).length_under) + async with instance.Shops() as shops: + shops[new_name.content] = shops.pop(name.content) + return await ctx.send("Name changed to {}.".format(new_name.content)) + else: + await ctx.send("What is the new role for this shop?") + role = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx).role) + async with instance.Shops() as shops: + shops[name.content]["Role"] = role.content + await ctx.send("{} is now restricted to only users with the {} role.".format(name.content, role.content)) + + async def delete_shop(self, ctx, instance): + shops = await instance.Shops.all() + await ctx.send("What shop would you like to delete?") + name = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx, custom=shops).content) + await ctx.send("Are you sure you wish to delete {} and all of its items?".format(name.content)) + choice = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx).confirm) + + if choice.content.lower() == "no": + return await ctx.send("Shop deletion canceled.") + async with instance.Shops() as shops: + del shops[name.content] + await ctx.send("{} was deleted.".format(name.content)) + + async def create_shop(self, ctx, instance): + await ctx.send("What is the name of this shop?\nName must be 25 characters or less.") + name = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx, length=25).length_under) + + if name.content.startswith(ctx.prefix): + return await ctx.send("Closing shop creation. Please don't run commands while attempting to create a shop.") + + if name.content in await instance.Shops(): + return await ctx.send("A shop with this name already exists.") + + msg = ( + "What role can use this shop? Use `all` for everyone.\n" + "*Note: this role must exist on this server and is case sensitive.*\n" + ) + if await self.shop_is_global(): + msg += ( + "Shop is also currently in global mode. If you choose to restrict " + "this shop to a role that is on this server, the shop will only be " + "visible on this server to people with the role." + ) + await ctx.send(msg) + + def predicate(m): + if m.author == ctx.author: + if m.content in [r.name for r in ctx.guild.roles]: + return True + elif m.content.lower() == "all": + return True + else: + return False + else: + return False + + try: + role = await ctx.bot.wait_for("message", timeout=25.0, check=predicate) + except asyncio.TimeoutError: + return await ctx.send("Response timed out. Shop creation ended.") + + role_name = role.content if role.content != "all" else "@everyone" + async with instance.Shops() as shops: + shops[name.content] = {"Items": {}, "Role": role_name} + await ctx.send( + "Added {} to the list of shops.\n" + "**NOTE:** This shop will not show up until an item is added to it's " + "list.".format(name.content) + ) + + async def pending_prompt(self, ctx, instance, data, item): + e = discord.Embed(color=await ctx.embed_colour()) + e.add_field(name=item, value=data[item]["Info"], inline=False) + if data[item]["Type"].lower() == "Role": + await ctx.send( + "{} Do you wish to redeem {}? This will grant you the role assigned to " + "this item and it will be removed from your inventory " + "permanently.".format(ctx.author.mention, item), + embed=e, + ) + else: + await ctx.send( + "{} Do you wish to redeem {}? This will add the item to the pending " + "list for an admin to review and grant. The item will be removed from " + "your inventory while this is " + "processing.".format(ctx.author.mention, item), + embed=e, + ) + try: + choice = await ctx.bot.wait_for("message", timeout=25, check=Checks(ctx).confirm) + except asyncio.TimeoutError: + return await ctx.send("No Response. Item redemption canceled.") + + if choice.content.lower() != "yes": + return await ctx.send("Canceled item redemption.") + + if data[item]["Type"].lower() == "role": + return await self.assign_role(ctx, instance, item, data[item]["Role"]) + else: + await self.pending_add(ctx, item) + + sm = ShopManager(ctx, instance=None, user_data=instance) + await sm.remove(item) + + +class ShopManager: + def __init__(self, ctx, instance, user_data): + self.ctx = ctx + self.instance = instance + self.user_data = user_data + + @staticmethod + def weighted_choice(choices): + """Stack Overflow: https://stackoverflow.com/a/4322940/6226473""" + values, weights = zip(*choices) + total = 0 + cum_weights = [] + for w in weights: + total += w + cum_weights.append(total) + x = random.random() * total + i = bisect(cum_weights, x) + return values[i] + + async def random_item(self, shop): + async with self.instance.Shops() as shops: + try: + return self.weighted_choice( + [(x, y["Cost"]) for x, y in shops[shop]["Items"].items() if y["Type"] != "random"] + ) + except IndexError: + return + + async def auto_handler(self, shop, item, amount): + async with self.instance.Shops() as shops: + msgs = [shops[shop]["Items"][item]["Messages"].pop() for _ in range(amount)] + msg = "\n".join(msgs) + if len(msg) < 2000: + await self.ctx.author.send(msg) + else: + chunks = textwrap.wrap(msg, 2000) + for chunk in chunks: + await asyncio.sleep(2) # At least a little buffer to prevent rate limiting + await self.ctx.author.send(chunk) + + async def order(self, shop, item): + try: + async with self.instance.Shops() as shops: + item_data = deepcopy(shops[shop]["Items"][item]) + except KeyError: + return await self.ctx.send("Could not locate that shop or item.") + + cur = await bank.get_currency_name(self.ctx.guild) + stock, cost, _type = item_data["Qty"], item_data["Cost"], item_data["Type"] + e = discord.Embed(color=await self.ctx.embed_colour()) + e.add_field(name=item, value=item_data["Info"], inline=False) + text = ( + f"How many {item} would you like to purchase?\n*If this " + f"is a random item, you can only buy 1 at a time.*" + ) + await self.ctx.send(content=text, embed=e) + + def predicate(m): + if m.author == self.ctx.author and self.ctx.channel == m.channel: + if m.content.isdigit(): + if _type == "random": + return int(m.content) == 1 + try: + return 0 < int(m.content) <= stock + except TypeError: + return 0 < int(m.content) + elif m.content.lower() in ("exit", "cancel", "e", "x"): + return True + else: + return False + + num = await self.ctx.bot.wait_for("message", timeout=25.0, check=predicate) + if num.content.lower() in ("exit", "cancel", "e", "x"): + raise ExitProcess() + amount = int(num.content) + try: + await num.delete() + except (discord.NotFound, discord.Forbidden): + pass + + cost *= amount + try: + await bank.withdraw_credits(self.ctx.author, cost) + except ValueError: + return await self.ctx.send( + "You cannot afford {}x {} for {} {}. Transaction ended.".format(num.content, item, cost, cur) + ) + im = ItemManager(self.ctx, self.instance) + if _type == "auto": + await self.auto_handler(shop, item, amount) + await im.remove(shop, item, stock, amount) + return await self.ctx.send("Message sent.") + + if _type == "random": + new_item = await self.random_item(shop) + if new_item is None: + try: + await bank.deposit_credits(self.ctx.author, cost) + except BalanceTooHigh as e: + await bank.set_balance(self.ctx.author, e.max_balance) + return await self.ctx.send( + "There aren't any non-random items available in {}, " + "so {} cannot be purchased.".format(shop, item) + ) + else: + await im.remove(shop, item, stock, amount) + item = new_item + async with self.instance.Shops() as shops: + item_data = deepcopy(shops[shop]["Items"][new_item]) + stock = item_data["Qty"] + + await im.remove(shop, item, stock, amount) + await self.add(item, item_data, amount) + await self.ctx.send("{} purchased {}x {} for {} {}.".format(self.ctx.author.mention, amount, item, cost, cur)) + + async def add(self, item, data, quantity): + async with self.user_data.Inventory() as inv: + if item in inv: + inv[item]["Qty"] += quantity + else: + inv[item] = data + inv[item]["Qty"] = quantity + + async def remove(self, item, number=1): + async with self.user_data.Inventory() as inv: + if number >= inv[item]["Qty"]: + del inv[item] + else: + inv[item]["Qty"] -= number + + +class ItemManager: + def __init__(self, ctx, instance): + self.ctx = ctx + self.instance = instance + + async def run(self, action): + + if action.lower() == "create": + await self.create() + elif action.lower() == "edit": + await self.edit() + else: + await self.delete() + + async def create(self): + name = await self.set_name() + if not name: + return + cost = await self.set_cost() + info = await self.set_info() + _type, role, msgs = await self.set_type() + if _type != "auto": + qty = await self.set_quantity(_type) + else: + qty = len(msgs) + + data = { + "Cost": cost, + "Qty": qty, + "Type": _type, + "Info": info, + "Role": role, + "Messages": msgs, + } + + msg = "What shop would you like to add this item to?\n" + shops = await self.instance.Shops() + msg += "Current shops are: " + msg += humanize_list([f"`{shopname}`" for shopname in sorted(shops.keys())]) + await self.ctx.send(msg) + shop = await self.ctx.bot.wait_for("message", timeout=25, check=Checks(self.ctx, custom=shops.keys()).content) + await self.add(data, shop.content, name) + await self.ctx.send("Item creation complete. Check your logs to ensure it went to the approriate shop.") + + async def delete(self): + shop_list = await self.instance.Shops.all() + + def predicate(m): + if self.ctx.author != m.author: + return False + return m.content in shop_list + + await self.ctx.send("What shop would you like to delete an item from?") + shop = await self.ctx.bot.wait_for("message", timeout=25, check=predicate) + + def predicate2(m): + if self.ctx.author != m.author: + return False + return m.content in shop_list[shop.content]["Items"] + + await self.ctx.send("What item would you like to delete from this shop?") + item = await self.ctx.bot.wait_for("message", timeout=25, check=predicate2) + await self.ctx.send("Are you sure you want to delete {} from {}?".format(item.content, shop.content)) + choice = await self.ctx.bot.wait_for("message", timeout=25, check=Checks(self.ctx).confirm) + if choice.content.lower() == "yes": + async with self.instance.Shops() as shops: + del shops[shop.content]["Items"][item.content] + await self.ctx.send("{} was deleted from the {}.".format(item.content, shop.content)) + else: + await self.ctx.send("Item deletion canceled.") + + async def edit(self): + choices = ("name", "type", "role", "qty", "cost", "msgs", "quantity", "info", "messages") + + while True: + shop, item, item_data = await self.get_item() + + def predicate(m): + if self.ctx.author != m.author: + return False + if m.content.lower() in ("msgs", "messages") and item_data["Type"] != "auto": + return False + elif m.content.lower() in ("qty", "quantity") and item_data["Type"] == "auto": + return False + elif m.content.lower() == "role" and item_data["Type"] != "role": + return False + elif m.content.lower() not in choices: + return False + else: + return True + + await self.ctx.send( + "What would you like to edit for this item?\n" + "`Name`, `Type`, `Role`, `Quantity`, `Cost`, `Info`, or `Messages`?\n" + "Note that `Messages` cannot be edited on non-auto type items." + ) + choice = await self.ctx.bot.wait_for("message", timeout=25.0, check=predicate) + + if choice.content.lower() == "name": + await self.set_name(item=item, shop=shop) + elif choice.content.lower() == "type": + await self.set_type(item, shop) + elif choice.content.lower() == "role": + await self.set_role(item, shop) + elif choice.content.lower() in ("qty", "quantity"): + await self.set_quantity(item_data["Type"], item, shop) + elif choice.content.lower() == "cost": + await self.set_cost(item=item, shop=shop) + elif choice.content.lower() == "info": + await self.set_info(item=item, shop=shop) + else: + await self.set_messages(item_data["Type"], item=item, shop=shop) + + await self.ctx.send("Would you like to continue editing?") + rsp = await self.ctx.bot.wait_for("message", timeout=25, check=Checks(self.ctx).confirm) + if rsp.content.lower() == "no": + await self.ctx.send("Editing process exited.") + break + + async def set_messages(self, _type, item=None, shop=None): + if _type != "auto": + return await self.ctx.send("You can only add messages to auto type items.") + await self.ctx.send( + "Auto items require a message to be stored per quantity. Separate each " + "message with a new line using a code block." + ) + msgs = await self.ctx.bot.wait_for("message", timeout=120, check=Checks(self.ctx).same) + auto_msgs = [x.strip() for x in msgs.content.strip("`").split("\n") if x] + if item: + async with self.instance.Shops() as shops: + shops[shop]["Items"][item]["Messages"].extend(auto_msgs) + shops[shop]["Items"][item]["Qty"] += len(auto_msgs) + return await self.ctx.send("{} messages were added to {}.".format(len(auto_msgs), item)) + return auto_msgs + + async def set_name(self, item=None, shop=None): + await self.ctx.send("Enter a name for this item. It can't be longer than 20 characters.") + name = await self.ctx.bot.wait_for("message", timeout=25, check=Checks(self.ctx, length=30).length_under) + + if name.content.startswith(self.ctx.prefix): + await self.ctx.send("Closing item creation. Please don't run commands while attempting to create an item.") + return None + + if item: + async with self.instance.Shops() as shops: + shops[shop]["Items"][name.content] = shops[shop]["Items"].pop(item) + return await self.ctx.send("{}'s name was changed to {}.".format(item, name.content)) + return name.content + + async def set_cost(self, item=None, shop=None): + await self.ctx.send("Enter the new cost for this item.") + cost = await self.ctx.bot.wait_for("message", timeout=25, check=Checks(self.ctx).positive) + + if item: + async with self.instance.Shops() as shops: + shops[shop]["Items"][item]["Cost"] = int(cost.content) + return await self.ctx.send("This item now costs {}.".format(cost.content)) + return int(cost.content) + + def hierarchy_check(self, m): + roles = [r.name for r in self.ctx.guild.roles if r.name != "Bot"] + if self.ctx.author == m.author and m.content in roles: + if self.ctx.author.top_role >= discord.utils.get(self.ctx.message.guild.roles, name=m.content): + return True + else: + return False + else: + return False + + async def set_role(self, item=None, shop=None): + await self.ctx.send( + "What role do you wish for this item to assign?\n*Note, you cannot add a role higher than your own.*" + ) + role = await self.ctx.bot.wait_for("message", timeout=25, check=self.hierarchy_check) + if item: + async with self.instance.Shops() as shops: + shops[shop]["Items"][item]["Role"] = role.content + return await self.ctx.send("This item now assigns the {} role.".format(role.content)) + return role.content + + async def set_quantity(self, _type=None, item=None, shop=None): + if _type == "auto": + return await self.ctx.send( + "You can't change the quantity of an auto item. The quantity will match the messages set." + ) + + await self.ctx.send("What quantity do you want to set this item to?\nType 0 for infinite.") + + def check(m): + return m.author == self.ctx.author and m.content.isdigit() and int(m.content) >= 0 + + qty = await self.ctx.bot.wait_for("message", timeout=25, check=check) + qty = int(qty.content) if int(qty.content) > 0 else "--" + if item: + async with self.instance.Shops() as shops: + shops[shop]["Items"][item]["Qty"] = qty + return await self.ctx.send( + "Quantity for {} now set to {}.".format(item, "infinite." if qty == "--" else qty) + ) + return qty + + async def set_type(self, item=None, shop=None): + valid_types = ("basic", "random", "auto", "role") + await self.ctx.send( + "What is the item type?\n" + "```\n" + "basic - Normal item and is added to the pending list when redeemed.\n" + "random - Picks a random item in the shop, weighted on cost.\n" + "role - Grants a role when redeemed.\n" + "auto - DM's a msg to the user instead of adding to their inventory.\n" + "```" + ) + _type = await self.ctx.bot.wait_for("message", timeout=25, check=Checks(self.ctx, custom=valid_types).content) + + if _type.content.lower() == "auto": + msgs = await self.set_messages("auto", item=item, shop=shop) + if not item: + return "auto", None, msgs + elif _type.content.lower() == "role": + role = await self.set_role(item=item, shop=shop) + if not item: + return "role", role, None + else: + if item: + async with self.instance.Shops() as shops: + shops[shop]["Items"][item]["Type"] = _type.content.lower() + try: + del shops[shop]["Items"][item]["Messages"] + del shops[shop]["Items"][item]["Role"] + except KeyError: + pass + return await self.ctx.send("Item type set to {}.".format(_type.content.lower())) + return _type.content.lower(), None, None + async with self.instance.Shops() as shops: + shops[shop]["Items"][item]["Type"] = _type.content.lower() + + async def set_info(self, item=None, shop=None): + await self.ctx.send("Specify the info text for this item.\n*Note* cannot be longer than 500 characters.") + info = await self.ctx.bot.wait_for("message", timeout=40, check=Checks(self.ctx, length=500).length_under) + if item: + async with self.instance.Shops() as shops: + shops[shop]["Items"][item]["Info"] = info.content + return await self.ctx.send("Info now set to:\n{}".format(info.content)) + return info.content + + async def get_item(self): + shops = await self.instance.Shops.all() + + await self.ctx.send("What shop is the item you would like to edit in?") + shop = await self.ctx.bot.wait_for("message", timeout=25.0, check=Checks(self.ctx, custom=shops).content) + + items = shops[shop.content]["Items"] + await self.ctx.send("What item would you like to edit?") + item = await self.ctx.bot.wait_for("message", timeout=25.0, check=Checks(self.ctx, custom=items).content) + + return shop.content, item.content, shops[shop.content]["Items"][item.content] + + async def add(self, data, shop, item, new_allowed=False): + async with self.instance.Shops() as shops: + if shop not in shops: + if new_allowed: + shops[shop] = {"Items": {item: data}, "Role": "@everyone"} + return log.info("Created the shop: {} and added {}.".format(shop, item)) + log.error("{} could not be added to {}, because it does not exist.".format(item, shop)) + elif item in shops[shop]["Items"]: + log.error("{} was not added because that item already exists in {}.".format(item, shop)) + else: + shops[shop]["Items"][item] = data + log.info("{} added to {}.".format(item, shop)) + + async def remove(self, shop, item, stock, amount): + try: + remainder = stock - amount + async with self.instance.Shops() as shops: + if remainder > 0: + shops[shop]["Items"][item]["Qty"] = remainder + else: + del shops[shop]["Items"][item] + except TypeError: + pass + return + + +class Parser: + def __init__(self, ctx, instance, msg): + self.ctx = ctx + self.instance = instance + self.msg = msg + + @staticmethod + def basic_checks(idx, row): + if len(row["Shop"]) > 25: + log.warning("Row {} was not added because shop name was too long.".format(idx)) + return False + elif len(row["Item"]) > 30: + log.warning("Row {} was not added because item name was too long.".format(idx)) + return False + elif not row["Cost"].isdigit() or int(row["Cost"]) < 0: + log.warning("Row {} was not added because the cost was lower than 0.".format(idx)) + return False + elif not row["Qty"].isdigit() or int(row["Qty"]) < 0: + log.warning("Row {} was not added because the quantity was lower than 0.".format(idx)) + return False + elif len(row["Info"]) > 500: + log.warning("Row {} was not added because the info was too long.".format(idx)) + return False + else: + return True + + def type_checks(self, idx, row, messages): + if row["Type"].lower() not in ("basic", "random", "auto", "role"): + log.warning("Row {} was not added because of an invalid type.".format(idx)) + return False + elif row["Type"].lower() == "role" and not row["Role"]: + log.warning("Row {} was not added because the type is a role, but no role was set.".format(idx)) + return False + elif ( + row["Type"].lower() == "role" and discord.utils.get(self.ctx.message.guild.roles, name=row["Role"]) is None + ): + log.warning( + "Row {} was not added because the {} role does not exist on the server.".format(idx, row["Role"]) + ) + return False + elif row["Type"].lower() == "auto" and int(row["Qty"]) == 0: + log.warning("Row {} was not added because auto items cannot have an infinite quantity.".format(idx)) + return False + elif row["Type"].lower() == "auto" and int(row["Qty"]) != len(messages): + log.warning( + "Row {} was not added because auto items must have an equal number of " + "messages and quantity.".format(idx) + ) + return False + elif row["Type"].lower() == "auto" and any(len(x) > 2000 for x in messages): + log.warning("Row {} was not added because one of the messages exceeds 2000 characters.".format(idx)) + return False + elif row["Type"].lower() == "role": + if discord.utils.get(self.ctx.message.guild.roles, name=row["Role"]) > self.ctx.author.top_role: + log.warning( + "Row {} was not added because the {} role is higher than the " + "shopkeeper's highest role.".format(idx, row["Role"]) + ) + return False + else: + return True + else: + return True + + async def parse_text_entry(self, text): + keys = ("Shop", "Item", "Type", "Qty", "Cost", "Info", "Role", "Messages") + raw_data = [ + [f.strip() for f in y] for y in [x.split(",") for x in text.strip("`").split("\n") if x] if 6 <= len(y) <= 8 + ] + bulk = [{key: value for key, value in zip_longest(keys, x)} for x in raw_data] + await self.parse_bulk(bulk) + + async def search_csv(self, file_path): + try: + with file_path.open("rt") as f: + reader = csv.DictReader(f, delimiter=",") + await self.parse_bulk(reader) + except FileNotFoundError: + await self.msg.edit(content="The specified filename could not be found.") + + async def parse_bulk(self, reader): + if not reader: + return await self.msg.edit(content="Data was faulty. No data was added.") + + keys = ("Cost", "Qty", "Type", "Info", "Role", "Messages") + for idx, row in enumerate(reader, 1): + try: + messages = [x.strip() for x in row["Messages"].split(",") if x] + except AttributeError: + messages = [] + + if not self.basic_checks(idx, row): + continue + elif not self.type_checks(idx, row, messages): + continue + else: + data = { + key: row.get(key, None) + if key not in ("Cost", "Qty", "Messages") + else int(row[key]) + if key != "Messages" + else messages + for key in keys + } + if data["Qty"] == 0: + data["Qty"] = "--" + item_manager = ItemManager(self.ctx, self.instance) + await item_manager.add(data, row["Shop"], row["Item"], new_allowed=True) + await self.msg.edit(content="Bulk process finished. Please check your console for more information.") + + +class ExitProcess(Exception): + pass diff --git a/translator/menus.py b/translator/menus.py new file mode 100644 index 0000000..85c803c --- /dev/null +++ b/translator/menus.py @@ -0,0 +1,19 @@ +import discord + +class ButtonMenu(discord.ui.View): + def __init__(self, embeds: list, timeout: int = 180): + super().__init__(timeout=timeout) + self.embeds = embeds + self.current_page = 0 + + @discord.ui.button(label="◀️", style=discord.ButtonStyle.gray) + async def previous(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page > 0: + self.current_page -= 1 + await interaction.response.edit_message(embed=self.embeds[self.current_page]) + + @discord.ui.button(label="▶️", style=discord.ButtonStyle.gray) + async def next(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page < len(self.embeds) - 1: + self.current_page += 1 + await interaction.response.edit_message(embed=self.embeds[self.current_page]) \ No newline at end of file diff --git a/translator/translator.py b/translator/translator.py index 4586b78..99ded20 100644 --- a/translator/translator.py +++ b/translator/translator.py @@ -5,7 +5,7 @@ from discord import app_commands import asyncio from datetime import datetime from redbot.core.bot import Red -from . import ButtonMenu +from .menus import ButtonMenu class TranslatorCog(commands.Cog): """Translate messages using Google Translate"""