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.
Some checks are pending
Run pre-commit / Run pre-commit (push) Waiting to run

This commit is contained in:
Valerie 2025-05-24 04:09:52 -04:00
parent 3fcd50cbf7
commit 7fc2053951
10 changed files with 1872 additions and 1 deletions

View file

@ -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:

8
shop/__init__.py Normal file
View file

@ -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)

54
shop/checks.py Normal file
View file

@ -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.")

11
shop/data/Example.csv Normal file
View file

@ -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.,,
1 Shop Item Type Cost Qty Info Role Messages
2 Holy Temple Bread basic 25 12 Hard as a rock.
3 Holy Temple Holy Water basic 30 8 Strong against Demons.
4 Holy Temple Divine Training role 500 5 Grants the Priest role. Priest
5 Holy Temple Healing Potion basic 85 10 Recovers some health.
6 Holy Temple Enchanted Stave basic 900 1 Divine blunt weapon.
7 Holy Temple Box of faith random 100 0 Pray for prosperity.
8 Holy Temple Angelic Whisper auto 5 6 Your prayers answered. msg1, msg2, msg3, msg4, msg5, msg6
9 Holy Temple Robes basic 25 18 Simple cloth robes.
10 Mage Tower Wand of Inferno basic 9000 1 Fiery magic implement.
11 Mage Tower Magic Beans basic 5 3 These might be useful.

12
shop/info.json Normal file
View file

@ -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."
}

97
shop/inventory.py Normal file
View file

@ -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

246
shop/menu.py Normal file
View file

@ -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

1423
shop/shop.py Normal file

File diff suppressed because it is too large Load diff

19
translator/menus.py Normal file
View file

@ -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])

View file

@ -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"""