diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..f50eb496 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: No767 +ko_fi: no767 \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 31331b91..91105dbc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,28 +7,10 @@ on: pull_request: branches: - dev - -# env: -# DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres jobs: Analyze: runs-on: ubuntu-latest - - # services: - # postgres: - # image: postgres:15 - # env: - # POSTGRES_USER: postgres - # POSTGRES_PASSWORD: postgres - # POSTGRES_DB: postgres - # ports: - # - 5432:5432 - # options: >- - # --health-cmd pg_isready - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 strategy: fail-fast: false @@ -65,5 +47,6 @@ jobs: poetry run pyright Bot - name: Run Ruff + # the rewrite for using PEP8 standards will come later run: | poetry run ruff Bot diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index 8641631c..7dd47538 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -18,7 +18,7 @@ jobs: with: python-version: '3.11' - name: Set up Node.js 18 - uses: actions/setup-node@v3.7.0 + uses: actions/setup-node@v3.8.0 with: node-version: '20' - name: Install Snyk CLI diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 898399f0..ff713aea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,79 +1,79 @@ -name: Tests - -on: - push: - branches: - - dev - - pull_request: - branches: - - dev - -env: - POSTGRES_URI: postgresql://postgres:postgres@localhost:5432/postgres - REDIS_URI: redis://localhost:6379/0 - - -jobs: - Test: - name: Test (${{ matrix.version }}) - runs-on: ubuntu-latest - - services: - redis: - image: redis/redis-stack-server:7.0.6-RC8 - ports: - - 6379:6379 - - postgres: - image: postgres:15 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - strategy: - fail-fast: false - matrix: - version: [3.8, 3.9, '3.10', '3.11'] - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Codecov Uploader - run: | - curl -Os https://uploader.codecov.io/latest/linux/codecov - chmod +x codecov - ./codecov - - - name: Set up Python - id: setup-python - uses: actions/setup-python@v4.7.0 - with: - python-version: ${{ matrix.version }} - - - name: Set up Poetry - uses: Gr1N/setup-poetry@v8 - - - name: Install Nox - run: | - pip install --upgrade nox - - - name: Run Tests - run: | - RAW_PYTHON_VERSION=${{ matrix.version }} - PYTHON_VERSION=$(echo $RAW_PYTHON_VERSION | sed 's/\.//') - nox --sessions test$PYTHON_VERSION - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: ./coverage.xml +name: Tests + +on: + push: + branches: + - dev + + pull_request: + branches: + - dev + +env: + POSTGRES_URI: postgresql://postgres:postgres@localhost:5432/postgres + REDIS_URI: redis://localhost:6379/0 + + +jobs: + Test: + name: Test (${{ matrix.version }}) + runs-on: ubuntu-latest + + services: + redis: + image: redis/redis-stack-server:7.2.0-RC3 + ports: + - 6379:6379 + + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + strategy: + fail-fast: false + matrix: + version: [3.8, 3.9, '3.10', '3.11'] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Codecov Uploader + run: | + curl -Os https://uploader.codecov.io/latest/linux/codecov + chmod +x codecov + ./codecov + + - name: Set up Python + id: setup-python + uses: actions/setup-python@v4.7.0 + with: + python-version: ${{ matrix.version }} + + - name: Set up Poetry + uses: Gr1N/setup-poetry@v8 + + - name: Install Nox + run: | + pip install --upgrade nox + + - name: Run Tests + run: | + RAW_PYTHON_VERSION=${{ matrix.version }} + PYTHON_VERSION=$(echo $RAW_PYTHON_VERSION | sed 's/\.//') + nox --sessions test$PYTHON_VERSION + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml \ No newline at end of file diff --git a/Bot/Cogs/__init__.py b/Bot/Cogs/__init__.py index e83970d1..f9a0c60e 100644 --- a/Bot/Cogs/__init__.py +++ b/Bot/Cogs/__init__.py @@ -1,3 +1,16 @@ -from pkgutil import iter_modules - -EXTENSIONS = [module.name for module in iter_modules(__path__, f"{__package__}.")] +from pkgutil import iter_modules +from typing import Literal, NamedTuple + + +class VersionInfo(NamedTuple): + major: int + minor: int + micro: int + releaselevel: Literal["dev", "alpha", "beta", "final"] + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.micro}-{self.releaselevel}" + + +EXTENSIONS = [module.name for module in iter_modules(__path__, f"{__package__}.")] +VERSION: VersionInfo = VersionInfo(major=0, minor=11, micro=0, releaselevel="final") diff --git a/Bot/Cogs/actions.py b/Bot/Cogs/actions.py index 3c9bb28f..c4dbc090 100644 --- a/Bot/Cogs/actions.py +++ b/Bot/Cogs/actions.py @@ -4,7 +4,7 @@ from discord.ext import commands from discord.ext.commands import Greedy from kumikocore import KumikoCore -from Libs.utils import Embed, formatGreedy +from Libs.utils import Embed, format_greedy class Actions(commands.Cog): @@ -25,7 +25,7 @@ async def hug(self, ctx: commands.Context, user: Greedy[discord.Member]) -> None async with self.session.get("https://nekos.life/api/v2/img/hug") as r: data = await r.json(loads=orjson.loads) embed = Embed( - title=f"{ctx.author.name} hugs {formatGreedy([items.name for items in user])}!" + title=f"{ctx.author.name} hugs {format_greedy([items.name for items in user])}!" ) embed.set_image(url=data["url"]) await ctx.send(embed=embed) @@ -37,7 +37,7 @@ async def pat(self, ctx: commands.Context, user: Greedy[discord.Member]) -> None async with self.session.get("https://nekos.life/api/v2/img/pat") as r: data = await r.json(loads=orjson.loads) embed = Embed( - title=f"{ctx.author.name} pats {formatGreedy([items.name for items in user])}!" + title=f"{ctx.author.name} pats {format_greedy([items.name for items in user])}!" ) embed.set_image(url=data["url"]) await ctx.send(embed=embed) @@ -49,7 +49,7 @@ async def kiss(self, ctx: commands.Context, user: Greedy[discord.Member]) -> Non async with self.session.get("https://nekos.life/api/v2/img/kiss") as r: data = await r.json(loads=orjson.loads) embed = Embed( - title=f"{ctx.author.name} kisses {formatGreedy([items.name for items in user])}!" + title=f"{ctx.author.name} kisses {format_greedy([items.name for items in user])}!" ) embed.set_image(url=data["url"]) await ctx.send(embed=embed) @@ -61,7 +61,7 @@ async def cuddle(self, ctx: commands.Context, user: Greedy[discord.Member]) -> N async with self.session.get("https://nekos.life/api/v2/img/cuddle") as r: data = await r.json(loads=orjson.loads) embed = Embed( - title=f"{ctx.author.name} cuddles {formatGreedy([items.name for items in user])}!" + title=f"{ctx.author.name} cuddles {format_greedy([items.name for items in user])}!" ) embed.set_image(url=data["url"]) await ctx.send(embed=embed) @@ -73,7 +73,7 @@ async def slap(self, ctx: commands.Context, user: Greedy[discord.Member]) -> Non async with self.session.get("https://nekos.life/api/v2/img/slap") as r: data = await r.json(loads=orjson.loads) embed = Embed( - title=f"{ctx.author.name} slaps {formatGreedy([items.name for items in user])}!" + title=f"{ctx.author.name} slaps {format_greedy([items.name for items in user])}!" ) embed.set_image(url=data["url"]) await ctx.send(embed=embed) @@ -87,7 +87,7 @@ async def tickles( async with self.session.get("https://nekos.life/api/v2/img/tickle") as r: data = await r.json(loads=orjson.loads) embed = Embed( - title=f"{ctx.author.name} tickles {formatGreedy([items.name for items in user])}!" + title=f"{ctx.author.name} tickles {format_greedy([items.name for items in user])}!" ) embed.set_image(url=data["url"]) await ctx.send(embed=embed) @@ -99,7 +99,7 @@ async def poke(self, ctx: commands.Context, user: Greedy[discord.Member]) -> Non async with self.session.get("https://nekos.life/api/v2/img/poke") as r: data = await r.json(loads=orjson.loads) embed = Embed( - title=f"{ctx.author.name} pokes {formatGreedy([items.name for items in user])}!" + title=f"{ctx.author.name} pokes {format_greedy([items.name for items in user])}!" ) embed.set_image(url=data["url"]) await ctx.send(embed=embed) diff --git a/Bot/Cogs/auctions.py b/Bot/Cogs/auctions.py new file mode 100644 index 00000000..36029cfa --- /dev/null +++ b/Bot/Cogs/auctions.py @@ -0,0 +1,222 @@ +import datetime + +from discord import PartialEmoji, app_commands +from discord.ext import commands +from kumikocore import KumikoCore +from Libs.cog_utils.auctions import ( + ListingFlag, + PurchasingFlag, + add_more_to_auction, + create_auction, + delete_auction, + format_options, + obtain_item_info, + purchase_auction, +) +from Libs.cog_utils.economy import is_economy_enabled +from Libs.ui.auctions import AuctionPages, AuctionSearchPages, OwnedAuctionPages +from Libs.utils import Embed, MessageConstants +from typing_extensions import Annotated + + +class Auctions(commands.Cog): + """List unwanted items away here""" + + def __init__(self, bot: KumikoCore) -> None: + self.bot = bot + self.pool = self.bot.pool + + @property + def display_emoji(self) -> PartialEmoji: + return PartialEmoji.from_str("<:auction_house:1136906394323398749>") + + @is_economy_enabled() + @commands.hybrid_group( + name="auctions", fallback="view", aliases=["auction-house", "ah"] + ) + async def auctions(self, ctx: commands.Context) -> None: + """List the items available for purchase""" + sql = """ + SELECT eco_item.id, eco_item.name, eco_item.description, auction_house.user_id, auction_house.amount_listed, auction_house.listed_price, auction_house.listed_at + FROM auction_house + INNER JOIN eco_item ON eco_item.id = auction_house.item_id + WHERE auction_house.guild_id = $1; + """ + if ctx.guild is None: + await ctx.send(MessageConstants.NO_DM.value) + return + rows = await self.pool.fetch(sql, ctx.guild.id) + + if len(rows) == 0: + await ctx.send("No records found") + return + + pages = AuctionPages(entries=rows, ctx=ctx, per_page=1) + await pages.start() + + @is_economy_enabled() + @auctions.command(name="create", aliases=["make"], usage="amount: ") + @app_commands.describe(name="The name of the item to list for purchase") + async def create( + self, + ctx: commands.Context, + name: Annotated[str, commands.clean_content], + *, + flags: ListingFlag, + ) -> None: + """Lists the given item for purchase""" + if ctx.guild is None: + await ctx.send(MessageConstants.NO_DM.value) + return + status = await create_auction( + guild_id=ctx.guild.id, + user_id=ctx.author.id, + amount_requested=flags.amount, + item_id=None, + item_name=name, + pool=self.bot.pool, + ) + await ctx.send(status) + + @is_economy_enabled() + @auctions.command(name="delete", aliases=["remove"]) + @app_commands.describe(name="The name of the item to list for removal") + async def delete( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """List the items available for purchase""" + if ctx.guild is None: + await ctx.send(MessageConstants.NO_DM.value) + return + status = await delete_auction( + guild_id=ctx.guild.id, + user_id=ctx.author.id, + item_name=name, + pool=self.bot.pool, + ) + await ctx.send(status) + + @is_economy_enabled() + @auctions.command(name="update", aliases=["edit"], usage="amount: ") + @app_commands.describe(name="The name of the item you want to update") + async def update( + self, + ctx: commands.Context, + name: Annotated[str, commands.clean_content], + *, + flags: ListingFlag, + ) -> None: + """Updates the listed amount for the given item""" + if ctx.guild is None: + await ctx.send(MessageConstants.NO_DM.value) + return + status = await add_more_to_auction( + guild_id=ctx.guild.id, + user_id=ctx.author.id, + amount_requested=flags.amount, + item_name=name, + pool=self.bot.pool, + ) + await ctx.send(status) + + @is_economy_enabled() + @auctions.command(name="owned") + async def owned(self, ctx: commands.Context) -> None: + """Get items listed by you""" + sql = """ + SELECT eco_item.id, eco_item.name, eco_item.description, auction_house.amount_listed, auction_house.listed_price, auction_house.listed_at + FROM auction_house + INNER JOIN eco_item ON eco_item.id = auction_house.item_id + WHERE auction_house.user_id = $1 AND auction_house.guild_id = $2; + """ + if ctx.guild is None: + await ctx.send(MessageConstants.NO_DM.value) + return + rows = await self.pool.fetch(sql, ctx.author.id, ctx.guild.id) + + pages = OwnedAuctionPages(entries=rows, ctx=ctx, per_page=1, pool=self.pool) + await pages.start() + + @is_economy_enabled() + @auctions.command(name="search") + async def search( + self, ctx: commands.Context, *, query: Annotated[str, commands.clean_content] + ) -> None: + """Searches for the item listed in the Auction House""" + sql = """ + SELECT eco_item.id AS item_id, eco_item.name as item_name, auction_house.user_id, auction_house.amount_listed + FROM auction_house + INNER JOIN eco_item ON eco_item.name % $2 + WHERE auction_house.guild_id=$1 + ORDER BY similarity(eco_item.name, $2) DESC + LIMIT 100; + """ + if ctx.guild is None: + await ctx.send(MessageConstants.NO_DM.value) + return + rows = await self.pool.fetch(sql, ctx.guild.id, query) + + if len(rows) == 0: + await ctx.send("No records found") + return + + pages = AuctionSearchPages(entries=rows, ctx=ctx, per_page=10) + await pages.start() + + @is_economy_enabled() + @auctions.command(name="info") + @app_commands.describe(name="The name of the item to look at") + async def info( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """Provides info about the given item""" + if ctx.guild is None: + await ctx.send("This cannot be used in DMs") + return + item_info = await obtain_item_info( + guild_id=ctx.guild.id, name=name, pool=self.pool + ) + if item_info is None: + await ctx.send("The item was never found") + return + if isinstance(item_info, list): + await ctx.send(format_options(item_info) or ".") + return + embed = Embed() + embed.title = item_info["name"] + embed.description = item_info["description"] + embed.add_field(name="Amount Listed", value=item_info["amount_listed"]) + embed.add_field(name="Listed Price", value=item_info["listed_price"]) + embed.add_field(name="Listed By", value=f"<@{item_info['user_id']}>") + embed.set_footer(text="Listed at") + embed.timestamp = item_info["listed_at"].replace(tzinfo=datetime.timezone.utc) + await ctx.send(embed=embed) + + @is_economy_enabled() + @auctions.command(name="buy", aliases=["purchase"], usage="name amount: int") + @app_commands.describe(name="The name of the item to purchase") + async def buy( + self, + ctx: commands.Context, + name: Annotated[str, commands.clean_content], + *, + flags: PurchasingFlag, + ) -> None: + """Make an purchase from the auction house""" + if ctx.guild is None: + await ctx.send(MessageConstants.NO_DM.value) + return + + status = await purchase_auction( + guild_id=ctx.guild.id, + user_id=ctx.author.id, + name=name, + item_id=None, + amount=flags.amount, + pool=self.pool, + ) + await ctx.send(status) + + +async def setup(bot: KumikoCore) -> None: + await bot.add_cog(Auctions(bot)) diff --git a/Bot/Cogs/dev-tools.py b/Bot/Cogs/dev_tools.py similarity index 84% rename from Bot/Cogs/dev-tools.py rename to Bot/Cogs/dev_tools.py index 4dc53c76..2205d193 100644 --- a/Bot/Cogs/dev-tools.py +++ b/Bot/Cogs/dev_tools.py @@ -1,6 +1,7 @@ from typing import Literal, Optional import discord +from Cogs import EXTENSIONS from discord import app_commands from discord.ext import commands from discord.ext.commands import Context, Greedy @@ -86,14 +87,22 @@ async def dispatch_event(self, ctx: commands.Context, event: str) -> None: await ctx.send("Dispatched event") @commands.check_any(commands.is_owner(), is_nat()) - @commands.command(name="arg-check", usage="") + @commands.hybrid_command(name="arg-check", usage="") async def arg_check(self, ctx: commands.Context, user: discord.Member): """Testing arg checks Args: user (discord.Member): The member to ping lol """ - await ctx.send(user.name) + raise RuntimeError("Testing moments") + # await ctx.send(user.name) + + @commands.command(name="reload-all") + async def upgrade(self, ctx: commands.Context) -> None: + """Reloads all cogs. This is used for upgrading""" + for cog in EXTENSIONS: + await self.bot.reload_extension(cog) + await ctx.send("Reloaded all cogs") async def setup(bot: KumikoCore): diff --git a/Bot/Cogs/dictionary.py b/Bot/Cogs/dictionary.py new file mode 100644 index 00000000..d976158f --- /dev/null +++ b/Bot/Cogs/dictionary.py @@ -0,0 +1,58 @@ +import orjson +from discord import PartialEmoji, app_commands +from discord.ext import commands +from kumikocore import KumikoCore +from Libs.ui.dictionary import DictPages, JapaneseDictPages +from typing_extensions import Annotated +from yarl import URL + + +class Dictionary(commands.Cog): + """Commands to search definitions of words""" + + def __init__(self, bot: KumikoCore) -> None: + self.bot = bot + self.session = self.bot.session + + @property + def display_emoji(self) -> PartialEmoji: + return PartialEmoji(name="\U0001f4d6") + + @commands.hybrid_group(name="define", fallback="english") + @app_commands.describe(query="The word to define") + async def define( + self, ctx: commands.Context, *, query: Annotated[str, commands.clean_content] + ) -> None: + """Define a word from the English dictionary""" + url = URL("https://api.dictionaryapi.dev/api/v2/entries/en") / query + async with self.session.get(url) as r: + data = await r.json(loads=orjson.loads) + if "message" in data: + await ctx.send("No results found") + return + pages = DictPages(data, ctx=ctx) + await pages.start() + + @define.command(name="japanese", aliases=["ja", "jp"]) + @app_commands.describe( + query="The word to define. This can be both in English or Japanese (romaji works)" + ) + async def japanese( + self, ctx: commands.Context, *, query: Annotated[str, commands.clean_content] + ) -> None: + """Get the definition of a word from the Japanese dictionary""" + params = {"keyword": query} + + async with self.session.get( + "https://jisho.org/api/v1/search/words", params=params + ) as r: + data = await r.json(loads=orjson.loads) + if len(data["data"]) == 0: + await ctx.send("No results found.") + return + pages = JapaneseDictPages(data["data"], ctx=ctx) + await pages.start() + + +async def setup(bot: KumikoCore) -> None: + await bot.add_cog(Dictionary(bot)) diff --git a/Bot/Cogs/economy.py b/Bot/Cogs/economy.py index 937c30cd..92515d57 100644 --- a/Bot/Cogs/economy.py +++ b/Bot/Cogs/economy.py @@ -2,9 +2,8 @@ from discord.ext import commands from kumikocore import KumikoCore from Libs.cache import KumikoCache -from Libs.cog_utils.economy import is_economy_enabled -from Libs.ui.economy import RegisterView -from Libs.ui.marketplace import ItemPages +from Libs.cog_utils.economy import RefundFlags, is_economy_enabled +from Libs.ui.economy import LeaderboardPages, RegisterView, UserInvPages from Libs.utils import ConfirmEmbed, Embed, is_manager @@ -18,6 +17,7 @@ def __init__(self, bot: KumikoCore) -> None: self.bot = bot self.pool = self.bot.pool self.redis_pool = self.bot.redis_pool + self.local_economy_key = "$.local_economy" @property def display_emoji(self) -> discord.PartialEmoji: @@ -40,14 +40,14 @@ async def enable(self, ctx: commands.Context) -> None: SET local_economy = $2 WHERE id = $1; """ - result = await cache.getJSONCache(key=key, path="$.local_economy") + result = await cache.get_json_cache(key=key, path=self.local_economy_key) if result is True: await ctx.send("Economy is already enabled for your server!") return else: await self.pool.execute(query, ctx.guild.id, True) # type: ignore - await cache.mergeJSONCache( - key=key, value=True, path="$.local_economy", ttl=None + await cache.merge_json_cache( + key=key, value=True, path=self.local_economy_key, ttl=None ) await ctx.send("Enabled economy!") return @@ -57,27 +57,20 @@ async def enable(self, ctx: commands.Context) -> None: @eco.command(name="disable") async def disable(self, ctx: commands.Context) -> None: """Disables the economy module for your server""" - key = f"cache:kumiko:{ctx.guild.id}:config" # type: ignore + key = f"cache:kumiko:{ctx.guild.id}:guild_config" # type: ignore cache = KumikoCache(connection_pool=self.redis_pool) query = """ UPDATE guild SET local_economy = $2 WHERE id = $1; """ - if await cache.cacheExists(key=key): - result = await cache.getJSONCache(key=key, path=".local_economy") - if result is True: - await self.pool.execute(query, ctx.guild.id, False) # type: ignore - await cache.mergeJSONCache( - key=key, value=False, path="$.local_economy", ttl=None - ) - await ctx.send( - "Economy is now disabled for your server. Please enable it first." - ) - return - else: - await ctx.send("Economy is already disabled for your server!") - return + await self.pool.execute(query, ctx.guild.id, False) # type: ignore + await cache.merge_json_cache( + key=key, value=False, path=".local_economy", ttl=None + ) + await ctx.send( + "Economy is now disabled for your server. Please enable it first." + ) @is_economy_enabled() @eco.command(name="wallet", aliases=["bal", "balance"]) @@ -94,16 +87,16 @@ async def wallet(self, ctx: commands.Context) -> None: f"You have not created an economy account yet! Run `{ctx.prefix}eco register` to create one." ) return - dictUser = dict(user) + user_record = dict(user) embed = Embed() embed.set_author( name=f"{ctx.author.display_name}'s Balance", icon_url=ctx.author.display_avatar.url, ) embed.set_footer(text="Created at") - embed.timestamp = dictUser["created_at"] - embed.add_field(name="Rank", value=dictUser["rank"], inline=True) - embed.add_field(name="Petals", value=dictUser["petals"], inline=True) + embed.timestamp = user_record["created_at"] + embed.add_field(name="Rank", value=user_record["rank"], inline=True) + embed.add_field(name="Petals", value=user_record["petals"], inline=True) await ctx.send(embed=embed) @is_economy_enabled() @@ -120,7 +113,7 @@ async def register(self, ctx: commands.Context) -> None: async def inventory(self, ctx: commands.Context) -> None: """View your inventory""" query = """ - SELECT eco_item.id, eco_item.name, eco_item.description, eco_item.price, eco_item.amount, eco_item.producer_id + SELECT eco_item.id, eco_item.name, eco_item.description, eco_item.price, user_inv.amount_owned, eco_item.producer_id FROM user_inv INNER JOIN eco_item ON eco_item.id = user_inv.item_id WHERE eco_item.guild_id = $1 AND user_inv.owner_id = $2; @@ -130,9 +123,84 @@ async def inventory(self, ctx: commands.Context) -> None: await ctx.send("No items available") return - pages = ItemPages(entries=rows, ctx=ctx, per_page=1) + pages = UserInvPages(entries=rows, ctx=ctx, per_page=1, pool=self.pool) + await pages.start() + + @is_economy_enabled() + @eco.command(name="top", aliases=["baltop"]) + async def top(self, ctx: commands.Context) -> None: + """View the top players in your server""" + query = """ + SELECT id, rank, petals + FROM eco_user + ORDER BY petals ASC + LIMIT 100; + """ + rows = await self.pool.fetch(query) + if len(rows) == 0: + await ctx.send("No users available") + return + + pages = LeaderboardPages(entries=rows, ctx=ctx, per_page=10) await pages.start() + @is_economy_enabled() + @eco.command(name="refund", aliases=["return"]) + async def refund(self, ctx: commands.Context, *, flags: RefundFlags) -> None: + """Refunds your item, but you will only get 75% of the original price back""" + sql = """ + SELECT eco_item.name, eco_item.price, user_inv.owner_id, user_inv.amount_owned, user_inv.item_id + FROM user_inv + INNER JOIN eco_item ON eco_item.id = user_inv.item_id + WHERE user_inv.owner_id = $1 AND user_inv.guild_id = $2 AND eco_item.name = $3; + """ + subtract_owned_items = """ + UPDATE user_inv + SET amount_owned = amount_owned - $1 + WHERE owner_id = $2 AND guild_id = $3 AND item_id = $4; + """ + add_back_items_to_stock = """ + UPDATE eco_item + SET amount = amount + $1 + WHERE id = $2; + """ + add_back_price = """ + UPDATE eco_user + SET petals = petals - $1 + WHERE id = $2; + """ + assert ( + ctx.guild is not None + ) # Apparently this fixes the ctx.guild.id being None thing + async with self.pool.acquire() as conn: + rows = await conn.fetchrow( + sql, ctx.author.id, ctx.guild.id, flags.name.lower() + ) + if rows is None: + await ctx.send("You do not own this item!") + return + records = dict(rows) + refund_price = ((records["price"] * flags.amount) / 4) * 3 + if flags.amount > records["amount_owned"]: + # Here we want to basically make sure that if the user requests more, then we don't take more than we need to + # TODO: Add that math here + await ctx.send("You do not own that many items!") + return + async with conn.transaction(): + await conn.execute( + subtract_owned_items, + flags.amount, + ctx.author.id, + ctx.guild.id, + records["item_id"], + ) + await conn.execute( + add_back_items_to_stock, flags.amount, records["item_id"] + ) + await conn.execute(add_back_price, refund_price, ctx.author.id) + + await ctx.send("Successfully refunded your item!") + async def setup(bot: KumikoCore) -> None: await bot.add_cog(Economy(bot)) diff --git a/Bot/Cogs/error-handler.py b/Bot/Cogs/error-handler.py deleted file mode 100644 index 7ecbb334..00000000 --- a/Bot/Cogs/error-handler.py +++ /dev/null @@ -1,166 +0,0 @@ -from typing import Union - -from discord.app_commands.errors import CommandInvokeError -from discord.ext import commands -from kumikocore import KumikoCore -from Libs.errors import ( - EconomyDisabled, - HTTPError, - KumikoException, - NoItemsError, - NotFoundError, - ValidationError, -) -from Libs.utils import Embed, ErrorEmbed - - -class ErrorHandler(commands.Cog): - """Cog to handle errors""" - - def __init__(self, bot: KumikoCore) -> None: - self.bot = bot - - def fullException(self, obj): - module = obj.__class__.__module__ - if module is None or module == str.__class__.__module__: - return obj.__class__.__name__ - return module + "." + obj.__class__.__name__ - - def getErrorCatches(self, error: Union[KumikoException, Exception]) -> ErrorEmbed: - errorEmbed = ErrorEmbed() - if isinstance(error, HTTPError): - errorEmbed.description = "There was an HTTP error, and the request could not be made. Please try again later or contact support staff in Kumiko's support server for help." - errorEmbed.add_field(name="Status", value=error.status, inline=False) - errorEmbed.add_field( - name="Message", value=error.message or "...", inline=False - ) - return errorEmbed - elif isinstance(error, NotFoundError): - errorEmbed.description = "The resource you were looking for could not be found. Please try again later" - return errorEmbed - elif isinstance(error, NoItemsError): - errorEmbed.description = ( - "The item you were looking for doesn't exist. Please try again later." - ) - return errorEmbed - else: - errorEmbed.add_field(name="Error", value=str(error), inline=False) - errorEmbed.add_field( - name="Full Exception Message", - value=f"{self.fullException(error)}: {error}", - inline=False, - ) - return errorEmbed - - @commands.Cog.listener() - async def on_command_error( - self, ctx: commands.Context, error: commands.CommandError - ) -> None: - """Handles any errors on regular prefixed commands - - Args: - ctx (commands.Context): Commands context - error (commands.CommandError): The error that is being propagated - """ - if isinstance(error, commands.CommandOnCooldown): - seconds = int(error.retry_after) % (24 * 3600) - hours = seconds // 3600 - seconds %= 3600 - minutes = seconds // 60 - seconds %= 60 - await ctx.send( - embed=Embed( - description=f"This command is currently on cooldown. Try again in {hours} hour(s), {minutes} minute(s), and {seconds} second(s)." - ) - ) - elif isinstance(error, commands.CommandInvokeError): - await ctx.send(embed=self.getErrorCatches(error.original)) - elif isinstance(error, commands.HybridCommandError): - if isinstance(error.original, CommandInvokeError): - await ctx.send(embed=self.getErrorCatches(error.original.original)) - elif isinstance(error, commands.DisabledCommand): - errorEmbed = ErrorEmbed() - errorEmbed.title = "Command Disabled" - errorEmbed.description = ( - "The command you were looking for is currently disabled" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, commands.CommandNotFound): - errorEmbed = ErrorEmbed() - errorEmbed.title = "Command Not Found" - errorEmbed.description = ( - "The command you were looking for could not be found" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, commands.NotOwner): - errorEmbed = ErrorEmbed() - errorEmbed.title = "Command requires the owner to run" - errorEmbed.description = ( - "The command can only be ran by the owner of the guild" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, commands.MissingPermissions): - missingPerms = ", ".join(error.missing_permissions).rstrip(",") - errorEmbed = ErrorEmbed() - errorEmbed.title = "Missing Permissions" - errorEmbed.description = ( - f"You are missing the following permissions: {missingPerms}" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, commands.BotMissingPermissions): - missingPerms = ", ".join(error.missing_permissions).rstrip(",") - errorEmbed = ErrorEmbed() - errorEmbed.title = "Kumiko is missing permissions" - errorEmbed.description = ( - f"Kumiko is missing the following permissions: {missingPerms}" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, commands.MissingAnyRole): - missingRoles = ", ".join( - str(roles) for roles in error.missing_roles - ).rstrip(",") - errorEmbed = ErrorEmbed() - errorEmbed.title = "Missing Roles" - errorEmbed.description = ( - f"You are missing the following role(s): {missingRoles}" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, commands.BotMissingAnyRole): - missingRoles = ", ".join( - str(roles) for roles in error.missing_roles - ).rstrip(",") - errorEmbed = ErrorEmbed() - errorEmbed.title = "Kumiko is missing roles" - errorEmbed.description = ( - f"Kumiko is missing the following role(s): {missingRoles}" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, commands.MissingRequiredArgument): - errorEmbed = ErrorEmbed() - errorEmbed.title = "Missing Required Argument" - errorEmbed.description = ( - f"You are missing the following argument(s): {error.param.name}" - ) - await ctx.send(embed=errorEmbed) - elif isinstance(error, ValidationError): - errorEmbed = ErrorEmbed() - errorEmbed.title = "Validation Error" - errorEmbed.description = str(error) - await ctx.send(embed=errorEmbed) - elif isinstance(error, EconomyDisabled): - errorEmbed = ErrorEmbed(title="Economy Disabled") - errorEmbed.description = str(error) - await ctx.send(embed=errorEmbed) - else: - errorEmbed = ErrorEmbed() - errorEmbed.add_field(name="Error", value=str(error), inline=False) - errorEmbed.add_field( - name="Full Exception Message", - value=f"{self.fullException(error)}: {error}", - inline=False, - ) - await ctx.send(embed=errorEmbed) - - -async def setup(bot: KumikoCore) -> None: - await bot.add_cog(ErrorHandler(bot)) diff --git a/Bot/Cogs/error_handler.py b/Bot/Cogs/error_handler.py new file mode 100644 index 00000000..b78d60e6 --- /dev/null +++ b/Bot/Cogs/error_handler.py @@ -0,0 +1,91 @@ +import traceback + +from discord.ext import commands +from kumikocore import KumikoCore +from Libs.errors import EconomyDisabledError, RedirectsDisabledError, ValidationError +from Libs.utils import ErrorEmbed + + +class ErrorHandler(commands.Cog): + """Cog to handle errors""" + + def __init__(self, bot: KumikoCore) -> None: + self.bot = bot + + def produce_error_embed(self, error: commands.CommandError): + embed = ErrorEmbed() + error_traceback = "\n".join(traceback.format_exception_only(type(error), error)) + desc = ( + "Uh oh! It seems like the command ran into an issue! For support, please visit Kumiko's Support Server to get help!\n\n", + f"**Error**: \n```{error_traceback}```", + ) + embed.description = "\n".join(desc) + return embed + + def create_premade_embed(self, title: str, description: str): + embed = ErrorEmbed() + embed.title = title + embed.description = description + return embed + + @commands.Cog.listener() + async def on_command_error( + self, ctx: commands.Context, error: commands.CommandError + ) -> None: + """Handles any errors on regular prefixed commands + + Args: + ctx (commands.Context): Commands context + error (commands.CommandError): The error that is being propagated + """ + if isinstance(error, commands.CommandInvokeError) or isinstance( + error, commands.HybridCommandError + ): + await ctx.send(embed=self.produce_error_embed(error)) + elif isinstance(error, commands.CommandNotFound): + await ctx.send( + embed=self.create_premade_embed( + "Command Not Found", + "The command you were looking for could not be found", + ) + ) + elif isinstance(error, commands.NotOwner): + await ctx.send( + embed=self.create_premade_embed( + "Command requires the owner to run", + "The command can only be ran by the owner of the guild", + ) + ) + elif isinstance(error, commands.MissingPermissions): + missing_perms = ", ".join(error.missing_permissions).rstrip(",") + await ctx.send( + embed=self.create_premade_embed( + "Missing Permissions", + f"You are missing the following permissions: {missing_perms}", + ) + ) + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send( + embed=self.create_premade_embed( + "Missing Requireed Argument", + f"You are missing the following argument(s): {error.param.name}", + ) + ) + elif isinstance(error, ValidationError): + await ctx.send( + embed=self.create_premade_embed("Validation Error", str(error)) + ) + elif isinstance(error, EconomyDisabledError): + await ctx.send( + embed=self.create_premade_embed("Economy Disabled", str(error)) + ) + elif isinstance(error, RedirectsDisabledError): + await ctx.send( + embed=self.create_premade_embed("Redirects Disabled", str(error)) + ) + else: + await ctx.send(embed=self.produce_error_embed(error)) + + +async def setup(bot: KumikoCore) -> None: + await bot.add_cog(ErrorHandler(bot)) diff --git a/Bot/Cogs/events-handler.py b/Bot/Cogs/events_handler.py similarity index 72% rename from Bot/Cogs/events-handler.py rename to Bot/Cogs/events_handler.py index ce80abaf..74dee956 100644 --- a/Bot/Cogs/events-handler.py +++ b/Bot/Cogs/events_handler.py @@ -2,7 +2,6 @@ import asyncpg import discord -from attrs import asdict from discord.ext import commands from discord.utils import format_dt, utcnow from kumikocore import KumikoCore @@ -21,7 +20,7 @@ def __init__(self, bot: KumikoCore) -> None: self.pool = self.bot.pool self.redis_pool = self.bot.redis_pool - async def ensureAllEnabled( + async def ensure_all_enabled( self, guild_id: int, pool: asyncpg.Pool, @@ -29,13 +28,13 @@ async def ensureAllEnabled( logging_config: Mapping[str, bool], event: str, ) -> bool: - logsEnabled = await get_or_fetch_log_enabled(guild_id, redis_pool, pool) - return logsEnabled is True and logging_config[event] is True + logs_enabled = await get_or_fetch_log_enabled(guild_id, redis_pool, pool) + return logs_enabled is True and logging_config[event] is True @commands.Cog.listener() async def on_guild_join(self, guild: discord.Guild) -> None: - existsQuery = "SELECT EXISTS(SELECT 1 FROM guild WHERE id = $1);" - insertQuery = """ + exists_query = "SELECT EXISTS(SELECT 1 FROM guild WHERE id = $1);" + insert_query = """ WITH guild_insert AS ( INSERT INTO guild (id) VALUES ($1) ) @@ -43,17 +42,17 @@ async def on_guild_join(self, guild: discord.Guild) -> None: """ cache = KumikoCache(connection_pool=self.redis_pool) key = f"cache:kumiko:{guild.id}:guild_config" - guildConfig = GuildConfig( + guild_config = GuildConfig( id=guild.id, logging_config=LoggingGuildConfig(channel_id=None) ) async with self.pool.acquire() as conn: async with conn.transaction(): - exists = await conn.fetchval(existsQuery, guild.id) + exists = await conn.fetchval(exists_query, guild.id) if exists is False: - await conn.execute(insertQuery, guild.id) - await cache.setJSONCache( + await conn.execute(insert_query, guild.id) + await cache.set_json_cache( key=key, - value=asdict(guildConfig, recurse=True), + value=guild_config, path="$", ttl=None, ) @@ -65,7 +64,7 @@ async def on_guild_remove(self, guild: discord.Guild) -> None: async with self.pool.acquire() as conn: async with conn.transaction(): await conn.execute("DELETE FROM guild WHERE id = $1", guild.id) - await cache.deleteJSONCache( + await cache.delete_json_cache( key=f"cache:kumiko:{guild.id}:guild_config", path="$" ) if guild.id in self.bot.prefixes: @@ -74,11 +73,11 @@ async def on_guild_remove(self, guild: discord.Guild) -> None: @commands.Cog.listener() async def on_member_join(self, member: discord.Member) -> None: guild = member.guild - getConfig = await get_or_fetch_config( + get_config = await get_or_fetch_config( id=member.guild.id, redis_pool=self.redis_pool, pool=self.pool ) - if await self.ensureAllEnabled(guild.id, self.pool, self.redis_pool, getConfig, "member_events"): # type: ignore - channel = guild.get_channel(getConfig["channel_id"]) # type: ignore + if await self.ensure_all_enabled(guild.id, self.pool, self.redis_pool, get_config, "member_events"): # type: ignore + channel = guild.get_channel(get_config["channel_id"]) # type: ignore if isinstance(channel, discord.TextChannel): embed = SuccessActionEmbed() embed.title = "Member Joined" @@ -93,11 +92,11 @@ async def on_member_join(self, member: discord.Member) -> None: @commands.Cog.listener() async def on_member_remove(self, member: discord.Member) -> None: guild = member.guild - getConfig = await get_or_fetch_config( + get_config = await get_or_fetch_config( id=guild.id, redis_pool=self.redis_pool, pool=self.pool ) - if await self.ensureAllEnabled(guild.id, self.pool, self.redis_pool, getConfig, "member_events"): # type: ignore - channel = guild.get_channel(getConfig["channel_id"]) # type: ignore + if await self.ensure_all_enabled(guild.id, self.pool, self.redis_pool, get_config, "member_events"): # type: ignore + channel = guild.get_channel(get_config["channel_id"]) # type: ignore if isinstance(channel, discord.TextChannel): embed = CancelledActionEmbed() embed.title = "Member Left" @@ -111,11 +110,11 @@ async def on_member_remove(self, member: discord.Member) -> None: @commands.Cog.listener() async def on_member_ban(self, guild: discord.Guild, user: discord.User) -> None: - getConfig = await get_or_fetch_config( + get_config = await get_or_fetch_config( id=guild.id, redis_pool=self.redis_pool, pool=self.pool ) - if await self.ensureAllEnabled(guild.id, self.pool, self.redis_pool, getConfig, "member_events"): # type: ignore - channel = guild.get_channel(getConfig["channel_id"]) # type: ignore + if await self.ensure_all_enabled(guild.id, self.pool, self.redis_pool, get_config, "member_events"): # type: ignore + channel = guild.get_channel(get_config["channel_id"]) # type: ignore if isinstance(channel, discord.TextChannel): embed = CancelledActionEmbed() embed.title = "Member Banned" @@ -126,11 +125,11 @@ async def on_member_ban(self, guild: discord.Guild, user: discord.User) -> None: @commands.Cog.listener() async def on_member_unban(self, guild: discord.Guild, user: discord.User) -> None: - getConfig = await get_or_fetch_config( + get_config = await get_or_fetch_config( id=guild.id, redis_pool=self.redis_pool, pool=self.pool ) - if await self.ensureAllEnabled(guild.id, self.pool, self.redis_pool, getConfig, "member_events"): # type: ignore - channel = guild.get_channel(getConfig["channel_id"]) # type: ignore + if await self.ensure_all_enabled(guild.id, self.pool, self.redis_pool, get_config, "member_events"): # type: ignore + channel = guild.get_channel(get_config["channel_id"]) # type: ignore if isinstance(channel, discord.TextChannel): embed = Embed(color=discord.Color.from_rgb(255, 143, 143)) embed.title = "Member Unbanned" @@ -141,11 +140,11 @@ async def on_member_unban(self, guild: discord.Guild, user: discord.User) -> Non @commands.Cog.listener() async def on_member_kick(self, guild: discord.Guild, user: discord.User) -> None: - getConfig = await get_or_fetch_config( + get_config = await get_or_fetch_config( id=guild.id, redis_pool=self.redis_pool, pool=self.pool ) - if await self.ensureAllEnabled(guild.id, self.pool, self.redis_pool, getConfig, "member_events"): # type: ignore - channel = guild.get_channel(getConfig["channel_id"]) # type: ignore + if await self.ensure_all_enabled(guild.id, self.pool, self.redis_pool, get_config, "member_events"): # type: ignore + channel = guild.get_channel(get_config["channel_id"]) # type: ignore if isinstance(channel, discord.TextChannel): embed = CancelledActionEmbed() embed.title = "Member Kicked" diff --git a/Bot/Cogs/events-log.py b/Bot/Cogs/events_log.py similarity index 62% rename from Bot/Cogs/events-log.py rename to Bot/Cogs/events_log.py index 41e07914..eda6f738 100644 --- a/Bot/Cogs/events-log.py +++ b/Bot/Cogs/events_log.py @@ -1,9 +1,8 @@ -from attrs import asdict from discord import PartialEmoji from discord.ext import commands from kumikocore import KumikoCore from Libs.cache import KumikoCache -from Libs.cog_utils.events_log import EventsFlag, get_or_fetch_config +from Libs.cog_utils.events_log import EventsFlag, get_or_fetch_channel_id from Libs.config import LoggingGuildConfig, get_or_fetch_guild_config from Libs.ui.events_log import RegisterView, UnregisterView from Libs.utils import ConfirmEmbed, Embed, is_manager @@ -16,6 +15,7 @@ def __init__(self, bot: KumikoCore) -> None: self.bot = bot self.pool = self.bot.pool self.redis_pool = self.bot.redis_pool + self.events_name_list = ["member_events", "mod_events", "eco_events"] @property def display_emoji(self) -> PartialEmoji: @@ -30,18 +30,18 @@ async def logs(self, ctx: commands.Context) -> None: @is_manager() @commands.guild_only() @logs.command(name="enable") - async def enableLogs(self, ctx: commands.Context) -> None: + async def enable(self, ctx: commands.Context) -> None: """Registers and enables events logging on the server""" - registerInfo = "In order to get started, **only** select one of the options within the dropdown menu in order to set it.\nOnce you are done, click the finish button." + register_info = "In order to get started, **only** select one of the options within the dropdown menu in order to set it.\nOnce you are done, click the finish button." embed = Embed(title="Registration Info") - embed.description = registerInfo + embed.description = register_info view = RegisterView(pool=self.pool, redis_pool=self.redis_pool) await ctx.send(embed=embed, view=view) @is_manager() @commands.guild_only() @logs.command(name="disable") - async def disableLogs(self, ctx: commands.Context) -> None: + async def disable(self, ctx: commands.Context) -> None: """Disables and unregisters the events logging on the server""" view = UnregisterView(pool=self.pool, redis_pool=self.redis_pool) embed = ConfirmEmbed() @@ -51,7 +51,7 @@ async def disableLogs(self, ctx: commands.Context) -> None: @is_manager() @commands.guild_only() @logs.command(name="info") - async def logInfo(self, ctx: commands.Context) -> None: + async def info(self, ctx: commands.Context) -> None: """Displays info about the events logging module""" guild_id = ctx.guild.id # type: ignore results = await get_or_fetch_guild_config(guild_id, self.pool, self.redis_pool) @@ -77,9 +77,16 @@ async def logInfo(self, ctx: commands.Context) -> None: @is_manager() @commands.guild_only() - @logs.command(name="configure", aliases=["config"]) - async def logConfig(self, ctx: commands.Context, events: EventsFlag) -> None: - """Configures which events are enabled""" + @logs.command(name="configure", aliases=["config"], usage="all: bool") + async def config( + self, ctx: commands.Context, name: str, status: bool, *, events: EventsFlag + ) -> None: + """Configures which events are enabled. Using the all flag enabled all events.""" + if name not in self.events_name_list: + await ctx.send( + "The name of the event was not found. The possible events are:\nmember_events\nmod_events\neco_events" + ) + return query = """ UPDATE logging_config SET member_events = $2, mod_events = $3, eco_events = $4 @@ -88,29 +95,45 @@ async def logConfig(self, ctx: commands.Context, events: EventsFlag) -> None: guild_id = ctx.guild.id # type: ignore key = f"cache:kumiko:{guild_id}:guild_config" cache = KumikoCache(connection_pool=self.redis_pool) - getConfig = await get_or_fetch_config( - id=guild_id, redis_pool=self.redis_pool, pool=self.pool + get_channel_id = await get_or_fetch_channel_id( + guild_id=guild_id, pool=self.pool, redis_pool=self.redis_pool ) - if getConfig is None: + if get_channel_id is None: await ctx.send("The config was not set up. Please enable the logs module") return + statuses = { + "member_events": status if name in "member_events" else False, + "mod_events": status if name in "mod_events" else False, + "eco_events": status if name in "eco_events" else False, + } + lgc = LoggingGuildConfig( - channel_id=getConfig["channel_id"], - member_events=events.member, - mod_events=events.mod, - eco_events=events.eco, + channel_id=int(get_channel_id), + member_events=statuses["member_events"], + mod_events=statuses["mod_events"], + eco_events=statuses["eco_events"], ) if events.all is True: lgc = LoggingGuildConfig( - channel_id=getConfig["channel_id"], + channel_id=int(get_channel_id), member_events=True, mod_events=True, eco_events=True, ) + await self.pool.execute(query, guild_id, True, True, True) + else: + await self.pool.execute( + query, + guild_id, + statuses["member_events"], + statuses["mod_events"], + statuses["eco_events"], + ) - await self.pool.execute(query, guild_id, events.member, events.mod, events.eco) - await cache.mergeJSONCache(key=key, value=asdict(lgc), path="$.logging_config") + await cache.merge_json_cache( + key=key, value=lgc, path=".logging_config", ttl=None + ) await ctx.send("Updated successfully!") diff --git a/Bot/Cogs/github.py b/Bot/Cogs/github.py index 6b806c6a..6b6b3b1a 100644 --- a/Bot/Cogs/github.py +++ b/Bot/Cogs/github.py @@ -7,7 +7,6 @@ from discord.utils import format_dt from dotenv import load_dotenv from kumikocore import KumikoCore -from Libs.errors import NotFoundError from Libs.utils import Embed from Libs.utils.pages import EmbedListSource, KumikoPages @@ -36,9 +35,7 @@ async def github(self, ctx: commands.Context) -> None: # Force defaults to use Kumiko's repo ? @github.command(name="release-list") @app_commands.describe(owner="The owner of the repo", repo="The repo to search") - async def githubReleasesList( - self, ctx: commands.Context, owner: str, repo: str - ) -> None: + async def releases(self, ctx: commands.Context, owner: str, repo: str) -> None: """Get up to 25 releases for a repo""" headers = { "Authorization": f"token {GITHUB_API_KEY}", @@ -52,9 +49,10 @@ async def githubReleasesList( ) as r: data = await r.json(loads=orjson.loads) if r.status == 404: - raise NotFoundError + await ctx.send("The release(s) were not found") + return else: - mainData = [ + main_data = [ { "title": item["name"], "description": item["body"], @@ -96,13 +94,13 @@ async def githubReleasesList( } for item in data ] - embedSource = EmbedListSource(mainData, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + embed_source = EmbedListSource(main_data, per_page=1) + pages = KumikoPages(source=embed_source, ctx=ctx) await pages.start() @github.command(name="repo") @app_commands.describe(owner="The owner of the repo", repo="The repo to search") - async def searchGitHub(self, ctx: commands.Context, owner: str, repo: str) -> None: + async def search(self, ctx: commands.Context, owner: str, repo: str) -> None: """Searches for one repo on GitHub""" headers = { "Authorization": f"token {GITHUB_API_KEY}", @@ -113,7 +111,8 @@ async def searchGitHub(self, ctx: commands.Context, owner: str, repo: str) -> No ) as r: data = await r.json(loads=orjson.loads) if r.status == 404: - raise NotFoundError + await ctx.send("The repo was not found") + return else: embed = Embed(title=data["name"], description=data["description"]) embed.set_thumbnail(url=data["owner"]["avatar_url"]) diff --git a/Bot/Cogs/ipc.py b/Bot/Cogs/ipc.py index a11372fa..e5442122 100644 --- a/Bot/Cogs/ipc.py +++ b/Bot/Cogs/ipc.py @@ -27,9 +27,10 @@ async def cog_unload(self) -> None: await self.ipc.stop() @Server.route() - async def get_user_data(self, data: ClientPayload) -> Dict: - user = self.bot.get_user(data.user_id) - return user.to_minimal_user_json() # type: ignore + async def health_check(self, data: ClientPayload) -> Dict: + bot_status = self.bot.is_closed() + status = "down" if bot_status is True else "ok" + return {"status": status} async def setup(bot: KumikoCore): diff --git a/Bot/Cogs/jobs.py b/Bot/Cogs/jobs.py index e30a0e49..98431211 100644 --- a/Bot/Cogs/jobs.py +++ b/Bot/Cogs/jobs.py @@ -1,507 +1,480 @@ -import asyncio -from typing import Dict - -import discord -from discord import app_commands -from discord.ext import commands -from kumikocore import KumikoCore -from Libs.cog_utils.economy import is_economy_enabled -from Libs.cog_utils.jobs import ( - JobListFlags, - JobOutputFlags, - createJob, - createJobLink, - createJobOutputItem, - formatOptions, - getJob, - submitJobApp, - updateJob, -) -from Libs.ui.jobs import ( - CreateJob, - CreateJobOutputItemModal, - DeleteJobViaIDView, - DeleteJobView, - JobPages, - PurgeJobsView, - UpdateJobModal, -) -from Libs.utils import ConfirmEmbed, Embed, JobName -from Libs.utils.pages import EmbedListSource, KumikoPages -from typing_extensions import Annotated - - -class Jobs(commands.Cog): - """Module for handling jobs for Kumiko's economy module""" - - def __init__(self, bot: KumikoCore) -> None: - self.bot = bot - self.pool = self.bot.pool - self._reserved_jobs_being_made: Dict[int, set[str]] = {} - - def is_job_being_made(self, guild_id: int, name: str) -> bool: - try: - being_made = self._reserved_jobs_being_made[guild_id] - except KeyError: - return False - else: - return name.lower() in being_made - - def add_in_progress_job(self, guild_id: int, name: str) -> None: - tags = self._reserved_jobs_being_made.setdefault(guild_id, set()) - tags.add(name.lower()) - - def remove_in_progress_job(self, guild_id: int, name: str) -> None: - try: - being_made = self._reserved_jobs_being_made[guild_id] - except KeyError: - return - - being_made.discard(name.lower()) - if len(being_made) == 0: - del self._reserved_jobs_being_made[guild_id] - - @property - def display_emoji(self) -> discord.PartialEmoji: - return discord.PartialEmoji(name="\U0001f4bc") - - @is_economy_enabled() - @commands.hybrid_group(name="jobs", fallback="list") - async def jobs(self, ctx: commands.Context, flags: JobListFlags) -> None: - """Lists all available jobs in your server""" - sql = """ - SELECT job.id, job.name, job.description, job.required_rank, job.pay_amount - FROM job_lookup - INNER JOIN job ON job.id = job_lookup.job_id - WHERE job_lookup.guild_id = $1 AND job_lookup.listed = $2; - """ - results = await self.pool.fetch(sql, ctx.guild.id, True) # type: ignore - - if len(results) == 0: - await ctx.send( - "There are no listed jobs in this server! Create one to get started!" - ) - return - if flags.compact is True: - pages = JobPages(entries=results, ctx=ctx, per_page=10) - await pages.start() - else: - dataList = [ - { - "title": row["name"], - "description": row["description"], - "fields": [ - {"name": "ID", "value": row["id"], "inline": True}, - { - "name": "Required Rank", - "value": row["required_rank"], - "inline": True, - }, - { - "name": "Pay Amount", - "value": row["pay_amount"], - "inline": True, - }, - ], - } - for row in results - ] - pages = KumikoPages(EmbedListSource(dataList, per_page=1), ctx=ctx) - await pages.start() - - @is_economy_enabled() - @jobs.command(name="create") - @app_commands.describe( - required_rank="The required rank or higher to obtain the job", - pay="The base pay required for the job", - ) - async def create( - self, ctx: commands.Context, required_rank: int = 0, pay: int = 15 - ) -> None: - """Create a job for your server""" - if ctx.interaction is not None: - createPinModal = CreateJob(self.pool, required_rank, pay) - await ctx.interaction.response.send_modal(createPinModal) - return - - await ctx.send("What would you like the job's name to be?") - - converter = JobName() - original = ctx.message - - def check(msg): - return msg.author == ctx.author and ctx.channel == msg.channel - - try: - name = await self.bot.wait_for("message", timeout=30.0, check=check) - except asyncio.TimeoutError: - await ctx.send("You took long. Goodbye.") - return - - try: - ctx.message = name - name = await converter.convert(ctx, name.content) - except commands.BadArgument as e: - await ctx.send(f'{e}. Redo the command "{ctx.prefix}jobs make" to retry.') - return - finally: - ctx.message = original - - if self.is_job_being_made(ctx.guild.id, name): # type: ignore - await ctx.send( - "Sorry. This job is currently being made by someone. " - f'Redo the command "{ctx.prefix}jobs make" to retry.' - ) - return - - query = """SELECT 1 FROM job WHERE guild_id=$1 AND LOWER(name)=$2;""" - async with self.pool.acquire() as conn: - row = await conn.fetchrow(query, ctx.guild.id, name.lower()) # type: ignore - if row is not None: - await ctx.send( - "Sorry. A job with that name already exists. " - f'Redo the command "{ctx.prefix}jobs make" to retry.' - ) - return None - - self.add_in_progress_job(ctx.guild.id, name) # type: ignore - await ctx.send( - f"Neat. So the name is {name}. What about the job's description? " - f"**You can type `abort` to abort the pin make process.**" - ) - - try: - msg = await self.bot.wait_for("message", check=check, timeout=350.0) - except asyncio.TimeoutError: - self.remove_in_progress_job(ctx.guild.id, name) # type: ignore - await ctx.send("You took too long. Goodbye.") - return - - if msg.content == "abort": - self.remove_in_progress_job(ctx.guild.id, name) # type: ignore - await ctx.send("Aborting.") - return - elif msg.content: - clean_content = await commands.clean_content().convert(ctx, msg.content) - else: - # fast path I guess? - clean_content = msg.content - - if msg.attachments: - clean_content = f"{clean_content}\n{msg.attachments[0].url}" - - if len(clean_content) > 2000: - await ctx.send("Job description is a maximum of 2000 characters.") - return - - try: - status = await createJob(ctx.author.id, ctx.guild.id, self.pool, name, clean_content, required_rank, pay) # type: ignore - await ctx.send(status) - finally: - self.remove_in_progress_job(ctx.guild.id, name) # type: ignore - - @is_economy_enabled() - @jobs.command(name="update") - @app_commands.describe( - name="The name of the job to update", - required_rank="The mew required rank or higher to obtain the job", - pay="The new base pay required for the job", - ) - async def update( - self, - ctx: commands.Context, - name: Annotated[str, commands.clean_content], - required_rank: int, - pay: int, - ) -> None: - """Updates an owned job with new information""" - if ctx.interaction is not None: - updateJobModal = UpdateJobModal(self.pool, name, required_rank, pay) - await ctx.interaction.response.send_modal(updateJobModal) - return - - def check(msg): - return msg.author == ctx.author and ctx.channel == msg.channel - - await ctx.send( - "What's the description for your job going to be?" - "Note that this new description replaces the old one." - ) - try: - msg = await self.bot.wait_for("message", check=check, timeout=350.0) - except asyncio.TimeoutError: - self.remove_in_progress_job(ctx.guild.id, name) # type: ignore - await ctx.send("You took too long. Goodbye.") - return - - if msg.content: - clean_content = await commands.clean_content().convert(ctx, msg.content) - else: - clean_content = msg.content - - if msg.attachments: - clean_content = f"{clean_content}\n{msg.attachments[0].url}" - - if len(clean_content) > 2000: - await ctx.send("Job description is a maximum of 2000 characters.") - return - - status = await updateJob(ctx.author.id, ctx.guild.id, self.pool, name, clean_content, required_rank, pay) # type: ignore - if status[-1] == 0: - await ctx.send( - "You either don't own this job or the job doesn't exist. Try again." - ) - return - await ctx.send( - f"Successfully updated the job `{name}` (RR: {required_rank}, Pay: {pay})" - ) - return - - @is_economy_enabled() - @jobs.command(name="delete") - @app_commands.describe(name="The name of the job to delete") - async def delete( - self, ctx: commands.Context, name: Annotated[str, commands.clean_content] - ) -> None: - """Deletes a job by name. You can only delete your own jobs.""" - view = DeleteJobView(self.pool, name) - embed = ConfirmEmbed() - embed.description = f"Are you sure you want to delete the job `{name}`?" - await ctx.send(embed=embed, view=view) - - @is_economy_enabled() - @jobs.command(name="delete-id") - @app_commands.describe(id="The ID of the job to delete") - async def delete_via_id(self, ctx: commands.Context, id: int) -> None: - """Deletes the job via the job ID""" - view = DeleteJobViaIDView(self.pool, id) - embed = ConfirmEmbed() - embed.description = f"Are you sure you want to delete the job? (ID: `{id}`)?" - await ctx.send(embed=embed, view=view) - - @is_economy_enabled() - @jobs.command(name="purge") - async def purge(self, ctx: commands.Context) -> None: - """Purges all jobs that you own""" - view = PurgeJobsView(self.pool) - embed = ConfirmEmbed() - embed.description = "Are you sure you want to delete all jobs that you own?" - await ctx.send(embed=embed, view=view) - - @is_economy_enabled() - @jobs.command(name="file") - @app_commands.describe(name="The name of the job to file") - async def file( - self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] - ) -> None: - """Files (publicly lists) a job for general availability. This must be one that you own""" - query = """ - UPDATE job_lookup - SET listed = $4 - WHERE guild_id=$1 AND creator_id=$2 AND LOWER(name)=$3; - """ - status = await self.pool.execute(query, ctx.guild.id, ctx.author.id, name.lower(), True) # type: ignore - if status[-1] == 0: - await ctx.send( - "You either don't own this job or the job doesn't exist. Try again." - ) - else: - await ctx.send(f"Successfully filed job `{name}` for general availability.") - - @is_economy_enabled() - @jobs.command(name="unfile") - @app_commands.describe(name="The name of the job to un-file") - async def unfile( - self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] - ) -> None: - """Un-files a job for general availability. This must be one that you own""" - query = """ - UPDATE job_lookup - SET listed = $4 - WHERE guild_id=$1 AND creator_id=$2 AND LOWER(name)=$3; - """ - status = await self.pool.execute(query, ctx.guild.id, ctx.author.id, name.lower(), False) # type: ignore - if status[-1] == 0: - await ctx.send( - "You either don't own this job or the job doesn't exist. Try again." - ) - else: - await ctx.send( - f"Successfully un-filed job `{name}` for general availability." - ) - - # Probably should make a custom converter for this - @is_economy_enabled() - @jobs.command(name="apply") - @app_commands.describe(name="The name of the job to apply") - async def apply( - self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] - ) -> None: - """Apply for a job""" - query = """ - SELECT COUNT(*) FROM job WHERE guild_id = $1 AND worker_id = $2; - """ - async with self.pool.acquire() as conn: - jobCount = await conn.fetchval(query, ctx.guild.id, ctx.author.id) # type: ignore - rows = await conn.fetchrow("SELECT creator_id, worker_id FROM job WHERE guild_id = $1 AND name = $2;", ctx.guild.id, name.lower()) # type: ignore - # customizable? - if jobCount > 3: - await ctx.send("You can't have more than 3 jobs at a time!") - return - - if dict(rows)["creator_id"] == ctx.author.id: - await ctx.send("You can't apply for your own job!") - return - - if dict(rows)["worker_id"] is not None: - await ctx.send("This job is already taken!") - return - - status = await submitJobApp(ctx.author.id, ctx.guild.id, name.lower(), False, conn) # type: ignore - await ctx.send(status) - return - - @is_economy_enabled() - @jobs.command(name="quit") - @app_commands.describe(name="The name of the job to quit") - async def quit( - self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] - ) -> None: - """Quit a current job that you have""" - async with self.pool.acquire() as conn: - rows = await conn.fetchrow("SELECT creator_id, worker_id FROM job WHERE guild_id = $1 AND name = $2;", ctx.guild.id, name.lower()) # type: ignore - if dict(rows)["creator_id"] == ctx.author.id: - await ctx.send("You can't apply for your own job!") - return - - if dict(rows)["worker_id"] is None: - await ctx.send("This job is available! Apply for it first!") - return - else: - status = await submitJobApp(None, ctx.guild.id, name.lower(), True, conn) # type: ignore - await ctx.send(status) - return - - @is_economy_enabled() - @jobs.command(name="info") - @app_commands.describe(name="The name of the job to get") - async def info( - self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] - ) -> None: - """Get info about a job""" - jobResults = await getJob(ctx.guild.id, name.lower(), self.pool) # type: ignore - if isinstance(jobResults, list): - await ctx.send(formatOptions(jobResults) or "No jobs were found") - return - embed = Embed(title=jobResults["name"], description=jobResults["description"]) # type: ignore - embed.add_field(name="Required Rank", value=jobResults["required_rank"]) # type: ignore - embed.add_field(name="Pay Amount", value=jobResults["pay_amount"]) # type: ignore - embed.set_footer(text=f"ID: {jobResults['id']}") # type: ignore - await ctx.send(embed=embed) - - @is_economy_enabled() - @jobs.command(name="search") - @app_commands.describe(query="The name of the job to look for") - async def search( - self, ctx: commands.Context, *, query: Annotated[str, commands.clean_content] - ) -> None: - """Search for jobs that are available. These must be listed in order to show up""" - if len(query) < 3: - await ctx.send("The query must be at least 3 characters") - return - sql = """SELECT job.id, job.name, job.description, job.required_rank, job.pay_amount - FROM job_lookup - WHERE guild_id=$1 AND name % $2 AND listed = $3 - ORDER BY similarity(name, $2) DESC - LIMIT 100; - """ - rows = await self.pool.fetch(sql, ctx.guild.id, query, True) # type: ignore - if rows: - pages = JobPages(entries=rows, ctx=ctx, per_page=10) - await pages.start() - else: - await ctx.send("No jobs were found") - return - - @is_economy_enabled() - @jobs.command(name="output", usage=" price: int amount_per_hour: int") - @app_commands.describe(name="The name of the item that the job outputs") - async def associate_item( - self, - ctx: commands.Context, - name: Annotated[str, commands.clean_content], - *, - flags: JobOutputFlags, - ) -> None: - """Associate an item with the job's output. A job can only produce one item.""" - if ctx.interaction is not None: - outputModal = CreateJobOutputItemModal( - self.pool, name, flags.price, flags.amount_per_hour - ) - await ctx.interaction.response.send_modal(outputModal) - return - - def check(msg): - return msg.author == ctx.author and ctx.channel == msg.channel - - await ctx.send("What's the description for your item going to be?") - try: - msg = await self.bot.wait_for("message", check=check, timeout=350.0) - except asyncio.TimeoutError: - self.remove_in_progress_job(ctx.guild.id, name) # type: ignore - await ctx.send("You took too long. Goodbye.") - return - - if msg.content: - clean_content = await commands.clean_content().convert(ctx, msg.content) - else: - clean_content = msg.content - - if msg.attachments: - clean_content = f"{clean_content}\n{msg.attachments[0].url}" - - if len(clean_content) > 2000: - await ctx.send("Item description is a maximum of 2000 characters.") - return - - query = """ - SELECT eco_item_lookup.item_id, job_lookup.job_id - FROM eco_item_lookup - INNER JOIN job_lookup ON eco_item_lookup.producer_id = job_lookup.creator_id - WHERE eco_item_lookup.guild_id=$1 AND LOWER(eco_item_lookup.name)=$2 AND eco_item_lookup.producer_id=$3; - """ - status = await createJobOutputItem( - name=name, - description=clean_content, - price=flags.price, - amount=flags.amount_per_hour, - guild_id=ctx.guild.id, # type: ignore - worker_id=ctx.author.id, - pool=self.pool, - ) - async with self.pool.acquire() as conn: - if status[-1] != "0": - rows = await conn.fetchrow(query, ctx.guild.id, name, ctx.author.id) # type: ignore - if rows is None: - # this is bugged for some odd reason - await ctx.send("You aren't the producer of the item!") - return - record = dict(rows) - jobLinkStatus = await createJobLink( - worker_id=ctx.author.id, - item_id=record["item_id"], - job_id=record["job_id"], - conn=conn, - ) - if jobLinkStatus[-1] != "0": - await ctx.send( - f"Successfully created the output item `{name}` (Price: {flags.price}, Amount Per Hour: {flags.amount_per_hour})" - ) - return - else: - await ctx.send("There was an error making it. Please try again") - return - - -async def setup(bot: KumikoCore) -> None: - await bot.add_cog(Jobs(bot)) +import asyncio +from typing import Dict + +import discord +from discord import app_commands +from discord.ext import commands +from kumikocore import KumikoCore +from Libs.cog_utils.economy import is_economy_enabled +from Libs.cog_utils.jobs import ( + JobListFlags, + JobOutputFlags, + create_job, + create_job_output_item, + format_job_options, + get_job, + submit_job_app, + update_job, +) +from Libs.ui.jobs import ( + CreateJob, + CreateJobOutputItemModal, + DeleteJobViaIDView, + DeleteJobView, + JobPages, + PurgeJobsView, + UpdateJobModal, +) +from Libs.utils import ConfirmEmbed, Embed, JobName, MessageConstants +from Libs.utils.pages import EmbedListSource, KumikoPages +from typing_extensions import Annotated + + +class Jobs(commands.Cog): + """Module for handling jobs for Kumiko's economy module""" + + def __init__(self, bot: KumikoCore) -> None: + self.bot = bot + self.pool = self.bot.pool + self._reserved_jobs_being_made: Dict[int, set[str]] = {} + + def is_job_being_made(self, guild_id: int, name: str) -> bool: + try: + being_made = self._reserved_jobs_being_made[guild_id] + except KeyError: + return False + else: + return name.lower() in being_made + + def add_in_progress_job(self, guild_id: int, name: str) -> None: + tags = self._reserved_jobs_being_made.setdefault(guild_id, set()) + tags.add(name.lower()) + + def remove_in_progress_job(self, guild_id: int, name: str) -> None: + try: + being_made = self._reserved_jobs_being_made[guild_id] + except KeyError: + return + + being_made.discard(name.lower()) + if len(being_made) == 0: + del self._reserved_jobs_being_made[guild_id] + + @property + def display_emoji(self) -> discord.PartialEmoji: + return discord.PartialEmoji(name="\U0001f4bc") + + @is_economy_enabled() + @commands.hybrid_group(name="jobs", fallback="list") + async def jobs(self, ctx: commands.Context, flags: JobListFlags) -> None: + """Lists all available jobs in your server""" + sql = """ + SELECT job.id, job.name, job.description, job.required_rank, job.pay_amount + FROM job_lookup + INNER JOIN job ON job.id = job_lookup.job_id + WHERE job_lookup.guild_id = $1 AND job_lookup.listed = $2; + """ + results = await self.pool.fetch(sql, ctx.guild.id, True) # type: ignore + + if len(results) == 0: + await ctx.send( + "There are no listed jobs in this server! Create one to get started!" + ) + return + if flags.compact is True: + pages = JobPages(entries=results, ctx=ctx, per_page=10) + await pages.start() + else: + data_list = [ + { + "title": row["name"], + "description": row["description"], + "fields": [ + {"name": "ID", "value": row["id"], "inline": True}, + { + "name": "Required Rank", + "value": row["required_rank"], + "inline": True, + }, + { + "name": "Pay Amount", + "value": row["pay_amount"], + "inline": True, + }, + ], + } + for row in results + ] + pages = KumikoPages(EmbedListSource(data_list, per_page=1), ctx=ctx) + await pages.start() + + @is_economy_enabled() + @jobs.command(name="create") + @app_commands.describe( + required_rank="The required rank or higher to obtain the job", + pay="The base pay required for the job", + ) + async def create( + self, ctx: commands.Context, required_rank: int = 0, pay: int = 15 + ) -> None: + """Create a job for your server""" + if ctx.interaction is not None: + create_job_modal = CreateJob(self.pool, required_rank, pay) + await ctx.interaction.response.send_modal(create_job_modal) + return + + await ctx.send("What would you like the job's name to be?") + + converter = JobName() + original = ctx.message + + def check(msg): + return msg.author == ctx.author and ctx.channel == msg.channel + + try: + name = await self.bot.wait_for("message", timeout=30.0, check=check) + except asyncio.TimeoutError: + await ctx.send("You took long. Goodbye.") + return + + try: + ctx.message = name + name = await converter.convert(ctx, name.content) + except commands.BadArgument as e: + await ctx.send(f'{e}. Redo the command "{ctx.prefix}jobs make" to retry.') + return + finally: + ctx.message = original + + if self.is_job_being_made(ctx.guild.id, name): # type: ignore + await ctx.send( + "Sorry. This job is currently being made by someone. " + f'Redo the command "{ctx.prefix}jobs make" to retry.' + ) + return + + query = """SELECT 1 FROM job WHERE guild_id=$1 AND LOWER(name)=$2;""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow(query, ctx.guild.id, name.lower()) # type: ignore + if row is not None: + await ctx.send( + "Sorry. A job with that name already exists. " + f'Redo the command "{ctx.prefix}jobs make" to retry.' + ) + return None + + self.add_in_progress_job(ctx.guild.id, name) # type: ignore + await ctx.send( + f"Neat. So the name is {name}. What about the job's description? " + f"**You can type `abort` to abort the pin make process.**" + ) + + try: + msg = await self.bot.wait_for("message", check=check, timeout=350.0) + except asyncio.TimeoutError: + self.remove_in_progress_job(ctx.guild.id, name) # type: ignore + await ctx.send(MessageConstants.TIMEOUT.value) + return + + if msg.content == "abort": + self.remove_in_progress_job(ctx.guild.id, name) # type: ignore + await ctx.send("Aborting.") + return + elif msg.content: + clean_content = await commands.clean_content().convert(ctx, msg.content) + else: + # fast path I guess? + clean_content = msg.content + + if msg.attachments: + clean_content = f"{clean_content}\n{msg.attachments[0].url}" + + if len(clean_content) > 2000: + await ctx.send("Job description is a maximum of 2000 characters.") + return + + try: + status = await create_job(ctx.author.id, ctx.guild.id, self.pool, name, clean_content, required_rank, pay) # type: ignore + await ctx.send(status) + finally: + self.remove_in_progress_job(ctx.guild.id, name) # type: ignore + + @is_economy_enabled() + @jobs.command(name="update") + @app_commands.describe( + name="The name of the job to update", + required_rank="The mew required rank or higher to obtain the job", + pay="The new base pay required for the job", + ) + async def update( + self, + ctx: commands.Context, + name: Annotated[str, commands.clean_content], + required_rank: int, + pay: int, + ) -> None: + """Updates an owned job with new information""" + if ctx.interaction is not None: + update_job_modal = UpdateJobModal(self.pool, name, required_rank, pay) + await ctx.interaction.response.send_modal(update_job_modal) + return + + def check(msg): + return msg.author == ctx.author and ctx.channel == msg.channel + + await ctx.send( + "What's the description for your job going to be?" + "Note that this new description replaces the old one." + ) + try: + msg = await self.bot.wait_for("message", check=check, timeout=350.0) + except asyncio.TimeoutError: + self.remove_in_progress_job(ctx.guild.id, name) # type: ignore + await ctx.send(MessageConstants.TIMEOUT.value) + return + + if msg.content: + clean_content = await commands.clean_content().convert(ctx, msg.content) + else: + clean_content = msg.content + + if msg.attachments: + clean_content = f"{clean_content}\n{msg.attachments[0].url}" + + if len(clean_content) > 2000: + await ctx.send("Job description is a maximum of 2000 characters.") + return + + status = await update_job(ctx.author.id, ctx.guild.id, self.pool, name, clean_content, required_rank, pay) # type: ignore + if status[-1] == 0: + await ctx.send(MessageConstants.NO_JOB.value) + return + await ctx.send( + f"Successfully updated the job `{name}` (RR: {required_rank}, Pay: {pay})" + ) + return + + @is_economy_enabled() + @jobs.command(name="delete") + @app_commands.describe(name="The name of the job to delete") + async def delete( + self, ctx: commands.Context, name: Annotated[str, commands.clean_content] + ) -> None: + """Deletes a job by name. You can only delete your own jobs.""" + view = DeleteJobView(self.pool, name) + embed = ConfirmEmbed() + embed.description = f"Are you sure you want to delete the job `{name}`?" + await ctx.send(embed=embed, view=view) + + @is_economy_enabled() + @jobs.command(name="delete-id") + @app_commands.describe(id="The ID of the job to delete") + async def delete_via_id(self, ctx: commands.Context, id: int) -> None: + """Deletes the job via the job ID""" + view = DeleteJobViaIDView(self.pool, id) + embed = ConfirmEmbed() + embed.description = f"Are you sure you want to delete the job? (ID: `{id}`)?" + await ctx.send(embed=embed, view=view) + + @is_economy_enabled() + @jobs.command(name="purge") + async def purge(self, ctx: commands.Context) -> None: + """Purges all jobs that you own""" + view = PurgeJobsView(self.pool) + embed = ConfirmEmbed() + embed.description = "Are you sure you want to delete all jobs that you own?" + await ctx.send(embed=embed, view=view) + + @is_economy_enabled() + @jobs.command(name="file") + @app_commands.describe(name="The name of the job to file") + async def file( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """Files (publicly lists) a job for general availability. This must be one that you own""" + query = """ + UPDATE job_lookup + SET listed = $4 + WHERE guild_id=$1 AND creator_id=$2 AND LOWER(name)=$3; + """ + status = await self.pool.execute(query, ctx.guild.id, ctx.author.id, name.lower(), True) # type: ignore + if status[-1] == 0: + await ctx.send(MessageConstants.NO_JOB.value) + else: + await ctx.send(f"Successfully filed job `{name}` for general availability.") + + @is_economy_enabled() + @jobs.command(name="unfile") + @app_commands.describe(name="The name of the job to un-file") + async def unfile( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """Un-files a job for general availability. This must be one that you own""" + query = """ + UPDATE job_lookup + SET listed = $4 + WHERE guild_id=$1 AND creator_id=$2 AND LOWER(name)=$3; + """ + status = await self.pool.execute(query, ctx.guild.id, ctx.author.id, name.lower(), False) # type: ignore + if status[-1] == 0: + await ctx.send(MessageConstants.NO_JOB.value) + else: + await ctx.send( + f"Successfully un-filed job `{name}` for general availability." + ) + + # Probably should make a custom converter for this + @is_economy_enabled() + @jobs.command(name="apply") + @app_commands.describe(name="The name of the job to apply") + async def apply( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """Apply for a job""" + query = """ + SELECT COUNT(*) FROM job WHERE guild_id = $1 AND worker_id = $2; + """ + async with self.pool.acquire() as conn: + job_count = await conn.fetchval(query, ctx.guild.id, ctx.author.id) # type: ignore + rows = await conn.fetchrow("SELECT creator_id, worker_id FROM job WHERE guild_id = $1 AND name = $2;", ctx.guild.id, name.lower()) # type: ignore + # customizable? + if job_count > 3: + await ctx.send("You can't have more than 3 jobs at a time!") + return + + if dict(rows)["creator_id"] == ctx.author.id: + await ctx.send("You can't apply for your own job!") + return + + if dict(rows)["worker_id"] is not None: + await ctx.send("This job is already taken!") + return + + status = await submit_job_app(ctx.author.id, ctx.guild.id, name.lower(), False, conn) # type: ignore + await ctx.send(status) + return + + @is_economy_enabled() + @jobs.command(name="quit") + @app_commands.describe(name="The name of the job to quit") + async def quit( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """Quit a current job that you have""" + async with self.pool.acquire() as conn: + rows = await conn.fetchrow("SELECT creator_id, worker_id FROM job WHERE guild_id = $1 AND name = $2;", ctx.guild.id, name.lower()) # type: ignore + if dict(rows)["creator_id"] == ctx.author.id: + await ctx.send("You can't apply for your own job!") + return + + if dict(rows)["worker_id"] is None: + await ctx.send("This job is available! Apply for it first!") + return + else: + status = await submit_job_app(None, ctx.guild.id, name.lower(), True, conn) # type: ignore + await ctx.send(status) + return + + @is_economy_enabled() + @jobs.command(name="info") + @app_commands.describe(name="The name of the job to get") + async def info( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """Get info about a job""" + job_results = await get_job(ctx.guild.id, name.lower(), self.pool) # type: ignore + if isinstance(job_results, list): + await ctx.send(format_job_options(job_results) or "No jobs were found") + return + embed = Embed(title=job_results["name"], description=job_results["description"]) # type: ignore + embed.add_field(name="Required Rank", value=job_results["required_rank"]) # type: ignore + embed.add_field(name="Pay Amount", value=job_results["pay_amount"]) # type: ignore + embed.set_footer(text=f"ID: {job_results['id']}") # type: ignore + await ctx.send(embed=embed) + + @is_economy_enabled() + @jobs.command(name="search") + @app_commands.describe(query="The name of the job to look for") + async def search( + self, ctx: commands.Context, *, query: Annotated[str, commands.clean_content] + ) -> None: + """Search for jobs that are available. These must be listed in order to show up""" + if len(query) < 3: + await ctx.send("The query must be at least 3 characters") + return + sql = """SELECT job.id, job.name, job.description, job.required_rank, job.pay_amount + FROM job_lookup + WHERE guild_id=$1 AND name % $2 AND listed = $3 + ORDER BY similarity(name, $2) DESC + LIMIT 100; + """ + rows = await self.pool.fetch(sql, ctx.guild.id, query, True) # type: ignore + if rows: + pages = JobPages(entries=rows, ctx=ctx, per_page=10) + await pages.start() + else: + await ctx.send("No jobs were found") + return + + @is_economy_enabled() + @jobs.command(name="output", usage=" price: int amount_per_hour: int") + @app_commands.describe(name="The name of the item that the job outputs") + async def associate_item( + self, + ctx: commands.Context, + name: Annotated[str, commands.clean_content], + *, + flags: JobOutputFlags, + ) -> None: + """Associate an item with the job's output. A job can only produce one item.""" + if ctx.interaction is not None: + output_modal = CreateJobOutputItemModal( + self.pool, name, flags.price, flags.amount_per_hour + ) + await ctx.interaction.response.send_modal(output_modal) + return + + def check(msg): + return msg.author == ctx.author and ctx.channel == msg.channel + + await ctx.send("What's the description for your item going to be?") + try: + msg = await self.bot.wait_for("message", check=check, timeout=350.0) + except asyncio.TimeoutError: + self.remove_in_progress_job(ctx.guild.id, name) # type: ignore + await ctx.send(MessageConstants.TIMEOUT.value) + return + + if msg.content: + clean_content = await commands.clean_content().convert(ctx, msg.content) + else: + clean_content = msg.content + + if msg.attachments: + clean_content = f"{clean_content}\n{msg.attachments[0].url}" + + if len(clean_content) > 2000: + await ctx.send("Item description is a maximum of 2000 characters.") + return + + status = await create_job_output_item( + name=name, + description=clean_content, + price=flags.price, + amount=flags.amount_per_hour, + guild_id=ctx.guild.id, # type: ignore + worker_id=ctx.author.id, + pool=self.pool, + ) + if status[-1] != "0": + await ctx.send( + f"Successfully created the output item `{name}` (Price: {flags.price}, Amount Per Hour: {flags.amount_per_hour})" + ) + return + else: + await ctx.send("There was an error making it. Please try again") + return + + +async def setup(bot: KumikoCore) -> None: + await bot.add_cog(Jobs(bot)) diff --git a/Bot/Cogs/marketplace.py b/Bot/Cogs/marketplace.py index c5651a92..17239245 100644 --- a/Bot/Cogs/marketplace.py +++ b/Bot/Cogs/marketplace.py @@ -1,196 +1,176 @@ -import discord -from discord import app_commands -from discord.ext import commands -from kumikocore import KumikoCore -from Libs.cog_utils.economy import PurchaseFlags, is_economy_enabled -from Libs.cog_utils.marketplace import formatOptions, getItem, isPaymentValid -from Libs.ui.marketplace import ItemPages, SimpleSearchItemPages -from Libs.utils import Embed, get_or_fetch_member -from typing_extensions import Annotated - - -class Marketplace(commands.Cog): - """Shop for items and others produced from the Jobs module - - This is the module to buy and sell items produced from the Jobs module. - """ - - def __init__(self, bot: KumikoCore) -> None: - self.bot = bot - self.pool = self.bot.pool - - @property - def display_emoji(self) -> discord.PartialEmoji: - return discord.PartialEmoji.from_str("<:shop:1132982447177478214>") - - @is_economy_enabled() - @commands.hybrid_group(name="marketplace", fallback="list") - async def marketplace(self, ctx: commands.Context) -> None: - """List the items available for purchase""" - query = """ - SELECT eco_item.id, eco_item.name, eco_item.description, eco_item.price, eco_item.amount, eco_item.producer_id - FROM eco_item_lookup - INNER JOIN eco_item ON eco_item.id = eco_item_lookup.item_id - WHERE eco_item.guild_id = $1; - """ - rows = await self.pool.fetch(query, ctx.guild.id) # type: ignore - if len(rows) == 0: - await ctx.send("No items available") - return - - pages = ItemPages(entries=rows, ctx=ctx, per_page=1) - await pages.start() - - @is_economy_enabled() - @marketplace.command(name="buy", aliases=["purchase"], usage="amount: ") - @app_commands.describe(name="The name of the item to buy") - async def buy( - self, - ctx: commands.Context, - name: Annotated[str, commands.clean_content], - *, - flags: PurchaseFlags, - ) -> None: - """Buy an item from the marketplace""" - # I have committed several sins - query = """ - SELECT eco_item.id, eco_item.price, eco_item.amount, eco_item.producer_id - FROM eco_item_lookup - INNER JOIN eco_item ON eco_item.id = eco_item_lookup.item_id - WHERE eco_item_lookup.guild_id=$1 AND LOWER(eco_item_lookup.name)=$2; - """ - purchaseItem = """ - WITH item_update AS ( - UPDATE eco_item - SET amount = $4 - WHERE guild_id = $1 AND name = $3 - RETURNING id - ) - INSERT INTO user_inv (owner_id, guild_id, amount_owned, item_id) - VALUES ($2, $1, $5, (SELECT id FROM item_update)); - """ - fetchCreatedItem = """ - SELECT eco_item.id, user_inv.owner_id - FROM user_inv - INNER JOIN eco_item ON eco_item.id = user_inv.item_id - WHERE user_inv.owner_id = $1 AND user_inv.guild_id = $2 AND LOWER(eco_item.name) = $3; - """ - updateBalanceQuery = """ - UPDATE eco_user - SET petals = petals + $2 - WHERE id = $1; - """ - updatePurchaserQuery = """ - UPDATE eco_user - SET petals = petals - $2 - WHERE id = $1; - """ - createLinkUpdate = """ - INSERT INTO user_item_relations (item_id, user_id) - VALUES ($1, $2); - """ - async with self.pool.acquire() as conn: - rows = await conn.fetchrow(query, ctx.guild.id, name.lower()) # type: ignore - if rows is None: - await ctx.send( - "The item you are looking for does not exist, or is already bought. Please try again" - ) - return - records = dict(rows) - if records["producer_id"] == ctx.author.id: - await ctx.send( - "You can't buy your own goods! Buy something else instead" - ) - return - totalPrice = records["price"] * flags.amount - if await isPaymentValid(records, ctx.author.id, flags.amount, conn) is True: - async with conn.transaction(): - await conn.execute( - updateBalanceQuery, - records["producer_id"], - totalPrice, - ) - await conn.execute(updatePurchaserQuery, ctx.author.id, totalPrice) - status = await conn.execute(purchaseItem, ctx.guild.id, ctx.author.id, name.lower(), records["amount"] - flags.amount, flags.amount) # type: ignore - if status[-1] != "0": - createdRows = await conn.fetchrow(fetchCreatedItem, ctx.author.id, ctx.guild.id, name.lower()) # type: ignore - if createdRows is None: - await ctx.send( - "No items fetched. This is a bug in the system" - ) - return - createdRecords = dict(createdRows) - await conn.execute( - createLinkUpdate, - createdRecords["id"], - createdRecords["owner_id"], - ) - else: - await ctx.send( - "Something went wrong with the purchase. This is usually due to the fact that there are extras. Please try again" - ) - return - await ctx.send(f"Purchased item `{name}` for `{totalPrice}`") - else: - await ctx.send( - "The payment is invalid. This is due to the following:\n" - "1. The amount you requested is higher than the amount in stock\n" - "2. You don't have enough funds to make the purchase\n" - "3. There are no remaining in stock\n" - ) - return - - @is_economy_enabled() - @marketplace.command(name="info") - @app_commands.describe(name="The name of the item to search for") - async def info( - self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] - ) -> None: - """Provides info about an item listed on the marketplace""" - item = await getItem(ctx.guild.id, name, self.bot.pool) # type: ignore - if isinstance(item, list): - await ctx.send(formatOptions(item) or ".") - return - - member = await get_or_fetch_member(ctx.guild, item["producer_id"]) # type: ignore - if member is None or item is None: - await ctx.send("There was an issue") - return - record = item - embed = Embed() - embed.set_author(name=record["name"], icon_url=member.display_avatar.url) - embed.set_footer(text=f"ID: {record['id']} | Created at") - embed.timestamp = record["created_at"] - embed.description = record["description"] - embed.add_field(name="Price", value=record["price"]) - embed.add_field(name="Amount", value=record["amount"]) - await ctx.send(embed=embed) - - @is_economy_enabled() - @marketplace.command(name="search") - @app_commands.describe(query="The name of the item to look for") - async def search( - self, ctx: commands.Context, *, query: Annotated[str, commands.clean_content] - ) -> None: - """Searches for an item in the marketplace""" - if len(query) < 3: - await ctx.send("The query must be at least 3 characters") - return - - sql = """ - SELECT id, name - FROM eco_item_lookup - WHERE guild_id=$1 AND name % $2 - ORDER BY similarity(name, $2) DESC - LIMIT 100; - """ - records = await self.pool.fetch(sql, ctx.guild.id, query) # type: ignore - if records: - pages = SimpleSearchItemPages(entries=records, per_page=20, ctx=ctx) - await pages.start() - else: - await ctx.send("No items found") - - -async def setup(bot: KumikoCore) -> None: - await bot.add_cog(Marketplace(bot)) +import discord +from discord import app_commands +from discord.ext import commands +from kumikocore import KumikoCore +from Libs.cog_utils.economy import PurchaseFlags, is_economy_enabled +from Libs.cog_utils.marketplace import format_item_options, get_item, is_payment_valid +from Libs.ui.marketplace import ItemPages, SimpleSearchItemPages +from Libs.utils import Embed, get_or_fetch_member +from typing_extensions import Annotated + + +class Marketplace(commands.Cog): + """Shop for items and others produced from the Jobs module + + This is the module to buy and sell items produced from the Jobs module. + """ + + def __init__(self, bot: KumikoCore) -> None: + self.bot = bot + self.pool = self.bot.pool + + @property + def display_emoji(self) -> discord.PartialEmoji: + return discord.PartialEmoji.from_str("<:shop:1132982447177478214>") + + @is_economy_enabled() + @commands.hybrid_group(name="marketplace", fallback="list") + async def marketplace(self, ctx: commands.Context) -> None: + """List the items available for purchase""" + query = """ + SELECT eco_item.id, eco_item.name, eco_item.description, eco_item.price, eco_item.amount, eco_item.producer_id + FROM eco_item_lookup + INNER JOIN eco_item ON eco_item.id = eco_item_lookup.item_id + WHERE eco_item.guild_id = $1; + """ + rows = await self.pool.fetch(query, ctx.guild.id) # type: ignore + if len(rows) == 0: + await ctx.send("No items available") + return + + pages = ItemPages(entries=rows, ctx=ctx, per_page=1) + await pages.start() + + @is_economy_enabled() + @marketplace.command(name="buy", aliases=["purchase"], usage="amount: ") + @app_commands.describe(name="The name of the item to buy") + async def buy( + self, + ctx: commands.Context, + name: Annotated[str, commands.clean_content], + *, + flags: PurchaseFlags, + ) -> None: + """Buy an item from the marketplace""" + # I have committed several sins + query = """ + SELECT eco_item.id, eco_item.price, eco_item.amount, eco_item.producer_id + FROM eco_item_lookup + INNER JOIN eco_item ON eco_item.id = eco_item_lookup.item_id + WHERE eco_item_lookup.guild_id=$1 AND LOWER(eco_item_lookup.name)=$2; + """ + # kids we have an issue. Upserts are needed + purchase_item = """ + WITH item_update AS ( + UPDATE eco_item + SET amount = $4 + WHERE guild_id = $1 AND name = $3 + RETURNING id + ) + INSERT INTO user_inv (owner_id, guild_id, amount_owned, item_id) + VALUES ($2, $1, $5, (SELECT id FROM item_update)) + ON CONFLICT (owner_id, item_id) DO UPDATE + SET amount_owned = user_inv.amount_owned + $5; + """ + update_balance_query = """ + UPDATE eco_user + SET petals = petals + $2 + WHERE id = $1; + """ + update_purchaser_query = """ + UPDATE eco_user + SET petals = petals - $2 + WHERE id = $1; + """ + async with self.pool.acquire() as conn: + rows = await conn.fetchrow(query, ctx.guild.id, name.lower()) # type: ignore + if rows is None: + await ctx.send( + "The item you are looking for does not exist, or is already bought. Please try again" + ) + return + records = dict(rows) + if records["producer_id"] == ctx.author.id: + await ctx.send( + "You can't buy your own goods! Buy something else instead" + ) + return + total_price = records["price"] * flags.amount + if ( + await is_payment_valid(records, ctx.author.id, flags.amount, conn) + is True + ): + async with conn.transaction(): + await conn.execute( + update_balance_query, + records["producer_id"], + total_price, + ) + await conn.execute( + update_purchaser_query, ctx.author.id, total_price + ) + await conn.execute(purchase_item, ctx.guild.id, ctx.author.id, name.lower(), records["amount"] - flags.amount, flags.amount) # type: ignore + await ctx.send(f"Purchased item `{name}` for `{total_price}`") + else: + await ctx.send( + "The payment is invalid. This is due to the following:\n" + "1. The amount you requested is higher than the amount in stock\n" + "2. You don't have enough funds to make the purchase\n" + "3. There are no remaining in stock\n" + ) + return + + @is_economy_enabled() + @marketplace.command(name="info") + @app_commands.describe(name="The name of the item to search for") + async def info( + self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] + ) -> None: + """Provides info about an item listed on the marketplace""" + item = await get_item(ctx.guild.id, name, self.bot.pool) # type: ignore + if isinstance(item, list): + await ctx.send(format_item_options(item) or ".") + return + + member = await get_or_fetch_member(ctx.guild, item["producer_id"]) # type: ignore + if member is None or item is None: + await ctx.send("There was an issue") + return + record = item + embed = Embed() + embed.set_author(name=record["name"], icon_url=member.display_avatar.url) + embed.set_footer(text=f"ID: {record['id']} | Created at") + embed.timestamp = record["created_at"] + embed.description = record["description"] + embed.add_field(name="Price", value=record["price"]) + embed.add_field(name="Amount", value=record["amount"]) + await ctx.send(embed=embed) + + @is_economy_enabled() + @marketplace.command(name="search") + @app_commands.describe(query="The name of the item to look for") + async def search( + self, ctx: commands.Context, *, query: Annotated[str, commands.clean_content] + ) -> None: + """Searches for an item in the marketplace""" + if len(query) < 3: + await ctx.send("The query must be at least 3 characters") + return + + sql = """ + SELECT id, name + FROM eco_item_lookup + WHERE guild_id=$1 AND name % $2 + ORDER BY similarity(name, $2) DESC + LIMIT 100; + """ + records = await self.pool.fetch(sql, ctx.guild.id, query) # type: ignore + if records: + pages = SimpleSearchItemPages(entries=records, per_page=20, ctx=ctx) + await pages.start() + else: + await ctx.send("No items found") + + +async def setup(bot: KumikoCore) -> None: + await bot.add_cog(Marketplace(bot)) diff --git a/Bot/Cogs/meta.py b/Bot/Cogs/meta.py index 825dbed7..bd53b6fd 100644 --- a/Bot/Cogs/meta.py +++ b/Bot/Cogs/meta.py @@ -1,16 +1,12 @@ -import datetime import platform -import time import discord import psutil from discord.ext import commands from kumikocore import KumikoCore -from Libs.utils import Embed +from Libs.utils import Embed, human_timedelta from psutil._common import bytes2human -VERSION = "v0.10.2" - class Meta(commands.Cog): """Commands to obtain info about Kumiko or others""" @@ -22,21 +18,20 @@ def __init__(self, bot: KumikoCore) -> None: def display_emoji(self) -> discord.PartialEmoji: return discord.PartialEmoji(name="\U00002754") - @commands.Cog.listener() - async def on_ready(self): - global startTime - startTime = time.time() + def get_bot_uptime(self, *, brief: bool = False) -> str: + return human_timedelta( + self.bot.uptime, accuracy=None, brief=brief, suffix=False + ) @commands.hybrid_command(name="uptime") - async def botUptime(self, ctx: commands.Context) -> None: + async def uptime(self, ctx: commands.Context) -> None: """Returns uptime for Kumiko""" - uptime = datetime.timedelta(seconds=int(round(time.time() - startTime))) embed = Embed() - embed.description = f"Kumiko's Uptime: `{uptime.days} Days, {uptime.seconds//3600} Hours, {(uptime.seconds//60)%60} Minutes, {(uptime.seconds%60)} Seconds`" + embed.description = f"Kumiko's Uptime: **{self.get_bot_uptime()}**" await ctx.send(embed=embed) @commands.hybrid_command(name="info") - async def kumikoInfo(self, ctx: commands.Context) -> None: + async def info(self, ctx: commands.Context) -> None: """Shows some basic info about Kumiko""" embed = Embed() embed.title = f"{self.bot.user.name} Info" # type: ignore @@ -49,14 +44,16 @@ async def kumikoInfo(self, ctx: commands.Context) -> None: embed.add_field( name="Discord.py Version", value=discord.__version__, inline=True ) - embed.add_field(name="Kumiko Build Version", value=VERSION, inline=True) + embed.add_field( + name="Kumiko Build Version", value=str(self.bot.version), inline=True + ) await ctx.send(embed=embed) @commands.hybrid_command(name="version") async def version(self, ctx: commands.Context) -> None: """Returns the current version of Kumiko""" embed = Embed() - embed.description = f"Build Version: {VERSION}" + embed.description = f"Build Version: {str(self.bot.version)}" await ctx.send(embed=embed) @commands.hybrid_command(name="ping") @@ -68,20 +65,20 @@ async def ping(self, ctx: commands.Context) -> None: @commands.is_owner() @commands.hybrid_command(name="sys-metrics", aliases=["sysmetrics"]) - async def sysMetrics(self, ctx: commands.Context) -> None: + async def sys_metrics(self, ctx: commands.Context) -> None: """Tells you the current system metrics along with other information""" await ctx.defer() - currMem = psutil.virtual_memory() + mem = psutil.virtual_memory() proc = psutil.Process() with proc.oneshot(): - procMem = bytes2human(proc.memory_info().rss) - diskUsage = psutil.disk_usage("/") + proc_mem = bytes2human(proc.memory_info().rss) + disk_usage = psutil.disk_usage("/") embed = Embed() embed.title = "System Metrics + Info" embed.description = ( f"**CPU:** {psutil.cpu_percent()}% (Proc - {proc.cpu_percent()}%)\n" - f"**Mem:** {procMem} ({procMem}/{bytes2human(currMem.total)})\n" - f"**Disk (System):** {diskUsage.percent}% ({bytes2human(diskUsage.used)}/{bytes2human(diskUsage.total)})\n" + f"**Mem:** {proc_mem} ({proc_mem}/{bytes2human(mem.total)})\n" + f"**Disk (System):** {disk_usage.percent}% ({bytes2human(disk_usage.used)}/{bytes2human(disk_usage.total)})\n" f"**Proc Status:** {proc.status()}\n" ) embed.add_field(name="Kernel Version", value=platform.release()) @@ -92,7 +89,7 @@ async def sysMetrics(self, ctx: commands.Context) -> None: embed.add_field( name="Discord.py Version", value=discord.__version__, inline=True ) - embed.add_field(name="Kumiko Build Version", value=VERSION) + embed.add_field(name="Kumiko Build Version", value=str(self.bot.version)) await ctx.send(embed=embed) diff --git a/Bot/Cogs/moderation.py b/Bot/Cogs/moderation.py index 30fcc40c..5724a061 100644 --- a/Bot/Cogs/moderation.py +++ b/Bot/Cogs/moderation.py @@ -5,7 +5,7 @@ from discord import PartialEmoji, app_commands from discord.ext import commands from kumikocore import KumikoCore -from Libs.utils import Embed, is_mod, parseTimeStr +from Libs.utils import Embed, MessageConstants, is_mod, parse_time_str class Moderation(commands.Cog): @@ -48,20 +48,18 @@ async def ban( `>mod ban @user1 7 spam` Bans user1 and deletes their messages from the last 7 days with the reason "spam" """ - userBanList = ( + ban_list = ( ", ".join([user.mention for user in users]).rstrip(",") if len(users) > 1 else users[0].mention ) - deleteSeconds = ( + del_seconds = ( delete_days * 86400 if delete_days is not None and delete_days <= 7 else 7 ) for members in users: - await members.ban(delete_message_seconds=deleteSeconds, reason=reason) - embed = Embed( - title="Issued Ban", description=f"Successfully banned {userBanList}" - ) - embed.add_field(name="Reason", value=reason or "No reason provided") + await members.ban(delete_message_seconds=del_seconds, reason=reason) + embed = Embed(title="Issued Ban", description=f"Successfully banned {ban_list}") + embed.add_field(name="Reason", value=reason or MessageConstants.NO_REASON.value) await ctx.send(embed=embed) @is_mod() @@ -85,7 +83,7 @@ async def unban( `>mod unban @user1 issue resolved` Unbans user1 with the reason "issue resolved" """ - unbanList = ( + unban_list = ( ", ".join([user.mention for user in users]).rstrip(",") if len(users) > 1 else users[0].mention @@ -93,9 +91,9 @@ async def unban( for members in users: await ctx.guild.unban(user=members, reason=reason) # type: ignore embed = Embed( - title="Issued Unban", description=f"Successfully unbanned {unbanList}" + title="Issued Unban", description=f"Successfully unbanned {unban_list}" ) - embed.add_field(name="Reason", value=reason or "No reason provided") + embed.add_field(name="Reason", value=reason or MessageConstants.NO_REASON.value) await ctx.send(embed=embed) @is_mod() @@ -114,7 +112,7 @@ async def kick( `>mod kick @user1` Kicks user1 """ - kickList = ( + kick_list = ( ", ".join([user.mention for user in users]).rstrip(",") if len(users) > 1 else users[0].mention @@ -122,9 +120,9 @@ async def kick( for members in users: await members.kick(reason=reason) embed = Embed( - title="Kicked User(s)", description=f"Successfully kicked {kickList}" + title="Kicked User(s)", description=f"Successfully kicked {kick_list}" ) - embed.add_field(name="Reason", value=reason or "No reason provided") + embed.add_field(name="Reason", value=reason or MessageConstants.NO_REASON.value) await ctx.send(embed=embed) @is_mod() @@ -154,22 +152,20 @@ async def mute( `>mod mute @user @user2 4h` Mutes both users for 4 hours """ - muteList = ( + mute_list = ( ", ".join([user.mention for user in users]).rstrip(",") if len(users) > 1 else users[0].mention ) - parsedTime = parseTimeStr(duration if duration is not None else "30m") - if parsedTime is not None and parsedTime > timedelta(days=28): - parsedTime = parseTimeStr("28d") - else: - parsedTime = parseTimeStr("28d") + parsed_time = parse_time_str(duration if duration is not None else "30m") + if parsed_time is not None and parsed_time > timedelta(days=28): + parsed_time = parse_time_str("28d") for members in users: - await members.timeout(parsedTime, reason=reason) + await members.timeout(parsed_time, reason=reason) embed = Embed( - title="Muted User(s)", description=f"Successfully muted {muteList}" + title="Muted User(s)", description=f"Successfully muted {mute_list}" ) - embed.add_field(name="Reason", value=reason or "No reason provided") + embed.add_field(name="Reason", value=reason or MessageConstants.NO_REASON.value) await ctx.send(embed=embed) @is_mod() @@ -196,7 +192,7 @@ async def unmute( `>mod unmute @user "timeout expired"` Unmutes the user with the reason "timeout expired" """ - unmuteList = ( + unmute_list = ( ", ".join([user.mention for user in users]).rstrip(",") if len(users) > 1 else users[0].mention @@ -204,9 +200,9 @@ async def unmute( for members in users: await members.timeout(None, reason=reason) embed = Embed( - title="Unmuted User(s)", description=f"Successfully unmuted {unmuteList}" + title="Unmuted User(s)", description=f"Successfully unmuted {unmute_list}" ) - embed.add_field(name="Reason", value=reason or "No reason provided") + embed.add_field(name="Reason", value=reason or MessageConstants.NO_REASON.value) await ctx.send(embed=embed) diff --git a/Bot/Cogs/nsfw.py b/Bot/Cogs/nsfw.py index 7c429abb..23282c18 100644 --- a/Bot/Cogs/nsfw.py +++ b/Bot/Cogs/nsfw.py @@ -26,14 +26,14 @@ def display_emoji(self) -> PartialEmoji: @commands.hybrid_group(name="r34", fallback="get") async def r34(self, ctx: commands.Context, *, tag: Optional[str]) -> None: """Obtain R34 images""" - cleanedTag = ( + cleaned_tag = ( f"{tag} -ai_generated* -stable_diffusion" if tag is not None else "all" ) params = { "page": "dapi", "s": "post", "q": "index", - "tags": cleanedTag, + "tags": cleaned_tag, "json": 1, "limit": 100, } @@ -41,9 +41,9 @@ async def r34(self, ctx: commands.Context, *, tag: Optional[str]) -> None: "https://api.rule34.xxx/index.php", params=params ) as r: data = await r.json(loads=orjson.loads) - formatData = [{"image": item["sample_url"]} for item in data] - embedSource = EmbedListSource(formatData, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + format_data = [{"image": item["sample_url"]} for item in data] + embed_source = EmbedListSource(format_data, per_page=1) + pages = KumikoPages(source=embed_source, ctx=ctx) await pages.start() @commands.is_nsfw() @@ -64,11 +64,11 @@ async def random(self, ctx: commands.Context) -> None: "https://api.rule34.xxx/index.php", params=params ) as r: data = await r.json(loads=orjson.loads) - randomPick = random.choice(data) + random_pick = random.choice(data) embed = Embed() - view = R34DownloadView(link=randomPick["file_url"]) - embed.set_footer(text=f"Source: {randomPick['source'] or None}") - embed.set_image(url=randomPick["sample_url"]) + view = R34DownloadView(link=random_pick["file_url"]) + embed.set_footer(text=f"Source: {random_pick['source'] or None}") + embed.set_image(url=random_pick["sample_url"]) await ctx.send(embed=embed, view=view) diff --git a/Bot/Cogs/pins.py b/Bot/Cogs/pins.py index b4636d8b..f6aae5da 100644 --- a/Bot/Cogs/pins.py +++ b/Bot/Cogs/pins.py @@ -8,13 +8,13 @@ from discord.ext import commands from kumikocore import KumikoCore from Libs.cog_utils.pins import ( - createPin, - editPin, - formatOptions, - getAllPins, - getOwnedPins, - getPinInfo, - getPinText, + create_pin, + edit_pin, + format_options, + get_all_pins, + get_owned_pins, + get_pin_content, + get_pin_info, ) from Libs.ui.pins import CreatePin, DeletePinView, PinEditModal, PinPages, PurgePinView from Libs.utils import ConfirmEmbed, Embed, PinName, get_or_fetch_member @@ -62,11 +62,11 @@ async def pins( self, ctx: commands.Context, *, name: Annotated[str, commands.clean_content] ): """Pin text for later retrieval""" - pinText = await getPinText(ctx.guild.id, name, self.bot.pool) # type: ignore - if isinstance(pinText, list): - await ctx.send(formatOptions(pinText) or ".") + pin_text = await get_pin_content(ctx.guild.id, name, self.bot.pool) # type: ignore + if isinstance(pin_text, list): + await ctx.send(format_options(pin_text) or ".") return - await ctx.send(pinText or ".") + await ctx.send(pin_text or ".") @commands.guild_only() @pins.command(name="create") @@ -83,9 +83,9 @@ async def create( await ctx.send("The pin content is too long. The max is 2000 characters") return - guildId = ctx.guild.id # type: ignore - authorId = ctx.author.id - status = await createPin(authorId, guildId, self.pool, name, content) + guild_id = ctx.guild.id # type: ignore + author_id = ctx.author.id + status = await create_pin(author_id, guild_id, self.pool, name, content) await ctx.send(status) @commands.guild_only() @@ -93,8 +93,8 @@ async def create( async def make(self, ctx: commands.Context) -> None: """Interactively creates a tag for you""" if ctx.interaction is not None: - createPinModal = CreatePin(self.pool) - await ctx.interaction.response.send_modal(createPinModal) + create_pin_modal = CreatePin(self.pool) + await ctx.interaction.response.send_modal(create_pin_modal) return await ctx.send("What would you like the pin's name to be?") @@ -168,7 +168,7 @@ def check(msg): return try: - status = await createPin(ctx.author.id, ctx.guild.id, self.pool, name, clean_content) # type: ignore + status = await create_pin(ctx.author.id, ctx.guild.id, self.pool, name, clean_content) # type: ignore await ctx.send(status) finally: self.remove_in_progress_tag(ctx.guild.id, name) # type: ignore @@ -180,17 +180,17 @@ async def info( self, ctx: commands.Context, name: Annotated[str, commands.clean_content] ) -> None: """Provides info about a pin""" - pinInfo = await getPinInfo(ctx.guild.id, name, self.pool) # type: ignore - if pinInfo is None: + pin_info = await get_pin_info(ctx.guild.id, name, self.pool) # type: ignore + if pin_info is None: await ctx.send("Pin not found.") return embed = Embed() - embed.title = pinInfo["name"] - embed.timestamp = pinInfo["created_at"].replace(tzinfo=datetime.timezone.utc) + embed.title = pin_info["name"] + embed.timestamp = pin_info["created_at"].replace(tzinfo=datetime.timezone.utc) embed.set_footer(text="Pin created at") - embed.add_field(name="Owner", value=f"<@{pinInfo['author_id']}>") + embed.add_field(name="Owner", value=f"<@{pin_info['author_id']}>") embed.add_field( - name="Aliases", value=",".join(pinInfo["aliases"]).rstrip(",") or "None" + name="Aliases", value=",".join(pin_info["aliases"]).rstrip(",") or "None" ) await ctx.send(embed=embed) @@ -220,13 +220,13 @@ async def alias( Pin aliases are not checked for others. You have to provide with the exact spelling (case insensitive) as what the alias is """ # later we need to validate the max that the aliases can have - insertQuery = """ + insert_query = """ UPDATE pin_lookup SET aliases = ARRAY_APPEND(aliases, $2) WHERE guild_id=$3 AND id=(SELECT id FROM pin WHERE LOWER(pin.name)=$1) AND (NOT $2 = ANY(aliases) OR aliases IS NULL); """ async with self.pool.acquire() as conn: - status = await conn.execute(insertQuery, name, alias, ctx.guild.id) # type: ignore + status = await conn.execute(insert_query, name, alias, ctx.guild.id) # type: ignore if status[-1] == "0": await ctx.send( f"A pin with the name of `{name}` does not exist or there is an aliases with the name `{alias}` set already." @@ -246,13 +246,13 @@ async def unalias( ) -> None: """Unalias a pin. You can only unalias your own pins""" # later we need to validate the max that the aliases can have - insertQuery = """ + insert_query = """ UPDATE pin_lookup SET aliases = ARRAY_REMOVE(aliases, $2) WHERE guild_id=$3 AND id=(SELECT id FROM pin WHERE LOWER(pin.name)=$1) AND $2 = ANY(aliases); """ async with self.pool.acquire() as conn: - status = await conn.execute(insertQuery, name, alias, ctx.guild.id) # type: ignore + status = await conn.execute(insert_query, name, alias, ctx.guild.id) # type: ignore if status[-1] == "0": await ctx.send( f"A pin with the name of `{name}` does not exist or there are no aliases set." @@ -326,8 +326,8 @@ async def edit( await ctx.send("Ping content can only be up to 2000 characters") return - sqlRes = await editPin(ctx.guild.id, ctx.author.id, self.pool, name, content) # type: ignore - if sqlRes[-1] == "0": + sql_res = await edit_pin(ctx.guild.id, ctx.author.id, self.pool, name, content) # type: ignore + if sql_res[-1] == "0": await ctx.send("Could not edit the pin. Are you sure you own it?") else: await ctx.send("Successfully edited pin") @@ -338,7 +338,7 @@ async def edit( async def dumps(self, ctx: commands.Context) -> None: """Dumps all tags in your guild into a JSON file""" await ctx.defer() - result = await getAllPins(ctx.guild.id, self.pool) # type: ignore + result = await get_all_pins(ctx.guild.id, self.pool) # type: ignore buffer = BytesIO( orjson.dumps([dict(row) for row in result], option=orjson.OPT_INDENT_2) ) @@ -352,7 +352,7 @@ async def dumps(self, ctx: commands.Context) -> None: @pins.command(name="all") async def all(self, ctx: commands.Context) -> None: """Lists all pins in your guild""" - rows = await getAllPins(ctx.guild.id, self.pool) # type: ignore + rows = await get_all_pins(ctx.guild.id, self.pool) # type: ignore if rows: pages = PinPages(entries=rows, per_page=20, ctx=ctx) await pages.start() @@ -364,7 +364,7 @@ async def all(self, ctx: commands.Context) -> None: @app_commands.describe(member="The member or yourself to list pins from") async def list(self, ctx: commands.Context, member: User = commands.Author) -> None: """Lists all pins from a member or yourself""" - rows = await getOwnedPins(member.id, ctx.guild.id, self.pool) # type: ignore + rows = await get_owned_pins(member.id, ctx.guild.id, self.pool) # type: ignore if len(rows) == 0: await ctx.send("The member does not have any pins") return @@ -457,7 +457,7 @@ async def transfer( SET author_id = $3 WHERE pin.guild_id = $1 AND pin.name = $2; """ - lookupQuery = """ + lookup_query = """ UPDATE pin_lookup SET owner_id = $3 WHERE pin_lookup.guild_id = $1 AND pin_lookup.name = $2; @@ -465,7 +465,7 @@ async def transfer( async with self.pool.acquire() as conn: async with conn.transaction(): await conn.execute(query, ctx.guild.id, pin.lower(), member.id) # type: ignore - await conn.execute(lookupQuery, ctx.guild.id, pin.lower(), member.id) # type: ignore + await conn.execute(lookup_query, ctx.guild.id, pin.lower(), member.id) # type: ignore await ctx.send( f"Successfully transfer the pin `{pin.lower()}` to {member.mention}" diff --git a/Bot/Cogs/prefix.py b/Bot/Cogs/prefix.py index 543e1c26..449248a9 100644 --- a/Bot/Cogs/prefix.py +++ b/Bot/Cogs/prefix.py @@ -30,7 +30,7 @@ async def prefix(self, ctx: commands.Context) -> None: @app_commands.describe( old_prefix="The old prefix to replace", new_prefix="The new prefix to use" ) - async def updatePrefixes( + async def update( self, ctx: commands.Context, old_prefix: str, new_prefix: PrefixConverter ) -> None: """Updates the prefix for your server""" @@ -57,7 +57,7 @@ async def updatePrefixes( @commands.guild_only() @prefix.command(name="add") @app_commands.describe(prefix="The new prefix to add") - async def addPrefixes(self, ctx: commands.Context, prefix: PrefixConverter) -> None: + async def add(self, ctx: commands.Context, prefix: PrefixConverter) -> None: """Adds new prefixes into your server""" prefixes = await get_prefix(self.bot, ctx.message) # validatePrefix(self.bot.prefixes, prefix) is False @@ -74,23 +74,23 @@ async def addPrefixes(self, ctx: commands.Context, prefix: PrefixConverter) -> N SET prefix = ARRAY_APPEND(prefix, $1) WHERE id=$2; """ - guildId = ctx.guild.id # type: ignore # These are all done in an guild - await self.pool.execute(query, prefix, guildId) + guild_id = ctx.guild.id # type: ignore # These are all done in an guild + await self.pool.execute(query, prefix, guild_id) # the weird solution but it actually works - if isinstance(self.bot.prefixes[guildId], list): - self.bot.prefixes[guildId].append(prefix) + if isinstance(self.bot.prefixes[guild_id], list): + self.bot.prefixes[guild_id].append(prefix) else: - self.bot.prefixes[guildId] = [self.bot.default_prefix, prefix] + self.bot.prefixes[guild_id] = [self.bot.default_prefix, prefix] await ctx.send(f"Added prefix: {prefix}") @commands.guild_only() @prefix.command(name="info") - async def infoPrefixes(self, ctx: commands.Context) -> None: + async def info(self, ctx: commands.Context) -> None: """Displays infos about the current prefix set on your server""" prefixes = await get_prefix(self.bot, ctx.message) - cleanedPrefixes = ", ".join([f"`{item}`" for item in prefixes]).rstrip(",") + cleaned_prefixes = ", ".join([f"`{item}`" for item in prefixes]).rstrip(",") embed = Embed() - embed.description = f"**Current prefixes**\n{cleanedPrefixes}" + embed.description = f"**Current prefixes**\n{cleaned_prefixes}" embed.timestamp = utcnow() embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon.url) # type: ignore # LIES, LIES, AND LIES!!! await ctx.send(embed=embed) @@ -99,7 +99,7 @@ async def infoPrefixes(self, ctx: commands.Context) -> None: @commands.guild_only() @prefix.command(name="delete") @app_commands.describe(prefix="The prefix to delete") - async def deletePrefixes(self, ctx: commands.Context, prefix: str) -> None: + async def delete(self, ctx: commands.Context, prefix: str) -> None: """Deletes a prefix from your server""" view = DeletePrefixView(bot=self.bot, prefix=prefix) embed = ConfirmEmbed() diff --git a/Bot/Cogs/pronouns.py b/Bot/Cogs/pronouns.py new file mode 100644 index 00000000..920efd8c --- /dev/null +++ b/Bot/Cogs/pronouns.py @@ -0,0 +1,279 @@ +from typing import Optional + +import discord +import orjson +from discord import app_commands +from discord.ext import commands +from kumikocore import KumikoCore +from Libs.cog_utils.pronouns import parse_pronouns +from Libs.ui.pronouns import ( + PronounsInclusiveEntry, + PronounsInclusivePages, + PronounsNounsEntry, + PronounsNounsPages, + PronounsProfileCircleEntry, + PronounsProfileEntry, + PronounsProfilePages, + PronounsTermsEntry, + PronounsTermsPages, + PronounsValuesEntry, + PronounsWordsEntry, +) +from Libs.utils import Embed +from typing_extensions import Annotated +from yarl import URL + + +class Pronouns(commands.Cog): + """Your to-go module for pronouns! + + This module provides a way to view pronouns for others to see. And this is used seriously as a resource for LGBTQ+ folks. + """ + + def __init__(self, bot: KumikoCore) -> None: + self.bot = bot + self.session = self.bot.session + + @property + def display_emoji(self) -> discord.PartialEmoji: + return discord.PartialEmoji.from_str( + "<:ProgressPrideheart:1053776316438167632>" + ) + + @commands.hybrid_group(name="pronouns", fallback="get") + @app_commands.describe(member="The member to lookup") + async def pronouns(self, ctx: commands.Context, member: discord.Member) -> None: + """Obtains the pronouns of a Discord user from PronounDB + + This is not directly from Discord but a third party extension + """ + params = {"platform": "discord", "ids": member.id} + async with self.session.get( + "https://pronoundb.org/api/v2/lookup", params=params + ) as r: + data = await r.json(loads=orjson.loads) + if len(data) == 0: + await ctx.send("No pronouns found for these user(s).") + return + embed = Embed() + embed.set_author( + name=f"{member.global_name}'s pronouns", + icon_url=member.display_avatar.url, + ) + embed.description = "\n".join( + [ + f"{k}: {parse_pronouns(v)}" + for k, v in data[f"{member.id}"]["sets"].items() + ] + ) + await ctx.send(embed=embed) + + @pronouns.command(name="profile") + @app_commands.describe( + username="The username of the user. These are not Discord usernames, but pronouns.page usernames" + ) + async def profile( + self, ctx: commands.Context, *, username: Annotated[str, commands.clean_content] + ) -> None: + """Obtains the profile of an Pronouns.page user""" + await ctx.defer() + url = URL("https://en.pronouns.page/api/profile/get/") / username + params = {"version": 2} + async with self.session.get(url, params=params) as r: + data = await r.json(loads=orjson.loads) + if len(data["profiles"]) == 0: + await ctx.send("The profile was not found") + return + curr_username = data["username"] + avatar = data["avatar"] + converted = { + k: PronounsProfileEntry( + username=curr_username, + avatar=avatar, + locale=k, + names=[ + PronounsValuesEntry( + value=name["value"], opinion=name["opinion"] + ) + for name in v["names"] + ], + pronouns=[ + PronounsValuesEntry( + value=pronoun["value"], opinion=pronoun["opinion"] + ) + for pronoun in v["pronouns"] + ], + description=v["description"], + age=v["age"], + links=v["links"], + flags=v["flags"], + words=[ + PronounsWordsEntry( + header=words["header"], + values=[ + PronounsValuesEntry( + value=value["value"], opinion=value["opinion"] + ) + for value in words["values"] + ], + ) + for words in v["words"] + ], + timezone=v["timezone"]["tz"], + circle=[ + PronounsProfileCircleEntry( + username=member["username"], + avatar=member["avatar"], + mutual=member["circleMutual"], + relationship=member["relationship"], + ) + for member in v["circle"] + ] + if len(v["circle"]) != 0 + else None, + ) + for k, v in data["profiles"].items() + } + pages = PronounsProfilePages(entries=converted, ctx=ctx) + await pages.start() + + @pronouns.command(name="terms") + @app_commands.describe(query="The term to look for") + async def terms( + self, ctx: commands.Context, *, query: Optional[str] = None + ) -> None: + """Looks up terms from Pronouns.page""" + url = URL("https://en.pronouns.page/api/terms") + if query: + url = url / "search" / query + async with self.session.get(url) as r: + data = await r.json(loads=orjson.loads) + if len(data) == 0: + await ctx.send("No terms were found") + return + converted = [ + PronounsTermsEntry( + term=term["term"], + original=term["original"] if len(term["original"]) > 0 else None, + definition=term["definition"], + locale=term["locale"], + flags=term["flags"], + category=term["category"], + ) + for term in data + ] + pages = PronounsTermsPages(entries=converted, ctx=ctx) + await pages.start() + + @pronouns.command(name="nouns") + @app_commands.describe(query="The noun to look for") + async def nouns( + self, ctx: commands.Context, *, query: Optional[str] = None + ) -> None: + """Looks up nouns on Pronouns.page""" + url = URL("https://en.pronouns.page/api/nouns") + if query: + url = url / "search" / query + async with self.session.get(url) as r: + # If people start using this for pronouns, then a generator shows up + # so that's in case this happens + if r.content_type == "text/html": + await ctx.send("Uhhhhhhhhhhhh what mate") + return + data = await r.json(loads=orjson.loads) + if len(data) == 0: + await ctx.send("No nouns were found") + return + converted = [ + PronounsNounsEntry( + masc=entry["masc"], + fem=entry["fem"], + neutr=entry["neutr"], + masc_plural=entry["mascPl"], + fem_plural=entry["femPl"], + neutr_plural=entry["neutrPl"], + ) + for entry in data + ] + pages = PronounsNounsPages(entries=converted, ctx=ctx) + await pages.start() + + @pronouns.command(name="inclusive") + @app_commands.describe(term="The inclusive term to look for") + async def inclusive( + self, ctx: commands.Context, *, term: Optional[str] = None + ) -> None: + """Provides inclusive terms for users""" + url = URL("https://en.pronouns.page/api/inclusive") + if term: + url = url / "search" / term + async with self.session.get(url) as r: + data = await r.json(loads=orjson.loads) + if len(data) == 0: + await ctx.send("No nouns were found") + return + converted = [ + PronounsInclusiveEntry( + instead_of=entry["insteadOf"], + say=entry["say"], + because=entry["because"], + categories=entry["categories"], + clarification=entry["clarification"], + ) + for entry in data + ] + pages = PronounsInclusivePages(entries=converted, ctx=ctx) + await pages.start() + + @pronouns.command(name="lookup") + @app_commands.describe( + pronouns="The pronouns to look up. These are actual pronouns, such as she/her, and they/them. " + ) + async def lookup(self, ctx: commands.Context, *, pronouns: str) -> None: + """Lookup info about the given pronouns + + Pronouns include she/her, they/them and many others. You don't have to use the binary forms (eg they/them), but search them up like 'they' or 'she' + """ + url = URL("https://en.pronouns.page/api/pronouns/") + banner_url = URL("https://en.pronouns.page/api/banner/") + full_url = url / pronouns + full_banner_url = banner_url / f"{pronouns}.png" + async with self.session.get(full_url) as r: + data = await r.json(loads=orjson.loads) + if data is None: + await ctx.send("The pronouns requested were not found") + return + desc = f"{data['description']}\n\n" + + desc += "**Info**\n" + desc += ( + f"Aliases: {data['aliases']}\nPronounceable: {data['pronounceable']}\n" + ) + desc += f"Normative: {data['normative']}\n" + if len(data["morphemes"]) != 0: + desc += "\n**Morphemes**\n" + for k, v in data["morphemes"].items(): + desc += f"{k.replace('_', ' ').title()}: {v}\n" + + if len(data["pronunciations"]) != 0: + desc += "\n**Pronunciations**\n" + for k, v in data["pronunciations"].items(): + desc += f"{k.replace('_', ' ').title()}: {v}\n" + embed = Embed() + embed.title = data["name"] + embed.description = desc + embed.add_field(name="Examples", value="\n".join(data["examples"])) + embed.add_field( + name="Forms", + value=f"Third Form: {data['thirdForm']}\nSmall Form: {data['smallForm']}", + ) + embed.add_field( + name="Plural?", + value=f"Plural: {data['plural']}\nHonorific: {data['pluralHonorific']}", + ) + embed.set_image(url=str(full_banner_url)) + await ctx.send(embed=embed) + + +async def setup(bot: KumikoCore) -> None: + await bot.add_cog(Pronouns(bot)) diff --git a/Bot/Cogs/reddit.py b/Bot/Cogs/reddit.py index 51f72b0d..823d48b1 100644 --- a/Bot/Cogs/reddit.py +++ b/Bot/Cogs/reddit.py @@ -1,16 +1,14 @@ import os -from datetime import datetime from typing import Literal, Optional import asyncpraw import orjson from discord import PartialEmoji, app_commands from discord.ext import commands -from discord.utils import format_dt from dotenv import load_dotenv from kumikocore import KumikoCore -from Libs.utils import parseSubreddit -from Libs.utils.pages import EmbedListSource, KumikoPages +from Libs.ui.reddit import RedditEntry, RedditMemeEntry, RedditMemePages, RedditPages +from Libs.utils import parse_subreddit load_dotenv() @@ -41,7 +39,7 @@ async def reddit(self, ctx: commands.Context) -> None: search="The search query to use", subreddit="Which subreddit to use. Defaults to all.", ) - async def redditSearch( + async def search( self, ctx: commands.Context, *, search: str, subreddit: Optional[str] = "all" ) -> None: """Searches for posts on Reddit""" @@ -51,92 +49,31 @@ async def redditSearch( user_agent="Kumiko (by /u/No767)", requestor_kwargs={"session": self.bot.session}, ) as reddit: - sub = await reddit.subreddit(parseSubreddit(subreddit)) - data = [ - { - "title": post.title, - "description": post.selftext, - "image": post.url, - "fields": [ - {"name": "Author", "value": post.author}, - {"name": "Upvotes", "value": post.score}, - {"name": "NSFW", "value": post.over_18}, - {"name": "Flair", "value": post.link_flair_text}, - {"name": "Number of Comments", "value": post.num_comments}, - { - "name": "Reddit URL", - "value": f"https://reddit.com{post.permalink}", - }, - { - "name": "Created At", - "value": format_dt( - datetime.fromtimestamp(post.created_utc) - ), - }, - ], - } - async for post in sub.search(search) + sub = await reddit.subreddit(parse_subreddit(subreddit)) + sub_search = sub.search(search) + converted = [ + RedditEntry( + title=post.title, + description=post.selftext, + image_url=post.url, + author=post.author, + upvotes=post.score, + nsfw=post.over_18, + flair=post.link_flair_text, + num_of_comments=post.num_comments, + post_permalink=post.permalink, + created_utc=post.created_utc, + ) + async for post in sub_search ] - embedSource = EmbedListSource(data, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) - await pages.start() - - @reddit.command(name="eggirl") - @app_commands.describe(filter="Sort filters. Defaults to New") - async def redditEggIRL( - self, - ctx: commands.Context, - filter: Optional[Literal["New", "Hot", "Rising"]] = "New", - ) -> None: - """Literally just shows you r/egg_irl posts. No comment.""" - async with asyncpraw.Reddit( - client_id=REDDIT_ID, - client_secret=REDDIT_SECRET, - user_agent="Kumiko (by /u/No767)", - requestor_kwargs={"session": self.bot.session}, - ) as reddit: - sub = await reddit.subreddit(parseSubreddit("egg_irl")) - subGen = ( - sub.new(limit=10) - if filter == "New" - else sub.hot(limit=10) - if filter == "Hot" - else sub.rising(limit=10) - ) - data = [ - { - "title": post.title, - "description": post.selftext, - "image": post.url, - "fields": [ - {"name": "Author", "value": post.author}, - {"name": "Upvotes", "value": post.score}, - {"name": "NSFW", "value": post.over_18}, - {"name": "Flair", "value": post.link_flair_text}, - {"name": "Number of Comments", "value": post.num_comments}, - { - "name": "Reddit URL", - "value": f"https://reddit.com{post.permalink}", - }, - { - "name": "Created At", - "value": format_dt( - datetime.fromtimestamp(post.created_utc) - ), - }, - ], - } - async for post in subGen - ] - embedSource = EmbedListSource(data, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + pages = RedditPages(entries=converted, ctx=ctx) await pages.start() @reddit.command(name="feed") @app_commands.describe( subreddit="Subreddit to search", filter="Sort filters. Defaults to New" ) - async def redditFeed( + async def feed( self, ctx: commands.Context, subreddit: str, @@ -149,41 +86,30 @@ async def redditFeed( user_agent="Kumiko (by /u/No767)", requestor_kwargs={"session": self.bot.session}, ) as reddit: - sub = await reddit.subreddit(parseSubreddit(subreddit)) - subGen = ( + sub = await reddit.subreddit(parse_subreddit(subreddit)) + sub_gen = ( sub.new(limit=10) if filter == "New" else sub.hot(limit=10) if filter == "Hot" else sub.rising(limit=10) ) - data = [ - { - "title": post.title, - "description": post.selftext, - "image": post.url, - "fields": [ - {"name": "Author", "value": post.author}, - {"name": "Upvotes", "value": post.score}, - {"name": "NSFW", "value": post.over_18}, - {"name": "Flair", "value": post.link_flair_text}, - {"name": "Number of Comments", "value": post.num_comments}, - { - "name": "Reddit URL", - "value": f"https://reddit.com{post.permalink}", - }, - { - "name": "Created At", - "value": format_dt( - datetime.fromtimestamp(post.created_utc) - ), - }, - ], - } - async for post in subGen + converted = [ + RedditEntry( + title=post.title, + description=post.selftext, + image_url=post.url, + author=post.author, + upvotes=post.score, + nsfw=post.over_18, + flair=post.link_flair_text, + num_of_comments=post.num_comments, + post_permalink=post.permalink, + created_utc=post.created_utc, + ) + async for post in sub_gen ] - embedSource = EmbedListSource(data, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + pages = RedditPages(entries=converted, ctx=ctx) await pages.start() @reddit.command(name="memes") @@ -191,31 +117,28 @@ async def redditFeed( subreddit="Subreddit to search", amount="Amount of memes to return. Defaults to 5", ) - async def searchMemes( + async def search_memes( self, ctx: commands.Context, subreddit: str, amount: Optional[int] = 5 ) -> None: """Searches for memes on Reddit""" async with self.bot.session.get( - f"https://meme-api.com/gimme/{parseSubreddit(subreddit)}/{amount}" + f"https://meme-api.com/gimme/{parse_subreddit(subreddit)}/{amount}" ) as r: data = await r.json(loads=orjson.loads) - mainData = [ - { - "title": item["title"], - "image": item["url"], - "fields": [ - {"name": "Author", "value": item["author"]}, - {"name": "Subreddit", "value": item["subreddit"]}, - {"name": "Upvotes", "value": item["ups"]}, - {"name": "NSFW", "value": item["nsfw"]}, - {"name": "Spoiler", "value": item["spoiler"]}, - {"name": "Reddit URL", "value": item["postLink"]}, - ], - } + converted = [ + RedditMemeEntry( + title=item["title"], + url=item["url"], + author=item["author"], + subreddit=item["subreddit"], + ups=item["ups"], + nsfw=item["nsfw"], + spoiler=item["spoiler"], + reddit_url=item["postLink"], + ) for item in data["memes"] ] - embedSource = EmbedListSource(mainData, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + pages = RedditMemePages(entries=converted, ctx=ctx) await pages.start() diff --git a/Bot/Cogs/redirects.py b/Bot/Cogs/redirects.py new file mode 100644 index 00000000..eba8f1bf --- /dev/null +++ b/Bot/Cogs/redirects.py @@ -0,0 +1,146 @@ +import discord +from discord import PartialEmoji, app_commands +from discord.ext import commands +from kumikocore import KumikoCore +from Libs.cache import KumikoCache +from Libs.cog_utils.redirects import ( + can_close_threads, + is_redirects_enabled, + is_thread, + mark_as_resolved, +) +from Libs.ui.redirects import ConfirmResolvedView +from Libs.utils import is_manager + + +class Redirects(commands.Cog): + """Redirects a conversation into a separate thread + + This module is intended when you have multiple overlapping conversations in an channel. + This module should be used within your general channel or others. + """ + + def __init__(self, bot: KumikoCore) -> None: + self.bot = bot + self.pool = self.bot.pool + self.redis_pool = self.bot.redis_pool + self.redirects_path = ".redirects" + + @property + def display_emoji(self) -> PartialEmoji: + return PartialEmoji(name="\U0001f500") + + @commands.Cog.listener() + async def on_thread_create(self, thread: discord.Thread) -> None: + # this logic is the same as RoboDanny + message = thread.get_partial_message(thread.id) + try: + await message.pin() + except discord.HTTPException: + pass + + @is_redirects_enabled() + @commands.cooldown(1, 30, commands.BucketType.channel) + @commands.hybrid_group(name="redirect", fallback="conversation") + @app_commands.describe(thread_name="The name of the thread to create") + async def redirect(self, ctx: commands.Context, *, thread_name: str) -> None: + """Redirects a conversation into a separate thread""" + # Requires Permissions.create_public_threads + created_thread = await ctx.message.create_thread( + name=thread_name, reason=f"Conversation redirected by {ctx.author.name}" + ) + if ctx.message.reference is not None: + reference_author = ( + ctx.message.reference.cached_message.author.mention + if ctx.message.reference.cached_message is not None + else "you" + ) + await ctx.send( + f"Hey, {ctx.author.mention} has requested that {reference_author} redirect this conversation to {created_thread.jump_url} instead." + ) + else: + await ctx.send( + f"{ctx.author.global_name} has requested that the conversation be moved to {created_thread.jump_url} instead." + ) + + @is_manager() + @redirect.command(name="enable") + async def enable(self, ctx: commands.Context): + """Enables the redirect module""" + assert ctx.guild is not None + key = f"cache:kumiko:{ctx.guild.id}:guild_config" + cache = KumikoCache(self.redis_pool) + query = """ + UPDATE guild + SET redirects = $2 + WHERE id = $1; + """ + results = await cache.get_json_cache( + key=key, path=self.redirects_path, value_only=False + ) + if results is True: + await ctx.send("Redirects are already enabled") + return + else: + await self.pool.execute(query, ctx.guild.id, True) + await cache.merge_json_cache( + key=key, value=True, path=self.redirects_path, ttl=None + ) + await ctx.send("Redirects are now enabled") + return + + @is_manager() + @is_redirects_enabled() + @redirect.command(name="disable") + async def disable(self, ctx: commands.Context): + """Disables the redirects module""" + assert ctx.guild is not None + key = f"cache:kumiko:{ctx.guild.id}:guild_config" + cache = KumikoCache(connection_pool=self.redis_pool) + query = """ + UPDATE guild + SET redirects = $2 + WHERE id = $1; + """ + await self.pool.execute(query, ctx.guild.id, False) + await cache.merge_json_cache( + key=key, value=False, path=self.redirects_path, ttl=None + ) + await ctx.send( + "Redirects is now disabled for your server. Please enable it first." + ) + + @is_thread() + @is_redirects_enabled() + @commands.cooldown(1, 20, commands.BucketType.channel) + @commands.hybrid_command(name="resolved", aliases=["completed", "solved"]) + async def resolved(self, ctx: commands.Context) -> None: + """Marks a thread as completed""" + channel = ctx.channel + if not isinstance(channel, discord.Thread): + raise RuntimeError("This only works in threads") + + if can_close_threads(ctx) and ctx.invoked_with in [ + "resolved", + "completed", + "solved", + ]: + # Permissions.add_reaction and Permissions.read_message_history is required + await ctx.message.add_reaction(discord.PartialEmoji(name="\U00002705")) + await mark_as_resolved(channel, ctx.author) + return + else: + prompt_message = f"<@!{channel.owner_id}>, would you like to mark this thread as solved? If this thread is not marked as resolved, then it will not be resolved. This has been requested by {ctx.author.mention}." + view = ConfirmResolvedView(thread=channel, author=ctx.author, timeout=300.0) + await ctx.send(content=prompt_message, view=view) + + @resolved.error + async def on_resolved_error(self, ctx: commands.Context, error: Exception): + if isinstance(error, commands.CommandOnCooldown): + await ctx.send( + f"This command is on cooldown. Try again in {error.retry_after:.2f}s" + ) + + +async def setup(bot: KumikoCore) -> None: + await bot.add_cog(Redirects(bot)) diff --git a/Bot/Cogs/search.py b/Bot/Cogs/search.py index c9c67f94..b3661df7 100644 --- a/Bot/Cogs/search.py +++ b/Bot/Cogs/search.py @@ -10,7 +10,6 @@ from gql import Client, gql from gql.transport.aiohttp import AIOHTTPTransport from kumikocore import KumikoCore -from Libs.errors import NoItemsError from Libs.utils.pages import EmbedListSource, KumikoPages load_dotenv() @@ -37,7 +36,7 @@ async def search(self, ctx: commands.Context) -> None: @search.command(name="anime") @app_commands.describe(name="The name of the anime to search") - async def searchAnime(self, ctx: commands.Context, *, name: str) -> None: + async def anime(self, ctx: commands.Context, *, name: str) -> None: """Searches up animes""" async with Client( transport=AIOHTTPTransport(url="https://graphql.anilist.co/"), @@ -87,9 +86,10 @@ async def searchAnime(self, ctx: commands.Context, *, name: str) -> None: data = await gql_session.execute(query, variable_values=params) if len(data["Page"]["media"]) == 0: - raise NoItemsError + await ctx.send("The anime was not found") + return else: - mainData = [ + main_data = [ { "title": item["title"]["romaji"], "description": str(item["description"]).replace("
", ""), @@ -122,13 +122,13 @@ async def searchAnime(self, ctx: commands.Context, *, name: str) -> None: } for item in data["Page"]["media"] ] - embedSource = EmbedListSource(mainData, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + embed_source = EmbedListSource(main_data, per_page=1) + pages = KumikoPages(source=embed_source, ctx=ctx) await pages.start() @search.command(name="manga") @app_commands.describe(name="The name of the manga to search") - async def searchManga(self, ctx: commands.Context, *, name: str): + async def manga(self, ctx: commands.Context, *, name: str): """Searches for manga on AniList""" async with Client( transport=AIOHTTPTransport(url="https://graphql.anilist.co/"), @@ -176,9 +176,10 @@ async def searchManga(self, ctx: commands.Context, *, name: str): params = {"mangaName": name, "perPage": 25, "isAdult": False} data = await gql_session.execute(query, variable_values=params) if len(data["Page"]["media"]) == 0: - raise NoItemsError + await ctx.send("The manga(s) were not found") + return else: - mainData = [ + main_data = [ { "title": item["title"]["romaji"], "description": str(item["description"]).replace("
", ""), @@ -210,13 +211,13 @@ async def searchManga(self, ctx: commands.Context, *, name: str): } for item in data["Page"]["media"] ] - embedSource = EmbedListSource(mainData, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + embed_source = EmbedListSource(main_data, per_page=1) + pages = KumikoPages(source=embed_source, ctx=ctx) await pages.start() @search.command(name="gifs") @app_commands.describe(search="The search term to use") - async def searchGifs(self, ctx: commands.Context, *, search: str) -> None: + async def gifs(self, ctx: commands.Context, *, search: str) -> None: """Searches for gifs on Tenor""" params = { "q": search, @@ -230,14 +231,15 @@ async def searchGifs(self, ctx: commands.Context, *, search: str) -> None: ) as r: data = await r.json(loads=orjson.loads) if len(data["results"]) == 0 or r.status == 404: - raise NoItemsError + await ctx.send("The gifs were not found") + return else: - mainData = [ + main_data = [ {"image": item["media_formats"]["gif"]["url"]} for item in data["results"] ] - embedSource = EmbedListSource(mainData, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + embed_source = EmbedListSource(main_data, per_page=1) + pages = KumikoPages(source=embed_source, ctx=ctx) await pages.start() @search.command(name="mc-mods") @@ -245,7 +247,7 @@ async def searchGifs(self, ctx: commands.Context, *, search: str) -> None: mod_name="The name of the mod to search for", modloader="Which modloader to use. Defaults to Forge.", ) - async def searchMods( + async def mods( self, ctx: commands.Context, *, @@ -264,9 +266,10 @@ async def searchMods( ) as r: data = await r.json(loads=orjson.loads) if len(data["hits"]) == 0: - raise NoItemsError + await ctx.send("The mod(s) were/was not found") + return else: - mainData = [ + main_data = [ { "title": item["title"], "description": item["description"], @@ -301,8 +304,8 @@ async def searchMods( } for item in data["hits"] ] - embedSource = EmbedListSource(mainData, per_page=1) - pages = KumikoPages(source=embedSource, ctx=ctx) + embed_source = EmbedListSource(main_data, per_page=1) + pages = KumikoPages(source=embed_source, ctx=ctx) await pages.start() diff --git a/Bot/Cogs/tasks.py b/Bot/Cogs/tasks.py index ab19a38f..5fe2fc57 100644 --- a/Bot/Cogs/tasks.py +++ b/Bot/Cogs/tasks.py @@ -12,12 +12,16 @@ def __init__(self, bot: KumikoCore) -> None: self.bot = bot self.pool = self.bot.pool self.logger = logging.getLogger("discord") + + async def cog_load(self): self.update_item_stock.start() self.update_job_pay.start() + self.clear_auction_house.start() async def cog_unload(self): self.update_item_stock.stop() self.update_job_pay.stop() + self.clear_auction_house.stop() @tasks.loop(hours=1.0) async def update_item_stock(self) -> None: @@ -37,24 +41,24 @@ async def update_item_stock(self) -> None: # For now, i'll leave for now # By design, we quite literally want to restock every single one # The items now don't have owners - getItems = """ + get_itms = """ SELECT eco_item.id, eco_item.amount, eco_item.restock_amount FROM eco_item_lookup INNER JOIN eco_item ON eco_item.id = eco_item_lookup.item_id; """ - updateStock = """ + update_stock = """ UPDATE eco_item SET amount = amount + $2 WHERE id = $1; """ async with self.pool.acquire() as conn: - smt = await conn.prepare(getItems) + smt = await conn.prepare(get_itms) async with conn.transaction(): async for row in smt.cursor(): if row is not None: record = dict(row) await conn.execute( - updateStock, record["id"], record["restock_amount"] + update_stock, record["id"], record["restock_amount"] ) @tasks.loop(hours=1.0) @@ -71,46 +75,87 @@ async def update_job_pay(self) -> None: The user of prepared statements make sense here since we are running these cursors through literally every single registered user. Which can get a lot """ # is this inner join really needed? - sumDataQuery = """ + sum_data_query = """ SELECT SUM(job.pay_amount) AS total FROM job_lookup INNER JOIN job ON job.id = job_lookup.job_id WHERE job_lookup.worker_id = $1 AND job_lookup.listed = False GROUP BY job_lookup.worker_id; """ - updateQuery = """ + update_query = """ UPDATE eco_user SET petals = petals + $2 WHERE id = $1; """ - updateRankAndPetalsQuery = """ + update_rank_and_petals_query = """ UPDATE eco_user SET rank = $2, petals = petals + $3 WHERE id = $1; """ async with self.pool.acquire() as conn: smt = await conn.prepare("SELECT id, rank, petals FROM eco_user") - sumDataSmt = await conn.prepare(sumDataQuery) + sum_data_smt = await conn.prepare(sum_data_query) async with conn.transaction(): async for record in smt.cursor(): - fetchedRecord = dict(record) - total = await sumDataSmt.fetchval(fetchedRecord["id"]) + fetched_record = dict(record) + total = await sum_data_smt.fetchval(fetched_record["id"]) if total is not None: - predictedRank = calc_rank(fetchedRecord["petals"] + total) - if predictedRank > fetchedRecord["rank"]: + predicted_rank = calc_rank(fetched_record["petals"] + total) + if predicted_rank > fetched_record["rank"]: await conn.execute( - updateRankAndPetalsQuery, - fetchedRecord["id"], - predictedRank, + update_rank_and_petals_query, + fetched_record["id"], + predicted_rank, total, ) else: - await conn.execute(updateQuery, fetchedRecord["id"], total) + await conn.execute( + update_query, fetched_record["id"], total + ) + + @tasks.loop(hours=24.0) + async def clear_auction_house(self) -> None: + """The internal task of clearing the last records from 24 hours ago + + This is created because the auction house will always have the records completely cleared out after 24 hours. This is by design. + """ + select_records = """ + SELECT id, user_id, guild_id, item_id, amount_listed + FROM auction_house + WHERE listed_at >= (NOW() AT TIME ZONE 'utc') - INTERVAL '24 HOURS'; + """ + give_back_to_user = """ + INSERT INTO user_inv (owner_id, guild_id, item_id, amount_owned) + VALUES ($1, $2, $3, $4) + ON CONFLICT (owner_id, item_id) DO UPDATE + SET amount_owned = user_inv.amount_owned + $4; + """ + delete_records = """ + DELETE FROM auction_house + WHERE id = $1 AND user_id = $2; + """ + async with self.pool.acquire() as conn: + stmt = await conn.prepare(select_records) + async with conn.transaction(): + async for row in stmt.cursor(): + record = dict(row) + await conn.execute( + give_back_to_user, + record["user_id"], + record["guild_id"], + record["item_id"], + record["amount_listed"], + ) + await conn.execute(delete_records, record["id"], record["user_id"]) @update_job_pay.error async def on_update_pay_error(self, error) -> None: self.logger.exception(f"Error in update_pay: {error}") + @clear_auction_house.error + async def on_clear_auction_house_error(self, error) -> None: + self.logger.exception(f"Error in clear_auction_house: {error}") + async def setup(bot: KumikoCore) -> None: await bot.add_cog(Tasks(bot)) diff --git a/Bot/Cogs/waifus.py b/Bot/Cogs/waifus.py index 23066d48..866af8fb 100644 --- a/Bot/Cogs/waifus.py +++ b/Bot/Cogs/waifus.py @@ -26,9 +26,9 @@ async def waifu(self, ctx: commands.Context) -> None: await ctx.send_help(ctx.command) @waifu.command(name="one") - async def randomWaifu(self, ctx: commands.Context) -> None: + async def random_waifu(self, ctx: commands.Context) -> None: """Returns a random waifu pic""" - waifuTagList = [ + waifu_list = [ "uniform", "maid", "waifu", @@ -38,7 +38,7 @@ async def randomWaifu(self, ctx: commands.Context) -> None: "selfies", ] params = { - "included_tags": random.choice(waifuTagList), + "included_tags": random.choice(waifu_list), "is_nsfw": "false", "excluded_tags": "oppai", } @@ -48,9 +48,9 @@ async def randomWaifu(self, ctx: commands.Context) -> None: await ctx.send(embed=embed) @waifu.command(name="many") - async def randomWaifuMany(self, ctx: commands.Context) -> None: + async def many_random_waifus(self, ctx: commands.Context) -> None: """Returns up to 30 random waifu pics""" - waifuTagList = [ + waifu_list = [ "uniform", "maid", "waifu", @@ -60,16 +60,16 @@ async def randomWaifuMany(self, ctx: commands.Context) -> None: "selfies", ] params = { - "included_tags": random.choice(waifuTagList), + "included_tags": random.choice(waifu_list), "is_nsfw": "False", "excluded_tags": "oppai", "many": "true", } async with self.session.get("https://api.waifu.im/search/", params=params) as r: data = await r.json(loads=orjson.loads) - mainData = [{"image": item["url"]} for item in data["images"]] - embedSource = EmbedListSource(mainData, per_page=1) - menu = KumikoPages(source=embedSource, ctx=ctx, compact=False) + converted_data = [{"image": item["url"]} for item in data["images"]] + embed_source = EmbedListSource(converted_data, per_page=1) + menu = KumikoPages(source=embed_source, ctx=ctx, compact=False) await menu.start() diff --git a/Bot/Libs/cache/__init__.py b/Bot/Libs/cache/__init__.py index 301f3c19..c6d33dc6 100644 --- a/Bot/Libs/cache/__init__.py +++ b/Bot/Libs/cache/__init__.py @@ -1,12 +1,12 @@ from .cp_manager import KumikoCPManager -from .decorators import cache, cacheJson -from .key_builder import CommandKeyBuilder +from .decorators import cache, cache_json +from .key_builder import command_key_builder from .redis_cache import KumikoCache __all__ = [ - "CommandKeyBuilder", + "command_key_builder", "KumikoCache", "KumikoCPManager", "cache", - "cacheJson", + "cache_json", ] diff --git a/Bot/Libs/cache/cp_manager.py b/Bot/Libs/cache/cp_manager.py index 086209fd..6efa68e0 100644 --- a/Bot/Libs/cache/cp_manager.py +++ b/Bot/Libs/cache/cp_manager.py @@ -11,10 +11,10 @@ class KumikoCPManager: def __init__(self, uri: str, max_size: int = 20) -> None: self.uri = uri self.max_size = max_size - self.connPool = None + self.pool = None async def __aenter__(self) -> ConnectionPool: - return self.createPool() + return self.create_pool() async def __aexit__( self, @@ -22,17 +22,17 @@ async def __aexit__( exc: Optional[BE], traceback: Optional[TracebackType], ) -> None: - if self.connPool is not None: - await self.connPool.disconnect() + if self.pool is not None: + await self.pool.disconnect() - def createPool(self) -> ConnectionPool: - completeURI = URL(self.uri) % {"decode_responses": "True"} - self.connPool = ConnectionPool(max_connections=self.max_size).from_url( - str(completeURI) + def create_pool(self) -> ConnectionPool: + complete_uri = URL(self.uri) % {"decode_responses": "True"} + self.pool = ConnectionPool(max_connections=self.max_size).from_url( + str(complete_uri) ) - return self.connPool + return self.pool - def getConnPool(self) -> ConnectionPool: - if not self.connPool: - return self.createPool() - return self.connPool + def get_conn_pool(self) -> ConnectionPool: + if not self.pool: + return self.create_pool() + return self.pool diff --git a/Bot/Libs/cache/decorators.py b/Bot/Libs/cache/decorators.py index 267af84b..e2a5e64a 100644 --- a/Bot/Libs/cache/decorators.py +++ b/Bot/Libs/cache/decorators.py @@ -4,7 +4,7 @@ from redis.asyncio.connection import ConnectionPool -from .redis_cache import CommandKeyBuilder, KumikoCache +from .redis_cache import KumikoCache, command_key_builder class cache: @@ -47,20 +47,20 @@ async def deco( cache = KumikoCache(connection_pool=redis_pool) key = self.key if key is None: - key = CommandKeyBuilder( + key = command_key_builder( prefix="cache", namespace="kumiko", id=id or uuid.uuid4(), command=self.name or func.__name__, ) - if await cache.cacheExists(key=key) is False: - await cache.setBasicCache(key=key, value=res, ttl=self.ttl) + if await cache.cache_exists(key=key) is False: + await cache.set_basic_cache(key=key, value=res, ttl=self.ttl) return res - return await cache.getBasicCache(key=key) + return await cache.get_basic_cache(key=key) -class cacheJson: +class cache_json: """ A decorator to cache the result of a function that returns a `dict` to Redis. @@ -106,14 +106,14 @@ async def deco( cache = KumikoCache(connection_pool=redis_pool) key = self.key if key is None: - key = CommandKeyBuilder( + key = command_key_builder( prefix="cache", namespace="kumiko", id=id or uuid.uuid4(), command=self.name or func.__name__, ) - if await cache.cacheExists(key=key) is False: - await cache.setJSONCache(key=key, value=res, ttl=self.ttl) + if await cache.cache_exists(key=key) is False: + await cache.set_json_cache(key=key, value=res, ttl=self.ttl) return res - return await cache.getJSONCache(key=key, path=self.path) + return await cache.get_json_cache(key=key, path=self.path) diff --git a/Bot/Libs/cache/key_builder.py b/Bot/Libs/cache/key_builder.py index d585a4a7..93526d47 100644 --- a/Bot/Libs/cache/key_builder.py +++ b/Bot/Libs/cache/key_builder.py @@ -2,7 +2,7 @@ from typing import Optional, Union -def CommandKeyBuilder( +def command_key_builder( prefix: Optional[str] = None, namespace: Optional[str] = None, id: Optional[Union[int, uuid.UUID]] = None, diff --git a/Bot/Libs/cache/redis_cache.py b/Bot/Libs/cache/redis_cache.py index edaff8ab..d192d5d0 100644 --- a/Bot/Libs/cache/redis_cache.py +++ b/Bot/Libs/cache/redis_cache.py @@ -1,10 +1,10 @@ from typing import Any, Dict, Optional, Union +import msgspec import redis.asyncio as redis -from Libs.utils import encodeDatetime from redis.asyncio.connection import ConnectionPool -from .key_builder import CommandKeyBuilder +from .key_builder import command_key_builder class KumikoCache: @@ -13,7 +13,7 @@ class KumikoCache: def __init__(self, connection_pool: ConnectionPool) -> None: self.connection_pool = connection_pool - async def setBasicCache( + async def set_basic_cache( self, key: Optional[str], value: Union[str, bytes] = "", @@ -21,17 +21,19 @@ async def setBasicCache( ) -> None: """Sets the command cache on Redis Args: - key (Optional[str], optional): Key to set on Redis. Defaults to `CommandKeyBuilder(prefix="cache", namespace="kumiko", user_id=None, command=None)`. + key (Optional[str], optional): Key to set on Redis. Defaults to `command_key_builder(prefix="cache", namespace="kumiko", user_id=None, command=None)`. value (Union[str, bytes, dict]): Value to set on Redis. Defaults to None. ttl (Optional[int], optional): TTL for the key-value pair. Defaults to 30. """ - defaultKey = CommandKeyBuilder( + default_key = command_key_builder( prefix="cache", namespace="kumiko", id=None, command=None ) conn: redis.Redis = redis.Redis(connection_pool=self.connection_pool) - await conn.set(name=key if key is not None else defaultKey, value=value, ex=ttl) + await conn.set( + name=key if key is not None else default_key, value=value, ex=ttl + ) - async def getBasicCache(self, key: str) -> Union[str, None]: + async def get_basic_cache(self, key: str) -> Union[str, None]: """Gets the command cache from Redis Args: @@ -41,32 +43,39 @@ async def getBasicCache(self, key: str) -> Union[str, None]: res = await conn.get(key) return res - async def setJSONCache( + async def delete_basic_cache(self, key: str) -> None: + """Deletes the command cache from Redis + + Args: + key (str): Key to use + """ + conn: redis.Redis = redis.Redis(connection_pool=self.connection_pool) + await conn.delete(key) + + async def set_json_cache( self, key: str, value: Union[Dict[str, Any], Any], path: str = "$", - ttl: Union[int, None] = 5, + ttl: Union[int, None] = None, ) -> None: """Sets the JSON cache on Redis Args: key (str): The key to use for Redis value (Union[Dict[str, Any], Any]): The value of the key-pair value - path (str): The path to look for or set. Defautls to "$" - ttl (Union[int, None], optional): TTL of the key-value pair. If None, then the TTL will not be set. Defaults to 5. + path (str): The path to look for or set. Defaults to "$" + ttl (Union[int, None], optional): TTL of the key-value pair. If None, then the TTL will not be set. Defaults to None. """ client: redis.Redis = redis.Redis(connection_pool=self.connection_pool) - await client.json().set( - name=key, - path=path, - obj=encodeDatetime(value) if isinstance(value, dict) else value, + await client.json(encoder=msgspec.json, decoder=msgspec.json).set( + name=key, path=path, obj=value ) if isinstance(ttl, int): await client.expire(name=key, time=ttl) # The output type comes from here: https://github.com/redis/redis-py/blob/9f503578d1ffed20d63e8023bcd8a7dccd15ecc5/redis/commands/json/_util.py#L3C1-L3C73 - async def getJSONCache( + async def get_json_cache( self, key: str, path: str = "$", value_only: bool = True ) -> Union[None, Dict[str, Any], Any]: """Gets the JSON cache on Redis @@ -74,20 +83,22 @@ async def getJSONCache( Args: key (str): The key of the key-value pair to get path (str): The path to obtain the value from. Defaults to "$" (aka the root) - value_only (bool): Whether to return the value only. Defaults to True + value_only (bool): Whether to return the value only. This is really only useful when using root paths. Defaults to True Returns: Dict[str, Any]: The value of the key-value pair """ client: redis.Redis = redis.Redis(connection_pool=self.connection_pool) - value = await client.json().get(key, path) + value = await client.json(encoder=msgspec.json, decoder=msgspec.json).get( + key, path + ) if value is None: return None if value_only is True: return value[0] if isinstance(value, list) else value return value - async def deleteJSONCache(self, key: str, path: str = "$") -> None: + async def delete_json_cache(self, key: str, path: str = "$") -> None: """Deletes the JSON cache at key `key` and under `path` Args: @@ -97,29 +108,29 @@ async def deleteJSONCache(self, key: str, path: str = "$") -> None: client: redis.Redis = redis.Redis(connection_pool=self.connection_pool) await client.json().delete(key=key, path=path) - async def mergeJSONCache( + async def merge_json_cache( self, key: str, value: Union[Dict, Any], path: str = "$", - ttl: Union[int, None] = 30, + ttl: Union[int, None] = None, ) -> None: """Merges the key and value into a new value - This is the fix from using setJSONCache all of the time + This is the fix from using set_json_cache all of the time Args: key (str): Key to look for value (Union[Dict, Any]): Value to update path (str): The path to update. Defaults to "$" - ttl (int): TTL. Usually leave this for perma cache. Defaults to 30 seconds. + ttl (int): TTL. Usually leave this for perma cache. Defaults to None. """ client: redis.Redis = redis.Redis(connection_pool=self.connection_pool) - await client.json().merge(name=key, path=path, obj=value) # type: ignore + await client.json(encoder=msgspec.json, decoder=msgspec.json).merge(name=key, path=path, obj=value) # type: ignore if isinstance(ttl, int): await client.expire(name=key, time=ttl) - async def cacheExists(self, key: str) -> bool: + async def cache_exists(self, key: str) -> bool: """Checks to make sure if the cache exists Args: @@ -129,5 +140,5 @@ async def cacheExists(self, key: str) -> bool: bool: Whether the key exists or not """ client: redis.Redis = redis.Redis(connection_pool=self.connection_pool) - keyExists = await client.exists(key) >= 1 - return True if keyExists else False + key_exists = await client.exists(key) >= 1 + return True if key_exists else False diff --git a/Bot/Libs/cog_utils/auctions/__init__.py b/Bot/Libs/cog_utils/auctions/__init__.py new file mode 100644 index 00000000..147d77cb --- /dev/null +++ b/Bot/Libs/cog_utils/auctions/__init__.py @@ -0,0 +1,20 @@ +from .crud_utils import ( + add_more_to_auction, + create_auction, + delete_auction, + obtain_item_info, + purchase_auction, +) +from .flags import ListingFlag, PurchasingFlag +from .format_utils import format_options + +__all__ = [ + "create_auction", + "delete_auction", + "ListingFlag", + "add_more_to_auction", + "format_options", + "obtain_item_info", + "purchase_auction", + "PurchasingFlag", +] diff --git a/Bot/Libs/cog_utils/auctions/crud_utils.py b/Bot/Libs/cog_utils/auctions/crud_utils.py new file mode 100644 index 00000000..f37115bb --- /dev/null +++ b/Bot/Libs/cog_utils/auctions/crud_utils.py @@ -0,0 +1,305 @@ +from typing import Any, Dict, List, Optional, Union + +import asyncpg + + +async def is_auction_valid( + rows: Dict[str, Any], + user_id: int, + requested_amount: int, + conn: asyncpg.connection.Connection, +) -> bool: + query = """ + SELECT petals + FROM eco_user + WHERE id = $1; + """ + + petals = await conn.fetchval(query, user_id) # type: ignore # We have to suppress this since asyncpg is not typed + if petals is None: + return False + + amount_owned = rows["amount_owned"] + return (petals >= 5) and (requested_amount <= amount_owned) + + +async def have_enough_funds( + rows: Dict[str, Any], user_id: int, amount: int, conn: asyncpg.connection.Connection +) -> bool: + query = """ + SELECT petals + FROM eco_user + WHERE id = $1; + """ + + petals = await conn.fetchval(query, user_id) # type: ignore # We have to suppress this since asyncpg is not typed + if petals is None: + return False + + amount_listed = rows["amount_listed"] + total_price = rows["listed_price"] * amount + return (petals >= total_price) and (amount <= amount_listed) + + +async def purchase_auction( + guild_id: int, + user_id: int, + name: Optional[str], + item_id: Optional[int], + amount: int, + pool: asyncpg.Pool, +) -> str: + find_item_and_info = """ + SELECT eco_item.id, eco_item.name, auction_house.user_id, auction_house.amount_listed, auction_house.listed_price + FROM auction_house + INNER JOIN eco_item ON eco_item.id = auction_house.item_id + WHERE auction_house.guild_id = $1 AND item_id = $2 OR eco_item.name = $3; + """ + update_item_stock = """ + UPDATE auction_house + SET amount_listed = $4 + WHERE guild_id = $1 AND user_id = $2 AND item_id = $3; + """ + send_back_to_inv = """ + WITH item_remove AS ( + DELETE FROM auction_house + WHERE amount_listed <= 0 AND guild_id = $1 AND user_id = $2 AND item_id = $3 + ) + INSERT INTO user_inv (owner_id, guild_id, amount_owned, item_id) + VALUES ($2, $1, $4, $3) + ON CONFLICT (owner_id, item_id) DO UPDATE + SET amount_owned = user_inv.amount_owned + $4; + """ + update_balance_query = """ + UPDATE eco_user + SET petals = petals + $2 + WHERE id = $1; + """ + update_purchaser_query = """ + UPDATE eco_user + SET petals = petals - $2 + WHERE id = $1; + """ + async with pool.acquire() as conn: + item_rows = await conn.fetchrow( + find_item_and_info, guild_id, item_id, name or None + ) + if item_rows is None: + return "The item that you are trying to buy doesn't exist" + records = dict(item_rows) + total_price = records["listed_price"] * amount + current_stock = records["amount_listed"] - amount + if records["user_id"] == user_id: + return "You can't buy your own listed item. Once it is listed, you can only update or delete it." + if await have_enough_funds(records, user_id, amount, conn): + async with conn.transaction(): + await conn.execute( + update_item_stock, guild_id, user_id, records["id"], current_stock + ) + await conn.execute( + send_back_to_inv, guild_id, user_id, records["id"], amount + ) + await conn.execute(update_balance_query, user_id, total_price) + await conn.execute(update_purchaser_query, user_id, total_price) + return f"Successfully bought `{records['name']}` for `{total_price}` petal(s)" + else: + return "You either have requested too much or you don't have the funds to make the purchase" + + +async def create_auction( + guild_id: int, + user_id: int, + amount_requested: int, + item_id: Optional[int], + item_name: Optional[str], + pool: asyncpg.Pool, +) -> str: + take_from_base_fee = """ + UPDATE eco_user + SET petals = petals - $2 + WHERE id = $1; + """ + get_item_from_inv = """ + SELECT eco_item.price, user_inv.guild_id, user_inv.owner_id, user_inv.amount_owned, user_inv.item_id + FROM eco_item + INNER JOIN user_inv ON user_inv.item_id = eco_item.id + WHERE user_inv.owner_id = $1 AND user_inv.guild_id = $2 AND eco_item.name = $3 OR eco_item.id = $4; + """ + insert_into_auctions = """ + WITH deplete_from_inv AS ( + UPDATE user_inv + SET amount_owned = amount_owned - $4 + WHERE owner_id = $1 AND guild_id = $2 AND item_id = $3 + RETURNING id + ) + INSERT INTO auction_house (item_id, user_id, guild_id, amount_listed, listed_price) + VALUES ($3, $1, $2, $4, $5) + ON CONFLICT (item_id, user_id) DO UPDATE + SET amount_listed = auction_house.amount_listed + $4; + """ + async with pool.acquire() as conn: + rows = await conn.fetchrow( + get_item_from_inv, user_id, guild_id, item_name, item_id + ) + if rows is None: + return "The item that you are trying to list is not in your inventory" + records = dict(rows) + listed_price = records["price"] + await conn.execute(take_from_base_fee, user_id, 5) + if await is_auction_valid(records, user_id, amount_requested, conn): + async with conn.transaction(): + status = await conn.execute( + insert_into_auctions, + user_id, + guild_id, + records["item_id"], + amount_requested, + listed_price, + ) + if status[-1] != "0": + return "Successfully listed your item into the auction house" + else: + return "Successfully updated your listing" + else: + return "You either do not have enough petals (5 is required to list) or you own less than what you are requesting to list" + + +async def delete_auction( + guild_id: int, + user_id: int, + pool: asyncpg.Pool, + item_name: Optional[str] = None, + item_id: Optional[int] = None, +) -> str: + get_name_from_id = """ + SELECT id FROM eco_item + WHERE name = $1 AND guild_id = $2; + """ + + get_item_info = """ + SELECT auction_house.id, auction_house.item_id, auction_house.amount_listed, auction_house.user_id, user_inv.amount_owned + FROM auction_house + INNER JOIN user_inv ON user_inv.item_id = auction_house.item_id AND user_inv.owner_id = auction_house.user_id + WHERE auction_house.user_id = $1 AND auction_house.guild_id = $2 AND auction_house.item_id = $3; + """ + recreate_if_not_found = """ + INSERT INTO user_inv (owner_id, guild_id, item_id, amount_owned) + VALUES ($1, $2, $3, $4) + ON CONFLICT (owner_id, item_id) DO UPDATE + SET amount_owned = user_inv.amount_owned + $4; + """ + delete_from_ah = """ + DELETE FROM auction_house + WHERE id = $1 AND user_id = $2 AND guild_id = $3; + """ + + async with pool.acquire() as conn: + idx = await conn.fetchval(get_name_from_id, item_name, guild_id) + rows = await conn.fetchrow(get_item_info, user_id, guild_id, item_id or idx) + if rows is None: + return "Item not found" + records = dict(rows) + general_item_id = records["item_id"] + if idx is not None: + general_item_id = idx + if user_id != records["user_id"]: + async with conn.transaction(): + await conn.execute( + recreate_if_not_found, + user_id, + guild_id, + general_item_id, + records["amount_listed"], + ) + await conn.execute(delete_from_ah, records["id"], user_id, guild_id) + return "Successfully deleted your listing" + else: + return "You aren't the owner!" + + +async def add_more_to_auction( + guild_id: int, + user_id: int, + pool: asyncpg.Pool, + amount_requested: int, + item_name: Optional[str] = None, + item_id: Optional[int] = None, +) -> str: + get_name_from_id = """ + SELECT id FROM eco_item + WHERE name = $1 AND guild_id = $2; + """ + + get_item_from_inv = """ + SELECT user_inv.amount_owned, user_inv.item_id, auction_house.amount_listed, auction_house.user_id + FROM auction_house + INNER JOIN user_inv ON user_inv.item_id = auction_house.item_id AND user_inv.owner_id = auction_house.user_id + WHERE auction_house.user_id = $1 AND auction_house.guild_id = $2 AND auction_house.item_id = $3; + """ + update_auction_amount = """ + UPDATE auction_house + SET amount_listed = $4 + WHERE user_id = $1 AND guild_id = $2 AND item_id = $3; + """ + subtract_from_inv = """ + UPDATE user_inv + SET amount_owned = amount_owned - $4 + WHERE owner_id = $1 AND guild_id = $2 AND item_id = $3; + """ + async with pool.acquire() as conn: + idx = item_id + # This is sus + if item_id is None: + id_val = await conn.fetchval(get_name_from_id, item_name, guild_id) + if id_val is not None: + idx = id_val + + get_info = await conn.fetchrow(get_item_from_inv, user_id, guild_id, idx) + if get_info is None: + return "Item not found" + + records = dict(get_info) + + if amount_requested > records["amount_owned"]: + return "You dont have enough" + + if user_id != records["user_id"]: + async with conn.transaction(): + await conn.execute( + update_auction_amount, user_id, guild_id, idx, amount_requested + ) + await conn.execute( + subtract_from_inv, user_id, guild_id, idx, amount_requested + ) + return "Successfully added more" + else: + return "You don't own this item" + + +async def obtain_item_info( + guild_id: int, name: str, pool: asyncpg.Pool +) -> Union[Dict[str, Any], List[Dict[str, Any]], None]: + sql = """ + SELECT eco_item.name, eco_item.description, auction_house.user_id, auction_house.amount_listed, auction_house.listed_price, auction_house.listed_at + FROM auction_house + INNER JOIN eco_item ON eco_item.name = $2 + WHERE auction_house.guild_id = $1; + """ + async with pool.acquire() as conn: + res = await conn.fetchrow(sql, guild_id, name.lower()) + if res is None: + query = """ + SELECT eco_item.name + FROM auction_house + INNER JOIN eco_item ON eco_item.name % $2 + WHERE auction_house.guild_id=$1 + ORDER BY similarity(eco_item.name, $2) DESC + LIMIT 5; + """ + new_res = await conn.fetch(query, guild_id, name.lower()) + if new_res is None or len(new_res) == 0: + return None + + return [dict(row) for row in new_res] + + return dict(res) diff --git a/Bot/Libs/cog_utils/auctions/flags.py b/Bot/Libs/cog_utils/auctions/flags.py new file mode 100644 index 00000000..fcc6b9a5 --- /dev/null +++ b/Bot/Libs/cog_utils/auctions/flags.py @@ -0,0 +1,13 @@ +from discord.ext import commands + + +class ListingFlag(commands.FlagConverter): + amount: int = commands.flag( + default=1, aliases=["a"], description="The amount of items to list" + ) + + +class PurchasingFlag(commands.FlagConverter): + amount: int = commands.flag( + default=1, aliases=["a"], description="The amount of items you wish to purchase" + ) diff --git a/Bot/Libs/cog_utils/auctions/format_utils.py b/Bot/Libs/cog_utils/auctions/format_utils.py new file mode 100644 index 00000000..e540369b --- /dev/null +++ b/Bot/Libs/cog_utils/auctions/format_utils.py @@ -0,0 +1,17 @@ +from typing import Dict, List, Union + + +def format_options(rows: Union[List[Dict[str, str]], None]) -> str: + """Format the rows to be sent to the user + + Args: + rows (Union[List[Dict[str, str]], None]): Rows to format + + Returns: + str: _Formatted string + """ + if rows is None or len(rows) == 0: + return "Item not found" + + names = "\n".join([row["name"] for row in rows]) + return f"AH Item not found. Did you mean:\n{names}" diff --git a/Bot/Libs/cog_utils/dictionary/__init__.py b/Bot/Libs/cog_utils/dictionary/__init__.py new file mode 100644 index 00000000..9b58e272 --- /dev/null +++ b/Bot/Libs/cog_utils/dictionary/__init__.py @@ -0,0 +1,41 @@ +from typing import Dict, List, Optional + +from attrs import define + + +@define +class EnglishDef: + definition: str + synonyms: List[str] + antonyms: List[str] + example: str + + +@define +class EnglishDictEntry: + word: str + phonetics: List[Dict[str, str]] + part_of_speech: str + definitions: List[EnglishDef] + + +@define +class JapaneseEntryDef: + english_definitions: List[str] + parts_of_speech: List[str] + tags: List[str] + + +@define +class JapaneseWordEntry: + word: Optional[str] + reading: Optional[str] + + +@define +class JapaneseDictEntry: + word: List[JapaneseWordEntry] + definitions: List[JapaneseEntryDef] + is_common: Optional[bool] + tags: Optional[List[str]] + jlpt: Optional[List[str]] diff --git a/Bot/Libs/cog_utils/economy/__init__.py b/Bot/Libs/cog_utils/economy/__init__.py index e05ec218..94504057 100644 --- a/Bot/Libs/cog_utils/economy/__init__.py +++ b/Bot/Libs/cog_utils/economy/__init__.py @@ -1,4 +1,12 @@ from .checks import check_economy_enabled, is_economy_enabled -from .flags import ItemFlags, PurchaseFlags +from .flags import ItemFlags, PurchaseFlags, RefundFlags +from .utils import refund_item -__all__ = ["is_economy_enabled", "check_economy_enabled", "ItemFlags", "PurchaseFlags"] +__all__ = [ + "is_economy_enabled", + "check_economy_enabled", + "ItemFlags", + "PurchaseFlags", + "RefundFlags", + "refund_item", +] diff --git a/Bot/Libs/cog_utils/economy/checks.py b/Bot/Libs/cog_utils/economy/checks.py index f8a441b7..53395e59 100644 --- a/Bot/Libs/cog_utils/economy/checks.py +++ b/Bot/Libs/cog_utils/economy/checks.py @@ -1,18 +1,18 @@ from discord.ext import commands from Libs.config import get_or_fetch_guild_config -from Libs.errors import EconomyDisabled +from Libs.errors import EconomyDisabledError async def check_economy_enabled(ctx: commands.Context): if ctx.guild is None: - raise EconomyDisabled + raise EconomyDisabledError res = await get_or_fetch_guild_config( ctx.guild.id, ctx.bot.pool, ctx.bot.redis_pool ) if res is None: - raise EconomyDisabled + raise EconomyDisabledError elif res["local_economy"] is False: - raise EconomyDisabled + raise EconomyDisabledError return res["local_economy"] diff --git a/Bot/Libs/cog_utils/economy/flags.py b/Bot/Libs/cog_utils/economy/flags.py index c8ae5e42..40d789dd 100644 --- a/Bot/Libs/cog_utils/economy/flags.py +++ b/Bot/Libs/cog_utils/economy/flags.py @@ -14,3 +14,13 @@ class PurchaseFlags(commands.FlagConverter): amount: int = commands.flag( aliases=["a"], default=1, description="The amount of items to purchase" ) + + +class RefundFlags(commands.FlagConverter): + name: str = commands.flag( + aliases=["n"], + description="The name of the item to refund. You must currently own it.", + ) + amount: int = commands.flag( + aliases=["a"], default=1, description="The amount of items to refund" + ) diff --git a/Bot/Libs/cog_utils/economy/utils.py b/Bot/Libs/cog_utils/economy/utils.py new file mode 100644 index 00000000..e7827cf3 --- /dev/null +++ b/Bot/Libs/cog_utils/economy/utils.py @@ -0,0 +1,47 @@ +import asyncpg + + +async def refund_item( + guild_id: int, user_id: int, item_id: int, amount: int, pool: asyncpg.Pool +): + sql = """ + SELECT eco_item.name, eco_item.price, user_inv.owner_id, user_inv.amount_owned, user_inv.item_id + FROM user_inv + INNER JOIN eco_item ON eco_item.id = user_inv.item_id + WHERE user_inv.owner_id = $1 AND user_inv.guild_id = $2 AND eco_item.id = $3; + """ + subtract_owned_items = """ + UPDATE user_inv + SET amount_owned = amount_owned - $1 + WHERE owner_id = $2 AND guild_id = $3 AND item_id = $4; + """ + add_back_items_to_stock = """ + UPDATE eco_item + SET amount = amount + $1 + WHERE id = $2; + """ + add_back_price = """ + UPDATE eco_user + SET petals = petals + $1 + WHERE id = $2; + """ + async with pool.acquire() as conn: + rows = await conn.fetchrow(sql, user_id, guild_id, item_id) + if rows is None: + # idk probably return str status + return "User does not own this item!" + records = dict(rows) + refund_price = ((records["price"] * amount) / 4) * 3 + if amount > records["amount_owned"]: + return "User does not own that many items!" + async with conn.transaction(): + await conn.execute( + subtract_owned_items, + amount, + user_id, + guild_id, + records["item_id"], + ) + await conn.execute(add_back_items_to_stock, amount, records["item_id"]) + await conn.execute(add_back_price, refund_price, user_id) + return "Successfully refunded your item!" diff --git a/Bot/Libs/cog_utils/events_log/__init__.py b/Bot/Libs/cog_utils/events_log/__init__.py index a4b80ab5..734ac4fc 100644 --- a/Bot/Libs/cog_utils/events_log/__init__.py +++ b/Bot/Libs/cog_utils/events_log/__init__.py @@ -1,6 +1,6 @@ from .cache_utils import ( - delete_cache, disable_logging, + get_or_fetch_channel_id, get_or_fetch_config, get_or_fetch_log_enabled, set_or_update_cache, @@ -10,8 +10,8 @@ __all__ = [ "get_or_fetch_config", "set_or_update_cache", - "delete_cache", "disable_logging", "get_or_fetch_log_enabled", "EventsFlag", + "get_or_fetch_channel_id", ] diff --git a/Bot/Libs/cog_utils/events_log/cache_utils.py b/Bot/Libs/cog_utils/events_log/cache_utils.py index 6b900389..bbe009f9 100644 --- a/Bot/Libs/cog_utils/events_log/cache_utils.py +++ b/Bot/Libs/cog_utils/events_log/cache_utils.py @@ -17,13 +17,10 @@ async def get_or_fetch_config( ON guild.id = logging_config.guild_id WHERE guild.id = $1; """ - # async with pool.acquire() as conn: - # res = await conn.fetchrow(query, id) - # return dict(res) key = f"cache:kumiko:{id}:guild_config" cache = KumikoCache(redis_pool) - if await cache.cacheExists(key=key): - res = await cache.getJSONCache(key=key, path="$.logging_config") + if await cache.cache_exists(key=key): + res = await cache.get_json_cache(key=key, path=".logging_config") return res else: rows = await pool.fetchrow(query, id) @@ -42,8 +39,8 @@ async def get_or_fetch_log_enabled( """ key = f"cache:kumiko:{id}:guild_config" cache = KumikoCache(redis_pool) - if await cache.cacheExists(key=key): - res = await cache.getJSONCache(key=key, path="$.logs") + if await cache.cache_exists(key=key): + res = await cache.get_json_cache(key=key, path=".logs", value_only=False) return res # type: ignore else: val = await pool.fetchval(query, id) @@ -52,27 +49,44 @@ async def get_or_fetch_log_enabled( return val +async def get_or_fetch_channel_id( + guild_id: int, pool: asyncpg.Pool, redis_pool: ConnectionPool +) -> Union[str, None]: + query = """ + SELECT logging_config.channel_id + FROM logging_config + WHERE guild_id = $1; + """ + key = f"cache:kumiko:{guild_id}:logging_channel_id" + cache = KumikoCache(redis_pool) + if await cache.cache_exists(key=key): + res = await cache.get_basic_cache(key=key) + return res + else: + val = await pool.fetchval(query, guild_id) + if val is None: + return None + await cache.set_basic_cache(key=key, value=val, ttl=3600) + return val + + async def set_or_update_cache( key: str, redis_pool: ConnectionPool, data: Dict[str, Any] ) -> None: cache = KumikoCache(connection_pool=redis_pool) - if not await cache.cacheExists(key=key): - await cache.setJSONCache(key=key, value=data, ttl=None) + if not await cache.cache_exists(key=key): + await cache.set_json_cache(key=key, value=data, ttl=None) else: - await cache.setJSONCache( + await cache.set_json_cache( key=key, value=data["channel_id"], path=".channel_id", ttl=None ) -async def delete_cache(key: str, redis_pool: ConnectionPool) -> None: - cache = KumikoCache(connection_pool=redis_pool) - if await cache.cacheExists(key=key): - await cache.deleteJSONCache(key=key) - - async def disable_logging(guild_id: int, redis_pool: ConnectionPool) -> None: key = f"cache:kumiko:{guild_id}:guild_config" cache = KumikoCache(connection_pool=redis_pool) - # lgc = LoggingGuildConfig(channel_id=None) - await cache.mergeJSONCache(key=key, value=False, path="$.logs") - await cache.mergeJSONCache(key=key, value=None, path="$.logging_config") + await cache.delete_basic_cache(key=f"cache:kumiko:{guild_id}:logging_channel_id") + await cache.merge_json_cache(key=key, value=False, path=".logs", ttl=None) + await cache.merge_json_cache( + key=key, value=None, path=".logging_config.channel_id", ttl=None + ) diff --git a/Bot/Libs/cog_utils/events_log/flags.py b/Bot/Libs/cog_utils/events_log/flags.py index 6423eead..64323f3b 100644 --- a/Bot/Libs/cog_utils/events_log/flags.py +++ b/Bot/Libs/cog_utils/events_log/flags.py @@ -2,19 +2,6 @@ class EventsFlag(commands.FlagConverter): - member: bool = commands.flag( - default=True, - aliases=["member_events"], - description="Whether to enable member events", - ) - mod: bool = commands.flag( - default=True, aliases=["mod_events"], description="Whether to enable mod events" - ) - eco: bool = commands.flag( - default=True, - aliases=["eco_events"], - description="Whether to enable economy events", - ) all: bool = commands.flag( default=False, override=True, description="Whether to enable all events" ) diff --git a/Bot/Libs/cog_utils/jobs/__init__.py b/Bot/Libs/cog_utils/jobs/__init__.py index a63cd912..e4f6a010 100644 --- a/Bot/Libs/cog_utils/jobs/__init__.py +++ b/Bot/Libs/cog_utils/jobs/__init__.py @@ -1,22 +1,22 @@ from .flags import JobListFlags, JobOutputFlags -from .format_options import formatOptions +from .format_options import format_job_options from .job_utils import ( - createJob, - createJobLink, - createJobOutputItem, - getJob, - submitJobApp, - updateJob, + create_job, + create_job_link, + create_job_output_item, + get_job, + submit_job_app, + update_job, ) __all__ = [ - "createJob", - "updateJob", - "submitJobApp", - "formatOptions", - "getJob", + "create_job", + "update_job", + "submit_job_app", + "format_job_options", + "get_job", "JobOutputFlags", - "createJobOutputItem", - "createJobLink", + "create_job_output_item", + "create_job_link", "JobListFlags", ] diff --git a/Bot/Libs/cog_utils/jobs/format_options.py b/Bot/Libs/cog_utils/jobs/format_options.py index ec9527e9..935c9d3d 100644 --- a/Bot/Libs/cog_utils/jobs/format_options.py +++ b/Bot/Libs/cog_utils/jobs/format_options.py @@ -1,7 +1,7 @@ from typing import Dict, List, Union -def formatOptions(rows: Union[List[Dict[str, str]], None]) -> str: +def format_job_options(rows: Union[List[Dict[str, str]], None]) -> str: """Format the rows to be sent to the user Args: diff --git a/Bot/Libs/cog_utils/jobs/job_utils.py b/Bot/Libs/cog_utils/jobs/job_utils.py index 5c2efbbf..a8fdc42f 100644 --- a/Bot/Libs/cog_utils/jobs/job_utils.py +++ b/Bot/Libs/cog_utils/jobs/job_utils.py @@ -12,7 +12,7 @@ class JobResults(TypedDict): listed: bool -async def createJob( +async def create_job( author_id: int, guild_id: int, pool: asyncpg.Pool, @@ -48,7 +48,7 @@ async def createJob( return f"Job {name} successfully created" -async def updateJob( +async def update_job( author_id: int, guild_id: int, pool: asyncpg.Pool, @@ -68,7 +68,7 @@ async def updateJob( return status -async def submitJobApp( +async def submit_job_app( owner_id: Union[int, None], guild_id: int, name: str, @@ -89,7 +89,7 @@ async def submitJobApp( tr = connection.transaction() await tr.start() try: - await connection.execute(query, owner_id, guild_id, name.lower(), listed_status) # type: ignore + await connection.execute(query, owner_id, guild_id, name.lower(), listed_status) except asyncpg.UniqueViolationError: await tr.rollback() return "The job is already taken. Please apply for another one" @@ -98,7 +98,7 @@ async def submitJobApp( return f"Successfully {'quit' if listed_status is True else 'applied'} the job!" -async def getJob( +async def get_job( id: int, job_name: str, pool: asyncpg.Pool ) -> Union[Dict, List[Dict[str, str]], None]: """Gets a job from the database. @@ -111,14 +111,14 @@ async def getJob( Returns: Union[str, None]: The job details or None if it doesn't exist """ - sqlQuery = """ + query = """ SELECT job.id, job.name, job.description, job.required_rank, job.pay_amount, job_lookup.listed FROM job_lookup INNER JOIN job ON job.id = job_lookup.job_id WHERE job_lookup.guild_id=$1 AND LOWER(job_lookup.name)=$2; """ async with pool.acquire() as conn: - res = await conn.fetchrow(sqlQuery, id, job_name) + res = await conn.fetchrow(query, id, job_name) if res is None: query = """ SELECT job_lookup.name @@ -127,15 +127,15 @@ async def getJob( ORDER BY similarity(job_lookup.name, $2) DESC LIMIT 5; """ - newRes = await conn.fetch(query, id, job_name) - if newRes is None or len(newRes) == 0: + new_res = await conn.fetch(query, id, job_name) + if new_res is None or len(new_res) == 0: return None - return [dict(row) for row in newRes] + return [dict(row) for row in new_res] return dict(res) -async def createJobLink( +async def create_job_link( worker_id: int, item_id: int, job_id: int, conn: asyncpg.connection.Connection ): sql = """ @@ -146,7 +146,7 @@ async def createJobLink( return status -async def createJobOutputItem( +async def create_job_output_item( name: str, description: str, price: int, @@ -156,7 +156,6 @@ async def createJobOutputItem( pool: asyncpg.Pool, ): # I have committed way too much sins - # TODO - Add an upsert in this area sql = """ WITH item_insert AS ( INSERT INTO eco_item (guild_id, name, description, price, amount, restock_amount, producer_id) @@ -164,7 +163,7 @@ async def createJobOutputItem( RETURNING id ) INSERT INTO eco_item_lookup (name, guild_id, producer_id, item_id) - VALUES ($2, $1, $7, (SELECT id FROM item_insert)) + VALUES ($2, $1, $7, (SELECT id FROM item_insert)); """ async with pool.acquire() as conn: tr = conn.transaction() diff --git a/Bot/Libs/cog_utils/marketplace/__init__.py b/Bot/Libs/cog_utils/marketplace/__init__.py index b778fe9e..7ff2c95c 100644 --- a/Bot/Libs/cog_utils/marketplace/__init__.py +++ b/Bot/Libs/cog_utils/marketplace/__init__.py @@ -1,3 +1,8 @@ -from .utils import createPurchasedItem, formatOptions, getItem, isPaymentValid +from .utils import create_purchase_item, format_item_options, get_item, is_payment_valid -__all__ = ["isPaymentValid", "getItem", "formatOptions", "createPurchasedItem"] +__all__ = [ + "is_payment_valid", + "get_item", + "format_item_options", + "create_purchase_item", +] diff --git a/Bot/Libs/cog_utils/marketplace/utils.py b/Bot/Libs/cog_utils/marketplace/utils.py index a2b28ce9..49196187 100644 --- a/Bot/Libs/cog_utils/marketplace/utils.py +++ b/Bot/Libs/cog_utils/marketplace/utils.py @@ -3,7 +3,7 @@ import asyncpg -async def getItem( +async def get_item( id: int, item_name: str, pool: asyncpg.Pool ) -> Union[Dict, List[Dict[str, str]], None]: """Gets a item from the database. @@ -16,14 +16,14 @@ async def getItem( Returns: Union[str, None]: The item details or None if it doesn't exist """ - sqlQuery = """ + query = """ SELECT eco_item.id, eco_item.name, eco_item.description, eco_item.price, eco_item.amount, eco_item.created_at, eco_item.producer_id FROM eco_item_lookup INNER JOIN eco_item ON eco_item.id = eco_item_lookup.item_id WHERE eco_item_lookup.guild_id=$1 AND LOWER(eco_item_lookup.name)=$2; """ async with pool.acquire() as conn: - res = await conn.fetchrow(sqlQuery, id, item_name) + res = await conn.fetchrow(query, id, item_name) if res is None: query = """ SELECT eco_item_lookup.name @@ -32,15 +32,15 @@ async def getItem( ORDER BY similarity(eco_item_lookup.name, $2) DESC LIMIT 5; """ - newRes = await conn.fetch(query, id, item_name) - if newRes is None or len(newRes) == 0: + new_res = await conn.fetch(query, id, item_name) + if new_res is None or len(new_res) == 0: return None - return [dict(row) for row in newRes] + return [dict(row) for row in new_res] return dict(res) -async def isPaymentValid( +async def is_payment_valid( rows: Dict[str, Any], purchaser_id: int, requested_amount: int, @@ -52,16 +52,18 @@ async def isPaymentValid( WHERE id = $1; """ - petals = await conn.fetchval(query, purchaser_id) + petals = await conn.fetchval(query, purchaser_id) # type: ignore # We have to suppress this since asyncpg is not typed if petals is None: return False - totalPrice = rows["price"] * requested_amount - stockAmt = rows["amount"] - return (petals >= totalPrice) and (requested_amount < stockAmt) and (stockAmt > 0) + total_price = rows["price"] * requested_amount + stock_amt = rows["amount"] + return ( + (petals >= total_price) and (requested_amount < stock_amt) and (stock_amt > 0) + ) -def formatOptions(rows: Union[List[Dict[str, str]], None]) -> str: +def format_item_options(rows: Union[List[Dict[str, str]], None]) -> str: """Format the rows to be sent to the user Args: @@ -77,7 +79,7 @@ def formatOptions(rows: Union[List[Dict[str, str]], None]) -> str: return f"Item not found. Did you mean:\n{names}" -async def createPurchasedItem( +async def create_purchase_item( guild_id: int, user_id: int, name: str, diff --git a/Bot/Libs/cog_utils/pins/__init__.py b/Bot/Libs/cog_utils/pins/__init__.py index 4dcf477d..7a497501 100644 --- a/Bot/Libs/cog_utils/pins/__init__.py +++ b/Bot/Libs/cog_utils/pins/__init__.py @@ -1,19 +1,19 @@ -from .format_options import formatOptions +from .format_options import format_options from .pin_utils import ( - createPin, - editPin, - getAllPins, - getOwnedPins, - getPinInfo, - getPinText, + create_pin, + edit_pin, + get_all_pins, + get_owned_pins, + get_pin_content, + get_pin_info, ) __all__ = [ - "getPinText", - "formatOptions", - "getPinInfo", - "createPin", - "editPin", - "getAllPins", - "getOwnedPins", + "get_pin_content", + "format_options", + "get_pin_info", + "create_pin", + "edit_pin", + "get_all_pins", + "get_owned_pins", ] diff --git a/Bot/Libs/cog_utils/pins/format_options.py b/Bot/Libs/cog_utils/pins/format_options.py index 20c385c0..4cdaf2a8 100644 --- a/Bot/Libs/cog_utils/pins/format_options.py +++ b/Bot/Libs/cog_utils/pins/format_options.py @@ -1,7 +1,7 @@ from typing import Dict, List, Union -def formatOptions(rows: Union[List[Dict[str, str]], None]) -> str: +def format_options(rows: Union[List[Dict[str, str]], None]) -> str: """Format the rows to be sent to the user Args: diff --git a/Bot/Libs/cog_utils/pins/pin_utils.py b/Bot/Libs/cog_utils/pins/pin_utils.py index 3ec6a1ad..9c5e53c3 100644 --- a/Bot/Libs/cog_utils/pins/pin_utils.py +++ b/Bot/Libs/cog_utils/pins/pin_utils.py @@ -3,7 +3,7 @@ import asyncpg -async def getPinText( +async def get_pin_content( id: int, pin_name: str, pool: asyncpg.Pool ) -> Union[str, List[Dict[str, str]], None]: """Gets a tag from the database. @@ -16,14 +16,14 @@ async def getPinText( Returns: Union[str, None]: The tag content or None if it doesn't exist """ - sqlQuery = """ + query = """ SELECT pin.content FROM pin_lookup INNER JOIN pin ON pin.id = pin_lookup.pin_id WHERE pin_lookup.guild_id=$1 AND LOWER(pin_lookup.name)=$2 OR LOWER($2) = ANY(aliases); """ async with pool.acquire() as conn: - res = await conn.fetchval(sqlQuery, id, pin_name) + res = await conn.fetchval(query, id, pin_name) if res is None: query = """ SELECT pin_lookup.name @@ -32,15 +32,15 @@ async def getPinText( ORDER BY similarity(pin_lookup.name, $2) DESC LIMIT 5; """ - newRes = await conn.fetch(query, id, pin_name) - if newRes is None or len(newRes) == 0: + new_res = await conn.fetch(query, id, pin_name) + if new_res is None or len(new_res) == 0: return None - return [dict(row) for row in newRes] + return [dict(row) for row in new_res] return res -async def getPinInfo(id: int, pin_name: str, pool: asyncpg.Pool) -> Union[Dict, None]: +async def get_pin_info(id: int, pin_name: str, pool: asyncpg.Pool) -> Union[Dict, None]: """Gets the info from an pin Args: @@ -63,7 +63,7 @@ async def getPinInfo(id: int, pin_name: str, pool: asyncpg.Pool) -> Union[Dict, return dict(res) -async def createPin( +async def create_pin( author_id: int, guild_id: int, pool: asyncpg.Pool, name: str, content: str ) -> str: """Creates a pin from the given info @@ -109,7 +109,7 @@ async def createPin( return f"Pin `{name}` successfully created" -async def editPin( +async def edit_pin( guild_id: int, author_id: int, pool: asyncpg.Pool, name: str, content: str ) -> str: query = """ @@ -117,13 +117,11 @@ async def editPin( SET content = $1 WHERE guild_id = $3 AND LOWER(pin.name) = $2 AND author_id = $4; """ - status = await pool.execute( - query, content, name, guild_id, author_id # type: ignore - ) + status = await pool.execute(query, content, name, guild_id, author_id) return status -async def getAllPins(guild_id: int, pool: asyncpg.Pool): +async def get_all_pins(guild_id: int, pool: asyncpg.Pool): query = """ SELECT pin.id, pin.name, pin_lookup.aliases, pin.content, pin.created_at, pin.author_id FROM pin_lookup @@ -137,7 +135,7 @@ async def getAllPins(guild_id: int, pool: asyncpg.Pool): return rows -async def getOwnedPins(author_id: int, guild_id: int, pool: asyncpg.Pool): +async def get_owned_pins(author_id: int, guild_id: int, pool: asyncpg.Pool): query = """ SELECT pin.name, pin.id FROM pin_lookup diff --git a/Bot/Libs/cog_utils/pronouns/__init__.py b/Bot/Libs/cog_utils/pronouns/__init__.py new file mode 100644 index 00000000..21506225 --- /dev/null +++ b/Bot/Libs/cog_utils/pronouns/__init__.py @@ -0,0 +1,15 @@ +from typing import List + + +def parse_pronouns(entry: List[str]): + pronouns = { + "he": "he/him", + "she": "she/her", + "it": "it/its", + "they": "they/them", + } + for idx, item in enumerate(entry): + if item in pronouns: + entry[idx] = pronouns[item] + + return ", ".join(entry).rstrip(",") diff --git a/Bot/Libs/cog_utils/redirects/__init__.py b/Bot/Libs/cog_utils/redirects/__init__.py new file mode 100644 index 00000000..c6623818 --- /dev/null +++ b/Bot/Libs/cog_utils/redirects/__init__.py @@ -0,0 +1,10 @@ +from .checks import is_redirects_enabled, is_thread +from .utils import can_close_threads, get_or_fetch_status, mark_as_resolved + +__all__ = [ + "is_thread", + "can_close_threads", + "mark_as_resolved", + "is_redirects_enabled", + "get_or_fetch_status", +] diff --git a/Bot/Libs/cog_utils/redirects/checks.py b/Bot/Libs/cog_utils/redirects/checks.py new file mode 100644 index 00000000..09ba0a0e --- /dev/null +++ b/Bot/Libs/cog_utils/redirects/checks.py @@ -0,0 +1,36 @@ +import discord +from discord.ext import commands +from Libs.errors import RedirectsDisabledError + +from .utils import get_or_fetch_status + + +def check_if_thread(ctx: commands.Context): + return isinstance(ctx.channel, discord.Thread) and not isinstance( + ctx.channel, discord.ForumChannel + ) + + +def is_thread(): + def pred(ctx: commands.Context): + return check_if_thread(ctx) + + return commands.check(pred) + + +async def check_redirects_enabled(ctx: commands.Context): + if ctx.guild is None: + raise RedirectsDisabledError + status = await get_or_fetch_status(ctx.guild.id, ctx.bot.pool, ctx.bot.redis_pool) + if status is None: + raise RedirectsDisabledError + elif status is False: + raise RedirectsDisabledError + return status + + +def is_redirects_enabled(): + async def pred(ctx: commands.Context): + return await check_redirects_enabled(ctx) + + return commands.check(pred) diff --git a/Bot/Libs/cog_utils/redirects/utils.py b/Bot/Libs/cog_utils/redirects/utils.py new file mode 100644 index 00000000..5e58c6ca --- /dev/null +++ b/Bot/Libs/cog_utils/redirects/utils.py @@ -0,0 +1,48 @@ +from typing import Union + +import asyncpg +import discord +from discord.ext import commands +from Libs.cache import KumikoCache +from redis.asyncio.connection import ConnectionPool + + +async def get_or_fetch_status( + guild_id: int, pool: asyncpg.Pool, redis_pool: ConnectionPool +) -> Union[bool, None]: + sql = """ + SELECT redirects + FROM guild + WHERE id = $1; + """ + key = f"cache:kumiko:{guild_id}:guild_config" + cache = KumikoCache(connection_pool=redis_pool) + if await cache.cache_exists(key=key): + status = await cache.get_json_cache( + key=key, path=".redirects", value_only=False + ) + return status # type: ignore + else: + value = await pool.fetchval(sql, guild_id) + if value is None: + return None + await cache.merge_json_cache(key=key, value=value, path="$.redirects", ttl=None) + return value + + +def can_close_threads(ctx: commands.Context): + if not isinstance(ctx.channel, discord.Thread): + return False + + permissions = ctx.channel.permissions_for(ctx.author) # type: ignore # discord.Member is a subclass of discord.User + return permissions.manage_threads or ctx.channel.owner_id == ctx.author.id + + +async def mark_as_resolved( + thread: discord.Thread, user: Union[discord.User, discord.Member] +) -> None: + await thread.edit( + locked=True, + archived=True, + reason=f"Marked as resolved by {user.global_name} (ID: {user.id})", + ) diff --git a/Bot/Libs/config/defaults.py b/Bot/Libs/config/defaults.py index 10d44424..d5573dd9 100644 --- a/Bot/Libs/config/defaults.py +++ b/Bot/Libs/config/defaults.py @@ -1,21 +1,20 @@ -from typing import Union - -from attrs import define, field - - -@define -class LoggingGuildConfig: - channel_id: Union[int, None] - member_events: bool = field(default=True) - mod_events: bool = field(default=True) - eco_events: bool = field(default=False) - - -@define -class GuildConfig: - id: int - logging_config: Union[LoggingGuildConfig, None] - logs: bool = field(default=True) - birthday: bool = field(default=False) - local_economy: bool = field(default=False) - local_economy_name: str = field(default="Server Economy") +from typing import Union + +import msgspec + + +class LoggingGuildConfig(msgspec.Struct): + channel_id: Union[int, None] + member_events: bool = True + mod_events: bool = True + eco_events: bool = False + + +class GuildConfig(msgspec.Struct): + id: int + logging_config: Union[LoggingGuildConfig, None] + logs: bool = True + birthday: bool = False + local_economy: bool = False + redirects: bool = True + local_economy_name: str = "Server Economy" diff --git a/Bot/Libs/config/utils.py b/Bot/Libs/config/utils.py index 2a9575f2..55cdc064 100644 --- a/Bot/Libs/config/utils.py +++ b/Bot/Libs/config/utils.py @@ -1,5 +1,4 @@ import asyncpg -from attrs import asdict from Libs.cache import KumikoCache from Libs.config import GuildConfig, LoggingGuildConfig from redis.asyncio.connection import ConnectionPool @@ -9,7 +8,7 @@ async def get_or_fetch_guild_config( guild_id: int, pool: asyncpg.Pool, redis_pool: ConnectionPool ): sql = """ - SELECT guild.id, guild.logs, guild.birthday, guild.local_economy, guild.local_economy_name, logging_config.channel_id, logging_config.member_events, logging_config.mod_events, logging_config.mod_events, logging_config.eco_events + SELECT guild.id, logging_config.channel_id, logging_config.member_events, logging_config.mod_events, logging_config.mod_events, logging_config.eco_events, guild.logs, guild.birthday, guild.local_economy, guild.local_economy_name FROM guild INNER JOIN logging_config ON guild.id = logging_config.guild_id @@ -17,25 +16,25 @@ async def get_or_fetch_guild_config( """ key = f"cache:kumiko:{guild_id}:guild_config" cache = KumikoCache(connection_pool=redis_pool) - if await cache.cacheExists(key=key): - res = await cache.getJSONCache(key=key, path="$") + if await cache.cache_exists(key=key): + res = await cache.get_json_cache(key=key, path="$") return res rows = await pool.fetchrow(sql, guild_id) if rows is None: return None - fetchedRows = dict(rows) - guildConfig = GuildConfig( - id=fetchedRows["id"], + fetched_rows = dict(rows) + guild_config = GuildConfig( + id=fetched_rows["id"], logging_config=LoggingGuildConfig( - channel_id=fetchedRows["channel_id"], - member_events=fetchedRows["member_events"], - mod_events=fetchedRows["mod_events"], - eco_events=fetchedRows["eco_events"], + channel_id=fetched_rows["channel_id"], + member_events=fetched_rows["member_events"], + mod_events=fetched_rows["mod_events"], + eco_events=fetched_rows["eco_events"], ), - logs=fetchedRows["logs"], - birthday=fetchedRows["birthday"], - local_economy=fetchedRows["local_economy"], - local_economy_name=fetchedRows["local_economy_name"], + logs=fetched_rows["logs"], + birthday=fetched_rows["birthday"], + local_economy=fetched_rows["local_economy"], + local_economy_name=fetched_rows["local_economy_name"], ) - await cache.setJSONCache(key=key, value=asdict(guildConfig), path="$", ttl=None) - return asdict(guildConfig) + await cache.set_json_cache(key=key, value=guild_config, path="$", ttl=None) + return fetched_rows diff --git a/Bot/Libs/errors/__init__.py b/Bot/Libs/errors/__init__.py index 8ed27102..92d5b892 100644 --- a/Bot/Libs/errors/__init__.py +++ b/Bot/Libs/errors/__init__.py @@ -1,19 +1,21 @@ from .exceptions import ( - EconomyDisabled, + EconomyDisabledError, HTTPError, ItemNotFoundError, - KumikoException, + KumikoExceptionError, NoItemsError, NotFoundError, + RedirectsDisabledError, ValidationError, ) __all__ = [ - "KumikoException", + "KumikoExceptionError", "NoItemsError", "ItemNotFoundError", "ValidationError", "HTTPError", "NotFoundError", - "EconomyDisabled", + "EconomyDisabledError", + "RedirectsDisabledError", ] diff --git a/Bot/Libs/errors/exceptions.py b/Bot/Libs/errors/exceptions.py index b6003748..4978ab55 100644 --- a/Bot/Libs/errors/exceptions.py +++ b/Bot/Libs/errors/exceptions.py @@ -3,29 +3,29 @@ from discord.ext.commands.errors import CommandError -class KumikoException(Exception): +class KumikoExceptionError(Exception): """Base exception class for Kumiko. Any exceptions can be ideally caught in this class, but is not recommended. """ -class NoItemsError(KumikoException): +class NoItemsError(KumikoExceptionError): """Raised when no items are found in a list. This is used when the JSON response from an API contains no items. """ -class ItemNotFoundError(KumikoException): +class ItemNotFoundError(KumikoExceptionError): """Generally used if any item of the economy system is not found""" -class ValidationError(KumikoException): +class ValidationError(KumikoExceptionError): """Raised when a validation of any function fails""" -class HTTPError(KumikoException): +class HTTPError(KumikoExceptionError): """Raised when an HTTP request fails. This is used when the HTTP request to an API fails. @@ -54,10 +54,19 @@ def __init__(self) -> None: super().__init__(404, "Resource or endpoint not found") -class EconomyDisabled(CommandError): +class EconomyDisabledError(CommandError): """Raised when the economy system is disabled in a guild""" def __init__(self) -> None: super().__init__( message="The economy module is disabled in this server. Please ask your server admin to enable it." ) + + +class RedirectsDisabledError(CommandError): + """Raised when the redirects system is disabled in a guild""" + + def __init__(self) -> None: + super().__init__( + message="The redirects module is disabled in this server. Please ask your server admin to enable it." + ) diff --git a/Bot/Libs/ui/auctions/__init__.py b/Bot/Libs/ui/auctions/__init__.py new file mode 100644 index 00000000..9fa93e69 --- /dev/null +++ b/Bot/Libs/ui/auctions/__init__.py @@ -0,0 +1,3 @@ +from .pages import AuctionPages, AuctionSearchPages, OwnedAuctionPages + +__all__ = ["AuctionPages", "OwnedAuctionPages", "AuctionSearchPages"] diff --git a/Bot/Libs/ui/auctions/base_pages.py b/Bot/Libs/ui/auctions/base_pages.py new file mode 100644 index 00000000..80528c8e --- /dev/null +++ b/Bot/Libs/ui/auctions/base_pages.py @@ -0,0 +1,69 @@ +from typing import Any, Dict + +import asyncpg +import discord +from discord.ext import commands +from Libs.cog_utils.auctions import delete_auction +from Libs.utils.pages import EmbedListSource, KumikoPages, SimplePageSource + +from .modals import OwnedAuctionItemAdd + + +class OwnedAuctionItemBasePages(KumikoPages): + def __init__( + self, entries, *, ctx: commands.Context, pool: asyncpg.Pool, per_page: int = 1 + ): + super().__init__( + EmbedListSource(entries, per_page=per_page), ctx=ctx, compact=True + ) + self.add_item(self.add_more) + self.add_item(self.delete) + self.embed = discord.Embed(colour=discord.Colour.og_blurple()) + self.pool = pool + + async def get_embed_from_page(self, current_page: int) -> Dict[str, Any]: + page = await self.source.get_page(current_page) + kwargs_from_page = await self.get_kwargs_from_page(page) + return kwargs_from_page["embed"].to_dict() + + @discord.ui.button( + custom_id="add_more", label="Add More", style=discord.ButtonStyle.grey, row=2 + ) + async def add_more( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """add more""" + page_data = await self.get_embed_from_page(self.current_page) + item_id = int(page_data["fields"][-1]["value"]) + curr_amount = int(page_data["fields"][3]["value"]) + await interaction.response.send_modal( + OwnedAuctionItemAdd(curr_amount, item_id, self.pool) + ) + + @discord.ui.button( + custom_id="delete", label="Delete", style=discord.ButtonStyle.grey, row=2 + ) + async def delete(self, interaction: discord.Interaction, button: discord.ui.Button): + page_data = await self.get_embed_from_page(self.current_page) + item_id = int(page_data["fields"][-1]["value"]) + + if interaction.guild is None: + await interaction.response.send_message( + "You need to be in a server in order for this to work", ephemeral=True + ) + return + status = await delete_auction( + guild_id=interaction.guild.id, + user_id=interaction.user.id, + item_id=item_id, + pool=self.pool, + ) + await interaction.response.send_message(status, ephemeral=True) + + +class AuctionItemSearchBasePages(KumikoPages): + def __init__(self, entries, *, ctx: commands.Context, per_page: int = 10): + super().__init__(SimplePageSource(entries, per_page=per_page), ctx=ctx) + self.embed = discord.Embed( + title="Results", colour=discord.Colour.from_rgb(219, 171, 255) + ) diff --git a/Bot/Libs/ui/auctions/modals.py b/Bot/Libs/ui/auctions/modals.py new file mode 100644 index 00000000..60c3a63a --- /dev/null +++ b/Bot/Libs/ui/auctions/modals.py @@ -0,0 +1,37 @@ +from typing import Optional + +import asyncpg +import discord +from Libs.cog_utils.auctions import add_more_to_auction + + +class OwnedAuctionItemAdd(discord.ui.Modal, title="Add more"): + amount = discord.ui.TextInput( + label="Amount", placeholder="Enter a number", min_length=1 + ) + + def __init__(self, max_amount: Optional[int], item_id: int, pool: asyncpg.Pool): + super().__init__() + self.item_id = item_id + self.pool = pool + if max_amount is not None: + as_str = str(max_amount) + self.amount.placeholder = f"Enter a number between 1 and {as_str}" + self.amount.max_length = len(as_str) + + async def on_submit(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "You can't use this feature in DMs", ephemeral=True + ) + return + + status = await add_more_to_auction( + guild_id=interaction.guild.id, + user_id=interaction.user.id, + pool=self.pool, + item_id=self.item_id, + amount_requested=int(self.amount.value), + ) + await interaction.response.send_message(status, ephemeral=True) + return diff --git a/Bot/Libs/ui/auctions/pages.py b/Bot/Libs/ui/auctions/pages.py new file mode 100644 index 00000000..1fa1fbc4 --- /dev/null +++ b/Bot/Libs/ui/auctions/pages.py @@ -0,0 +1,48 @@ +from typing import List + +import asyncpg +from discord.ext import commands +from Libs.utils.pages import EmbedListSource, KumikoPages + +from .base_pages import AuctionItemSearchBasePages, OwnedAuctionItemBasePages +from .utils import ( + AuctionItem, + AuctionItemCompactPageEntry, + AuctionItemPageEntry, + CompactAuctionItem, + OwnedAuctionItem, + OwnedAuctionItemPageEntry, +) + + +class AuctionPages(KumikoPages): + def __init__( + self, entries: List[AuctionItem], *, ctx: commands.Context, per_page: int = 1 + ): + converted = [AuctionItemPageEntry(entry).to_dict() for entry in entries] + super().__init__(EmbedListSource(converted, per_page=per_page), ctx=ctx) + + +class OwnedAuctionPages(OwnedAuctionItemBasePages): + def __init__( + self, + entries: List[OwnedAuctionItem], + *, + ctx: commands.Context, + per_page: int = 1, + pool: asyncpg.Pool + ): + converted = [OwnedAuctionItemPageEntry(entry).to_dict() for entry in entries] + super().__init__(converted, per_page=per_page, ctx=ctx, pool=pool) + + +class AuctionSearchPages(AuctionItemSearchBasePages): + def __init__( + self, + entries: List[CompactAuctionItem], + *, + ctx: commands.Context, + per_page: int = 10 + ): + converted = [AuctionItemCompactPageEntry(entry) for entry in entries] + super().__init__(converted, per_page=per_page, ctx=ctx) diff --git a/Bot/Libs/ui/auctions/utils.py b/Bot/Libs/ui/auctions/utils.py new file mode 100644 index 00000000..ca2b6ddc --- /dev/null +++ b/Bot/Libs/ui/auctions/utils.py @@ -0,0 +1,123 @@ +import datetime +from typing import Any, Dict, TypedDict + +from discord.utils import format_dt + + +class AuctionItem(TypedDict): + id: int + name: str + description: str + user_id: int + amount_listed: int + listed_price: int + listed_at: datetime.datetime + + +class CompactAuctionItem(TypedDict): + item_id: int + item_name: str + user_id: int + amount_listed: int + + +class OwnedAuctionItem(TypedDict): + id: int + name: str + description: str + amount_listed: int + listed_price: int + listed_at: datetime.datetime + + +class AuctionItemPageEntry: + __slots__ = ( + "id", + "name", + "description", + "user_id", + "amount_listed", + "listed_price", + "listed_at", + ) + + def __init__(self, entry: AuctionItem): + self.id = entry["id"] + self.name = entry["name"] + self.description = entry["description"] + self.user_id = entry["user_id"] + self.amount_listed = entry["amount_listed"] + self.listed_price = entry["listed_price"] + self.listed_at = entry["listed_at"] + + def to_dict(self) -> Dict[str, Any]: + data = { + "title": self.name, + "description": self.description, + "fields": [ + {"name": "Price", "value": self.listed_price, "inline": True}, + {"name": "Amount Listed", "value": self.amount_listed, "inline": True}, + {"name": "Listed By", "value": f"<@{self.user_id}>", "inline": True}, + {"name": "ID", "value": self.id, "inline": True}, + { + "name": "Listed At", + "value": format_dt( + self.listed_at.replace(tzinfo=datetime.timezone.utc) + ), + "inline": True, + }, + ], + } + return data + + +class AuctionItemCompactPageEntry: + __slots__ = ("item_id", "item_name", "user_id", "amount_listed") + + def __init__(self, entry: CompactAuctionItem): + self.item_id = entry["item_id"] + self.item_name = entry["item_name"] + self.user_id = entry["user_id"] + self.amount_listed = entry["amount_listed"] + + def __str__(self) -> str: + return f"{self.item_name} | (ID: {self.item_id}) (Amt: {self.amount_listed}) (Listed By: <@{self.user_id}>)" + + +class OwnedAuctionItemPageEntry: + __slots__ = ( + "id", + "name", + "desc", + "user_id", + "amount_listed", + "listed_price", + "listed_at", + ) + + def __init__(self, entry: OwnedAuctionItem): + self.id = entry["id"] + self.name = entry["name"] + self.desc = entry["description"] + self.amount_listed = entry["amount_listed"] + self.listed_price = entry["listed_price"] + self.listed_at = entry["listed_at"] + + def to_dict(self) -> Dict[str, Any]: + data = { + "title": self.name, + "description": self.desc, + "fields": [ + {"name": "Price", "value": self.listed_price, "inline": True}, + {"name": "Amount Listed", "value": self.amount_listed, "inline": True}, + { + "name": "Listed At", + "value": format_dt( + self.listed_at.replace(tzinfo=datetime.timezone.utc) + ), + "inline": True, + }, + {"name": "ID", "value": self.id, "inline": True}, + ], + } + return data diff --git a/Bot/Libs/ui/dictionary/__init__.py b/Bot/Libs/ui/dictionary/__init__.py new file mode 100644 index 00000000..9a57160f --- /dev/null +++ b/Bot/Libs/ui/dictionary/__init__.py @@ -0,0 +1,3 @@ +from .pages import DictPages, JapaneseDictPages + +__all__ = ["DictPages", "JapaneseDictPages"] diff --git a/Bot/Libs/ui/dictionary/pages.py b/Bot/Libs/ui/dictionary/pages.py new file mode 100644 index 00000000..9cf1668d --- /dev/null +++ b/Bot/Libs/ui/dictionary/pages.py @@ -0,0 +1,59 @@ +import discord +from discord.ext import commands +from Libs.cog_utils.dictionary import ( + EnglishDictEntry, + JapaneseDictEntry, + JapaneseEntryDef, + JapaneseWordEntry, +) +from Libs.utils.pages import KumikoPages + +from .sources import EnglishDefinePageSource, JapaneseDefPageSource + + +class DictPages(KumikoPages): + def __init__(self, entries, *, ctx: commands.Context, per_page=1): + converted = [ + EnglishDictEntry( + word=entry["word"], + phonetics=entry["phonetics"], + part_of_speech=entry["meanings"][0]["partOfSpeech"], + definitions=[item["definitions"] for item in entry["meanings"]], + ) + for entry in entries + ] + super().__init__( + EnglishDefinePageSource(converted, per_page=per_page), ctx=ctx, compact=True + ) + self.embed = discord.Embed(colour=discord.Colour.og_blurple()) + + +class JapaneseDictPages(KumikoPages): + def __init__(self, entries, *, ctx: commands.Context, per_page=1): + converted = [ + JapaneseDictEntry( + word=[ + JapaneseWordEntry( + word=word["word"] if "word" in word else word["reading"], + reading=word["reading"] if "reading" in word else None, + ) + for word in entry["japanese"] + ], + definitions=[ + JapaneseEntryDef( + english_definitions=item["english_definitions"], + parts_of_speech=item["parts_of_speech"], + tags=item["tags"], + ) + for item in entry["senses"] + ], + is_common=entry["is_common"] if "is_common" in entry else None, + tags=entry["tags"], + jlpt=entry["jlpt"], + ) + for entry in entries + ] + super().__init__( + JapaneseDefPageSource(converted, per_page=per_page), ctx=ctx, compact=True + ) + self.embed = discord.Embed(colour=discord.Colour.light_grey()) diff --git a/Bot/Libs/ui/dictionary/sources.py b/Bot/Libs/ui/dictionary/sources.py new file mode 100644 index 00000000..f98ae8ae --- /dev/null +++ b/Bot/Libs/ui/dictionary/sources.py @@ -0,0 +1,66 @@ +from attrs import asdict +from discord.ext import menus +from Libs.cog_utils.dictionary import JapaneseDictEntry + + +class EnglishDefinePageSource(menus.ListPageSource): + async def format_page(self, menu, entries): + definitions = [] + for idx, entry in enumerate(entries.definitions[0], start=0): + default_text = f"{idx + 1}. {entry['definition']}" + if "example" in entry: + default_text = f"{idx}. {entry['definition']}\n--- *{entry['example']}*" + + definitions.append(default_text) + maximum = self.get_max_pages() + menu.embed.title = f"{entries.word}" + + if maximum > 1: + footer = ( + f"Page {menu.current_page + 1}/{maximum} ({len(self.entries)} entries)" + ) + menu.embed.set_footer(text=footer) + + header_desc = f"**{entries.part_of_speech}** {' • '.join([item['text'] for item in entries.phonetics if 'text' in item]).rstrip('*')}\n" + menu.embed.description = header_desc + "\n".join(definitions) + return menu.embed + + +class JapaneseDefPageSource(menus.ListPageSource): + async def format_page(self, menu, entries: JapaneseDictEntry): + word_str = "" + dict_entries = asdict(entries) + first_jpn_entry = dict_entries["word"][0] + if "word" and "reading" in first_jpn_entry: + if first_jpn_entry["word"] == first_jpn_entry["reading"]: + word_str += f"**{first_jpn_entry['word']}**\n" + else: + word_str += ( + f"**{first_jpn_entry['word']}** ({first_jpn_entry['reading']})\n" + ) + elif "word" not in first_jpn_entry: + word_str += f"**{first_jpn_entry['reading']}**\n" + + definitions = [] + for idx, entry in enumerate(entries.definitions, start=0): + parse_defs = ", ".join([item for item in entry.english_definitions]).rstrip( + "," + ) + parse_pos = ", ".join([item for item in entry.parts_of_speech]).rstrip(",") + parse_tags = ", ".join([item for item in entry.tags]).rstrip(",") + text = f"(**{parse_pos}**)\n{idx + 1}. {parse_defs}\n" + if len(entry.tags) != 0: + text = f"(**{parse_pos}**)\n{idx + 1}. {parse_defs} ({parse_tags})\n" + definitions.append(text) + + maximum = self.get_max_pages() + menu.embed.title = f"{word_str}" + + if maximum > 1: + footer = ( + f"Page {menu.current_page + 1}/{maximum} ({len(self.entries)} entries)" + ) + menu.embed.set_footer(text=footer) + + menu.embed.description = "\n".join(definitions) + return menu.embed diff --git a/Bot/Libs/ui/economy/__init__.py b/Bot/Libs/ui/economy/__init__.py index 80a66e3a..b0043c87 100644 --- a/Bot/Libs/ui/economy/__init__.py +++ b/Bot/Libs/ui/economy/__init__.py @@ -1,3 +1,4 @@ +from .pages import LeaderboardPages, UserInvPages from .views import RegisterView -__all__ = ["RegisterView"] +__all__ = ["RegisterView", "LeaderboardPages", "UserInvPages"] diff --git a/Bot/Libs/ui/economy/base_pages.py b/Bot/Libs/ui/economy/base_pages.py new file mode 100644 index 00000000..24db32a7 --- /dev/null +++ b/Bot/Libs/ui/economy/base_pages.py @@ -0,0 +1,68 @@ +from typing import Any, Dict + +import asyncpg +import discord +from discord.ext import commands +from Libs.utils.pages import EmbedListSource, KumikoPages, SimplePageSource + +from .modals import UserInvAHListModal, UserInvRefundModal + + +class BasePages(KumikoPages): + def __init__(self, entries, *, ctx: commands.Context, per_page: int = 10): + super().__init__(SimplePageSource(entries, per_page=per_page), ctx=ctx) + self.embed = discord.Embed( + title="Leaderboard stats", colour=discord.Colour.from_rgb(219, 171, 255) + ) + + +class UserInvBasePages(KumikoPages): + def __init__( + self, entries, *, ctx: commands.Context, per_page: int = 1, pool: asyncpg.Pool + ): + super().__init__( + EmbedListSource(entries, per_page=per_page), ctx=ctx, compact=True + ) + self.pool = pool + self.add_item(self.ah_list) + self.add_item(self.refund) + self.embed = discord.Embed(colour=discord.Colour.og_blurple()) + + async def get_embed_from_page(self, current_page: int) -> Dict[str, Any]: + page = await self.source.get_page(current_page) + kwargs_from_page = await self.get_kwargs_from_page(page) + return kwargs_from_page["embed"].to_dict() + + @discord.ui.button( + custom_id="ah_list", + emoji="<:auction_house:1136906394323398749>", + label="List on Auction House", + style=discord.ButtonStyle.grey, + row=2, + ) + async def ah_list( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """lists on AH""" + page_data = await self.get_embed_from_page(self.current_page) + item_id = int(page_data["fields"][0]["value"]) + curr_amount = int(page_data["fields"][-1]["value"]) + await interaction.response.send_modal( + UserInvAHListModal(curr_amount, item_id, self.pool) + ) + + @discord.ui.button( + custom_id="refund_item", + emoji=discord.PartialEmoji(name="\U0001f4b0"), + label="Refund", + style=discord.ButtonStyle.grey, + row=2, + ) + async def refund(self, interaction: discord.Interaction, button: discord.ui.Button): + """Basically refunds the item""" + page_data = await self.get_embed_from_page(self.current_page) + item_id = int(page_data["fields"][0]["value"]) + curr_amount = int(page_data["fields"][-1]["value"]) + await interaction.response.send_modal( + UserInvRefundModal(curr_amount, item_id, self.pool) + ) diff --git a/Bot/Libs/ui/economy/modals.py b/Bot/Libs/ui/economy/modals.py new file mode 100644 index 00000000..905cda32 --- /dev/null +++ b/Bot/Libs/ui/economy/modals.py @@ -0,0 +1,68 @@ +from typing import Optional + +import asyncpg +import discord +from Libs.cog_utils.auctions import create_auction +from Libs.cog_utils.economy import refund_item + + +class UserInvAHListModal(discord.ui.Modal, title="List an item for auction"): + amount = discord.ui.TextInput( + label="Amount", placeholder="Enter a number", min_length=1 + ) + + def __init__(self, max_amount: Optional[int], item_id: int, pool: asyncpg.Pool): + super().__init__() + self.item_id = item_id + self.pool = pool + if max_amount is not None: + as_str = str(max_amount) + self.amount.placeholder = f"Enter a number between 1 and {as_str}" + self.amount.max_length = len(as_str) + + async def on_submit(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "You can't use this feature in DMs", ephemeral=True + ) + return + status = await create_auction( + guild_id=interaction.guild.id, + user_id=interaction.user.id, + amount_requested=int(self.amount.value), + item_id=self.item_id, + item_name=None, + pool=self.pool, + ) + await interaction.response.send_message(status, ephemeral=True) + return + + +class UserInvRefundModal(discord.ui.Modal, title="Refund an item"): + amount = discord.ui.TextInput( + label="Amount", placeholder="Enter a number", min_length=1 + ) + + def __init__(self, max_amount: Optional[int], item_id: int, pool: asyncpg.Pool): + super().__init__() + self.item_id = item_id + self.pool = pool + if max_amount is not None: + as_str = str(max_amount) + self.amount.placeholder = f"Enter a number between 1 and {as_str}" + self.amount.max_length = len(as_str) + + async def on_submit(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "You need to be in a server in order for this to work", ephemeral=True + ) + return + item_refund = await refund_item( + interaction.guild.id, + interaction.user.id, + self.item_id, + int(self.amount.value), + self.pool, + ) + await interaction.response.send_message(item_refund, ephemeral=True) diff --git a/Bot/Libs/ui/economy/pages.py b/Bot/Libs/ui/economy/pages.py new file mode 100644 index 00000000..27e6ac84 --- /dev/null +++ b/Bot/Libs/ui/economy/pages.py @@ -0,0 +1,37 @@ +from typing import List + +import asyncpg +from discord.ext import commands + +from .base_pages import BasePages, UserInvBasePages +from .utils import ( + LeaderboardEntry, + LeaderboardPageEntry, + UserInvEntry, + UserInvPageEntry, +) + + +class LeaderboardPages(BasePages): + def __init__( + self, + entries: List[LeaderboardEntry], + *, + ctx: commands.Context, + per_page: int = 10 + ): + converted = [LeaderboardPageEntry(entry) for entry in entries] + super().__init__(converted, per_page=per_page, ctx=ctx) + + +class UserInvPages(UserInvBasePages): + def __init__( + self, + entries: List[UserInvEntry], + *, + ctx: commands.Context, + per_page: int = 1, + pool: asyncpg.Pool + ): + converted = [UserInvPageEntry(entry).to_dict() for entry in entries] + super().__init__(converted, per_page=per_page, ctx=ctx, pool=pool) diff --git a/Bot/Libs/ui/economy/utils.py b/Bot/Libs/ui/economy/utils.py new file mode 100644 index 00000000..0697be3c --- /dev/null +++ b/Bot/Libs/ui/economy/utils.py @@ -0,0 +1,51 @@ +from typing import Any, Dict, TypedDict + + +class LeaderboardEntry(TypedDict): + id: int + rank: int + petals: int + + +class UserInvEntry(TypedDict): + id: int + name: str + description: str + price: int + amount_owned: int + producer_id: int + + +class LeaderboardPageEntry: + __slots__ = ("id", "rank", "petals") + + def __init__(self, entry: LeaderboardEntry): + self.id = entry["id"] + self.rank = entry["rank"] + self.petals = entry["petals"] + + def __str__(self) -> str: + return f"<@{self.id}>: {self.petals} 🌸 | (Rank: {self.rank})" + + +class UserInvPageEntry: + __slots__ = ("id", "name", "description", "price", "amount_owned") + + def __init__(self, entries: UserInvEntry): + self.id: int = entries["id"] + self.name: str = entries["name"] + self.description: str = entries["description"] + self.price: int = entries["price"] + self.amount_owned: int = entries["amount_owned"] + + def to_dict(self) -> Dict[str, Any]: + data = { + "title": self.name, + "description": self.description, + "fields": [ + {"name": "ID", "value": self.id, "inline": True}, + {"name": "Price", "value": self.price, "inline": True}, + {"name": "Amount", "value": self.amount_owned, "inline": True}, + ], + } + return data diff --git a/Bot/Libs/ui/economy/views.py b/Bot/Libs/ui/economy/views.py index 2ba09162..83f936ac 100644 --- a/Bot/Libs/ui/economy/views.py +++ b/Bot/Libs/ui/economy/views.py @@ -19,15 +19,15 @@ async def confirm( status = await self.pool.execute(query, interaction.user.id) self.clear_items() if status[-1] == "0": - errorEmbed = Embed(description="You already have an economy account!") + error_embed = Embed(description="You already have an economy account!") await interaction.response.edit_message( - embed=errorEmbed, view=self, delete_after=20.0 + embed=error_embed, view=self, delete_after=20.0 ) else: - successEmbed = SuccessActionEmbed() - successEmbed.description = "Successfully created an economy account!" + success_embed = SuccessActionEmbed() + success_embed.description = "Successfully created an economy account!" await interaction.response.edit_message( - embed=successEmbed, view=self, delete_after=20.0 + embed=success_embed, view=self, delete_after=20.0 ) @discord.ui.button( diff --git a/Bot/Libs/ui/events_log/views.py b/Bot/Libs/ui/events_log/views.py index 9b0541b4..bba8b2a8 100644 --- a/Bot/Libs/ui/events_log/views.py +++ b/Bot/Libs/ui/events_log/views.py @@ -1,6 +1,5 @@ import asyncpg import discord -from attrs import asdict from Libs.cache import KumikoCache from Libs.cog_utils.events_log import disable_logging from Libs.config import LoggingGuildConfig @@ -33,14 +32,14 @@ async def select_channels( UPDATE SET channel_id = excluded.channel_id; """ async with self.pool.acquire() as conn: - guildId = interaction.guild.id # type: ignore + guild_id = interaction.guild.id # type: ignore cache = KumikoCache(connection_pool=self.redis_pool) lgc = LoggingGuildConfig(channel_id=select.values[0].id) tr = conn.transaction() await tr.start() try: - await conn.execute(query, guildId, select.values[0].id, True) + await conn.execute(query, guild_id, select.values[0].id, True) except asyncpg.UniqueViolationError: await tr.rollback() await interaction.response.send_message("There are duplicate records") @@ -49,13 +48,19 @@ async def select_channels( await interaction.response.send_message("Could not create records.") else: await tr.commit() - await cache.mergeJSONCache( - key=f"cache:kumiko:{guildId}:guild_config", - value=asdict(lgc), - path="$.logging_config", + await cache.merge_json_cache( + key=f"cache:kumiko:{guild_id}:guild_config", + value=lgc, + path=".logging_config", + ) + await cache.set_basic_cache( + key=f"cache:kumiko:{guild_id}:logging_channel_id", + value=str(select.values[0].id), + ttl=3600, ) await interaction.response.send_message( - f"Successfully set the logging channel to {select.values[0].mention}" + f"Successfully set the logging channel to {select.values[0].mention}", + ephemeral=True, ) @discord.ui.button(label="Finish", style=discord.ButtonStyle.green) @@ -91,37 +96,37 @@ async def confirm( DELETE FROM logging_config WHERE guild_id = (SELECT id FROM guild_update); """ async with self.pool.acquire() as conn: - guildId = interaction.guild.id # type: ignore + guild_id = interaction.guild.id # type: ignore tr = conn.transaction() await tr.start() try: - await conn.execute(query, guildId, False) + await conn.execute(query, guild_id, False) except asyncpg.UniqueViolationError: await tr.rollback() self.clear_items() - uniqueViolationEmbed = ErrorEmbed( + unique_violation_embed = ErrorEmbed( description="There are duplicate records" ) await interaction.response.edit_message( - embed=uniqueViolationEmbed, view=self + embed=unique_violation_embed, view=self ) except Exception: await tr.rollback() self.clear_items() - failedEmbed = ErrorEmbed( + failed_embed = ErrorEmbed( description="Could not update or delete records" ) - await interaction.response.edit_message(embed=failedEmbed, view=self) + await interaction.response.edit_message(embed=failed_embed, view=self) else: await tr.commit() - await disable_logging(guild_id=guildId, redis_pool=self.redis_pool) + await disable_logging(guild_id=guild_id, redis_pool=self.redis_pool) self.clear_items() - successEmbed = SuccessActionEmbed() - successEmbed.description = "Disabled and cleared all logging configs" + success_embed = SuccessActionEmbed() + success_embed.description = "Disabled and cleared all logging configs" - await interaction.response.edit_message(embed=successEmbed, view=self) + await interaction.response.edit_message(embed=success_embed, view=self) @discord.ui.button( label="Cancel", diff --git a/Bot/Libs/ui/jobs/modals.py b/Bot/Libs/ui/jobs/modals.py index b2f579eb..e1fe3ec4 100644 --- a/Bot/Libs/ui/jobs/modals.py +++ b/Bot/Libs/ui/jobs/modals.py @@ -1,157 +1,136 @@ -import asyncpg -import discord -from Libs.cog_utils.jobs import createJobLink, createJobOutputItem, updateJob - - -class CreateJob(discord.ui.Modal, title="Create Job"): - def __init__(self, pool: asyncpg.pool.Pool, required_rank: int, pay: int) -> None: - super().__init__() - self.pool: asyncpg.Pool = pool - self.required_rank = required_rank - self.pay = pay - self.name = discord.ui.TextInput( - label="Name", - placeholder="Name of the job", - min_length=1, - max_length=255, - row=0, - ) - self.description = discord.ui.TextInput( - label="Description", - style=discord.TextStyle.long, - placeholder="Description of the job", - min_length=1, - max_length=2000, - row=1, - ) - self.add_item(self.name) - self.add_item(self.description) - - async def on_submit(self, interaction: discord.Interaction) -> None: - # Ripped the whole thing from RDanny again... - query = """ - WITH job_insert AS ( - INSERT INTO job (name, description, guild_id, creator_id, required_rank, pay_amount) - VALUES ($3, $4, $1, $2, $5, $6) - RETURNING id - ) - INSERT into job_lookup (name, guild_id, creator_id, job_id) - VALUES ($3, $1, $2, (SELECT id FROM job_insert)); - """ - async with self.pool.acquire() as conn: - tr = conn.transaction() - await tr.start() - - try: - await conn.execute( - query, - interaction.guild.id, # type: ignore - interaction.user.id, - self.name.value, - self.description.value, - self.required_rank, - self.pay, - ) - except asyncpg.UniqueViolationError: - await tr.rollback() - await interaction.response.send_message("This job already exists.") - except Exception: - await tr.rollback() - await interaction.response.send_message("Could not create job.") - else: - await tr.commit() - await interaction.response.send_message( - f"Job {self.name} successfully created." - ) - - -class UpdateJobModal(discord.ui.Modal, title="Update Job"): - def __init__( - self, pool: asyncpg.pool.Pool, name: str, required_rank: int, pay: int - ) -> None: - super().__init__() - self.pool: asyncpg.Pool = pool - self.name = name - self.required_rank = required_rank - self.pay = pay - self.description = discord.ui.TextInput( - label="Description", - style=discord.TextStyle.long, - placeholder="Description of the job", - min_length=1, - max_length=2000, - row=1, - ) - self.add_item(self.description) - - async def on_submit(self, interaction: discord.Interaction) -> None: - status = await updateJob(interaction.user.id, interaction.guild.id, self.pool, self.name, self.description.value, self.required_rank, self.pay) # type: ignore - if status[-1] == 0: - await interaction.response.send_message( - "You either don't own this job or the job doesn't exist. Try again." - ) - return - await interaction.response.send_message( - f"Successfully updated the job `{self.name}` (RR: {self.required_rank}, Pay: {self.pay})" - ) - return - - -class CreateJobOutputItemModal(discord.ui.Modal, title="Create Output Item"): - def __init__(self, pool: asyncpg.Pool, name: str, price: int, amount: int) -> None: - super().__init__() - self.pool = pool - self.name = name - self.price = price - self.amount = amount - self.description = discord.ui.TextInput( - label="Description", - style=discord.TextStyle.long, - placeholder="Description of the item", - min_length=1, - max_length=2000, - row=0, - ) - self.add_item(self.description) - - async def on_submit(self, interaction: discord.Interaction) -> None: - query = """ - SELECT eco_item_lookup.item_id, job_lookup.job_id - FROM eco_item_lookup - INNER JOIN job_lookup ON eco_item_lookup.producer_id = job_lookup.worker_id - WHERE eco_item_lookup.guild_id=$1 AND LOWER(eco_item_lookup.name)=$2 AND eco_item_lookup.producer_id=$3; - """ - status = await createJobOutputItem( - name=self.name, - description=self.description.value, - price=self.price, - amount=self.amount, - guild_id=interaction.guild.id, # type: ignore - worker_id=interaction.user.id, - pool=self.pool, - ) - async with self.pool.acquire() as conn: - if status[-1] != "0": - rows = await conn.fetchrow(query, interaction.guild.id, self.name, interaction.user.id) # type: ignore - if rows is None: - await interaction.response.send_message( - "You aren't the producer of the item!" - ) - return - record = dict(rows) - jobLinkStatus = await createJobLink( - worker_id=interaction.user.id, - item_id=record["item_id"], - job_id=record["job_id"], - conn=conn, - ) - if jobLinkStatus[-1] != "0": - await interaction.response.send_message( - f"Successfully created the output item `{self.name}` (Price: {self.price}, Amount Per Hour: {self.amount})" - ) - return - else: - await interaction.response.send_message( - "There was an error making it. Please try again" - ) - return +import asyncpg +import discord +from Libs.cog_utils.jobs import create_job_output_item, update_job + + +class CreateJob(discord.ui.Modal, title="Create Job"): + def __init__(self, pool: asyncpg.pool.Pool, required_rank: int, pay: int) -> None: + super().__init__() + self.pool: asyncpg.Pool = pool + self.required_rank = required_rank + self.pay = pay + self.name = discord.ui.TextInput( + label="Name", + placeholder="Name of the job", + min_length=1, + max_length=255, + row=0, + ) + self.description = discord.ui.TextInput( + label="Description", + style=discord.TextStyle.long, + placeholder="Description of the job", + min_length=1, + max_length=2000, + row=1, + ) + self.add_item(self.name) + self.add_item(self.description) + + async def on_submit(self, interaction: discord.Interaction) -> None: + # Ripped the whole thing from RDanny again... + query = """ + WITH job_insert AS ( + INSERT INTO job (name, description, guild_id, creator_id, required_rank, pay_amount) + VALUES ($3, $4, $1, $2, $5, $6) + RETURNING id + ) + INSERT into job_lookup (name, guild_id, creator_id, job_id) + VALUES ($3, $1, $2, (SELECT id FROM job_insert)); + """ + async with self.pool.acquire() as conn: + tr = conn.transaction() + await tr.start() + + try: + await conn.execute( + query, + interaction.guild.id, # type: ignore + interaction.user.id, + self.name.value, + self.description.value, + self.required_rank, + self.pay, + ) + except asyncpg.UniqueViolationError: + await tr.rollback() + await interaction.response.send_message("This job already exists.") + except Exception: + await tr.rollback() + await interaction.response.send_message("Could not create job.") + else: + await tr.commit() + await interaction.response.send_message( + f"Job {self.name} successfully created." + ) + + +class UpdateJobModal(discord.ui.Modal, title="Update Job"): + def __init__( + self, pool: asyncpg.pool.Pool, name: str, required_rank: int, pay: int + ) -> None: + super().__init__() + self.pool: asyncpg.Pool = pool + self.name = name + self.required_rank = required_rank + self.pay = pay + self.description = discord.ui.TextInput( + label="Description", + style=discord.TextStyle.long, + placeholder="Description of the job", + min_length=1, + max_length=2000, + row=1, + ) + self.add_item(self.description) + + async def on_submit(self, interaction: discord.Interaction) -> None: + status = await update_job(interaction.user.id, interaction.guild.id, self.pool, self.name, self.description.value, self.required_rank, self.pay) # type: ignore + if status[-1] == 0: + await interaction.response.send_message( + "You either don't own this job or the job doesn't exist. Try again." + ) + return + await interaction.response.send_message( + f"Successfully updated the job `{self.name}` (RR: {self.required_rank}, Pay: {self.pay})" + ) + return + + +class CreateJobOutputItemModal(discord.ui.Modal, title="Create Output Item"): + def __init__(self, pool: asyncpg.Pool, name: str, price: int, amount: int) -> None: + super().__init__() + self.pool = pool + self.name = name + self.price = price + self.amount = amount + self.description = discord.ui.TextInput( + label="Description", + style=discord.TextStyle.long, + placeholder="Description of the item", + min_length=1, + max_length=2000, + row=0, + ) + self.add_item(self.description) + + async def on_submit(self, interaction: discord.Interaction) -> None: + status = await create_job_output_item( + name=self.name, + description=self.description.value, + price=self.price, + amount=self.amount, + guild_id=interaction.guild.id, # type: ignore + worker_id=interaction.user.id, + pool=self.pool, + ) + if status[-1] != "0": + await interaction.response.send_message( + f"Successfully created the output item `{self.name}` (Price: {self.price}, Amount Per Hour: {self.amount})" + ) + return + else: + await interaction.response.send_message( + "There was an error making it. Please try again" + ) + return diff --git a/Bot/Libs/ui/jobs/views.py b/Bot/Libs/ui/jobs/views.py index d3856985..1784d290 100644 --- a/Bot/Libs/ui/jobs/views.py +++ b/Bot/Libs/ui/jobs/views.py @@ -1,6 +1,6 @@ import asyncpg import discord -from Libs.utils import ErrorEmbed, SuccessActionEmbed +from Libs.utils import ErrorEmbed, MessageConstants, SuccessActionEmbed class DeleteJobView(discord.ui.View): @@ -17,27 +17,25 @@ def __init__(self, pool: asyncpg.pool.Pool, job_name: str) -> None: async def confirm( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: - guildId = interaction.guild.id # type: ignore - userId = interaction.user.id + guild_id = interaction.guild.id # type: ignore + user_id = interaction.user.id query = """ DELETE FROM job WHERE guild_id=$1 AND creator_id=$2 AND name= $3; """ async with self.pool.acquire() as conn: - status = await conn.execute(query, guildId, userId, self.job_name) + status = await conn.execute(query, guild_id, user_id, self.job_name) self.clear_items() if status[-1] == "0": - errorEmbed = ErrorEmbed( - description="Either you don't own any jobs or you have no permission to delete those jobs" - ) + error_embed = ErrorEmbed(description=MessageConstants.NO_PERM_JOB.value) await interaction.response.edit_message( - embed=errorEmbed, view=self, delete_after=20.0 + embed=error_embed, view=self, delete_after=20.0 ) else: - successEmbed = SuccessActionEmbed() - successEmbed.description = f"Deleted job `{self.job_name}`" + success_embed = SuccessActionEmbed() + success_embed.description = f"Deleted job `{self.job_name}`" await interaction.response.edit_message( - embed=successEmbed, view=self, delete_after=20.0 + embed=success_embed, view=self, delete_after=20.0 ) @discord.ui.button( @@ -67,27 +65,25 @@ def __init__(self, pool: asyncpg.pool.Pool, job_id: int) -> None: async def confirm( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: - guildId = interaction.guild.id # type: ignore - userId = interaction.user.id + guild_id = interaction.guild.id # type: ignore + user_id = interaction.user.id query = """ DELETE FROM job WHERE guild_id=$1 AND creator_id=$2 AND id=$3; """ async with self.pool.acquire() as conn: - status = await conn.execute(query, guildId, userId, self.job_id) + status = await conn.execute(query, guild_id, user_id, self.job_id) self.clear_items() if status[-1] == "0": - errorEmbed = ErrorEmbed( - description="Either you don't own any jobs or you have no permission to delete those jobs" - ) + error_embed = ErrorEmbed(description=MessageConstants.NO_PERM_JOB.value) await interaction.response.edit_message( - embed=errorEmbed, view=self, delete_after=20.0 + embed=error_embed, view=self, delete_after=20.0 ) else: - successEmbed = SuccessActionEmbed() - successEmbed.description = f"Deleted job via ID (ID: `{self.job_id}`)" + success_embed = SuccessActionEmbed() + success_embed.description = f"Deleted job via ID (ID: `{self.job_id}`)" await interaction.response.edit_message( - embed=successEmbed, view=self, delete_after=20.0 + embed=success_embed, view=self, delete_after=20.0 ) @discord.ui.button( @@ -116,27 +112,25 @@ def __init__(self, pool: asyncpg.pool.Pool) -> None: async def confirm( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: - guildId = interaction.guild.id # type: ignore - userId = interaction.user.id + guild_id = interaction.guild.id # type: ignore + user_id = interaction.user.id query = """ DELETE FROM job WHERE guild_id=$1 AND creator_id=$2; """ async with self.pool.acquire() as conn: - status = await conn.execute(query, guildId, userId) + status = await conn.execute(query, guild_id, user_id) self.clear_items() if status[-1] == "0": - errorEmbed = ErrorEmbed( - description="Either you don't own any jobs or you have no permission to delete those jobs" - ) + error_embed = ErrorEmbed(description=MessageConstants.NO_PERM_JOB.value) await interaction.response.edit_message( - embed=errorEmbed, view=self, delete_after=20.0 + embed=error_embed, view=self, delete_after=20.0 ) else: - successEmbed = SuccessActionEmbed() - successEmbed.description = "Fully purged all jobs that you own." + success_embed = SuccessActionEmbed() + success_embed.description = "Fully purged all jobs that you own." await interaction.response.edit_message( - embed=successEmbed, view=self, delete_after=20.0 + embed=success_embed, view=self, delete_after=20.0 ) @discord.ui.button( diff --git a/Bot/Libs/ui/marketplace/pages.py b/Bot/Libs/ui/marketplace/pages.py index 4ef0ff49..3b18cffe 100644 --- a/Bot/Libs/ui/marketplace/pages.py +++ b/Bot/Libs/ui/marketplace/pages.py @@ -40,7 +40,7 @@ def to_embed(self) -> discord.Embed: return embed def to_dict(self) -> Dict[str, Any]: - dictData = { + data = { "title": self.name, "description": self.description, "fields": [ @@ -49,7 +49,7 @@ def to_dict(self) -> Dict[str, Any]: {"name": "Amount", "value": self.amount, "inline": True}, ], } - return dictData + return data class ItemPages(SimpleItemPages): diff --git a/Bot/Libs/ui/pins/modals.py b/Bot/Libs/ui/pins/modals.py index 9bc41a88..746238ca 100644 --- a/Bot/Libs/ui/pins/modals.py +++ b/Bot/Libs/ui/pins/modals.py @@ -1,6 +1,6 @@ import asyncpg import discord -from Libs.cog_utils.pins import editPin +from Libs.cog_utils.pins import edit_pin class CreatePin(discord.ui.Modal, title="Create Pin"): @@ -27,7 +27,7 @@ def __init__(self, pool: asyncpg.pool.Pool) -> None: async def on_submit(self, interaction: discord.Interaction) -> None: # Ripped the whole thing from RDanny again... - insertQuery = """WITH pin_insert AS ( + query = """WITH pin_insert AS ( INSERT INTO pin (author_id, guild_id, name, content) VALUES ($1, $2, $3, $4) RETURNING id @@ -41,7 +41,7 @@ async def on_submit(self, interaction: discord.Interaction) -> None: try: await conn.execute( - insertQuery, + query, interaction.user.id, interaction.guild.id, # type: ignore self.name.value, @@ -83,9 +83,11 @@ def __init__(self, pool: asyncpg.Pool, name: str) -> None: self.add_item(self.content) async def on_submit(self, interaction: discord.Interaction) -> None: - guildId = interaction.guild.id # type: ignore - userId = interaction.user.id - res = await editPin(guildId, userId, self.pool, self.name, self.content.value) + guild_id = interaction.guild.id # type: ignore + user_id = interaction.user.id + res = await edit_pin( + guild_id, user_id, self.pool, self.name, self.content.value + ) if res[-1] == "0": await interaction.response.send_message( "Could not edit pin. Are you sure you own it?" diff --git a/Bot/Libs/ui/pins/views.py b/Bot/Libs/ui/pins/views.py index 3081b745..a753ed25 100644 --- a/Bot/Libs/ui/pins/views.py +++ b/Bot/Libs/ui/pins/views.py @@ -28,28 +28,28 @@ async def confirm( status = await conn.execute(query, self.name) except asyncpg.UniqueViolationError: self.clear_items() - uniqueViolationEmbed = ErrorEmbed( + unique_violation_embed = ErrorEmbed( description="There are duplicate records" ) await interaction.response.edit_message( - embed=uniqueViolationEmbed, view=self + embed=unique_violation_embed, view=self ) else: self.clear_items() if status[-1] == "0": - errorEmbed = ErrorEmbed( + error_embed = ErrorEmbed( description=f"A pin with the name of `{self.name}` does not exist." ) await interaction.response.edit_message( - embed=errorEmbed, view=self, delete_after=20.0 + embed=error_embed, view=self, delete_after=20.0 ) else: - successEmbed = SuccessActionEmbed() - successEmbed.description = ( + success_embed = SuccessActionEmbed() + success_embed.description = ( f"Deleted the following pin: `{self.name}`" ) await interaction.response.edit_message( - embed=successEmbed, view=self, delete_after=20.0 + embed=success_embed, view=self, delete_after=20.0 ) @discord.ui.button( @@ -87,19 +87,19 @@ async def confirm( status = await conn.execute(query, interaction.guild.id, interaction.user.id) # type: ignore self.clear_items() if status[-1] == "0": - errorEmbed = ErrorEmbed( + error_embed = ErrorEmbed( description="Either you don't own any pins or you have no permission to delete those pins" ) await interaction.response.edit_message( - embed=errorEmbed, view=self, delete_after=20.0 + embed=error_embed, view=self, delete_after=20.0 ) else: - successEmbed = SuccessActionEmbed() - successEmbed.description = ( + success_embed = SuccessActionEmbed() + success_embed.description = ( f"Fully purged all pins belonging to {interaction.user.mention}" ) await interaction.response.edit_message( - embed=successEmbed, view=self, delete_after=20.0 + embed=success_embed, view=self, delete_after=20.0 ) @discord.ui.button( diff --git a/Bot/Libs/ui/prefix/views.py b/Bot/Libs/ui/prefix/views.py index 1b6b0b1b..89c3e320 100644 --- a/Bot/Libs/ui/prefix/views.py +++ b/Bot/Libs/ui/prefix/views.py @@ -1,6 +1,6 @@ import discord from kumikocore import KumikoCore -from Libs.utils import CancelledActionEmbed, ErrorEmbed, SuccessActionEmbed +from Libs.utils import ErrorEmbed, SuccessActionEmbed class DeletePrefixView(discord.ui.View): @@ -55,5 +55,6 @@ async def cancel( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: self.clear_items() - embed = CancelledActionEmbed() - await interaction.response.edit_message(embed=embed, view=self) + await interaction.response.defer() + await interaction.delete_original_response() + self.stop() diff --git a/Bot/Libs/ui/pronouns/__init__.py b/Bot/Libs/ui/pronouns/__init__.py new file mode 100644 index 00000000..58b6ead6 --- /dev/null +++ b/Bot/Libs/ui/pronouns/__init__.py @@ -0,0 +1,31 @@ +from .pages import ( + PPPages, + PronounsInclusivePages, + PronounsNounsPages, + PronounsTermsPages, +) +from .profile_pages import PronounsProfilePages +from .structs import ( + PronounsInclusiveEntry, + PronounsNounsEntry, + PronounsProfileCircleEntry, + PronounsProfileEntry, + PronounsTermsEntry, + PronounsValuesEntry, + PronounsWordsEntry, +) + +__all__ = [ + "PPPages", + "PronounsProfileCircleEntry", + "PronounsValuesEntry", + "PronounsProfileEntry", + "PronounsWordsEntry", + "PronounsProfilePages", + "PronounsTermsPages", + "PronounsTermsEntry", + "PronounsInclusivePages", + "PronounsNounsPages", + "PronounsNounsEntry", + "PronounsInclusiveEntry", +] diff --git a/Bot/Libs/ui/pronouns/embed_entries.py b/Bot/Libs/ui/pronouns/embed_entries.py new file mode 100644 index 00000000..2738a237 --- /dev/null +++ b/Bot/Libs/ui/pronouns/embed_entries.py @@ -0,0 +1,78 @@ +from .structs import PronounsInclusiveEntry, PronounsNounsEntry, PronounsTermsEntry + + +class PronounsTermsEmbedEntry: + __slots__ = ("term", "original", "definition", "locale", "flags", "category") + + def __init__(self, entry: PronounsTermsEntry): + self.term = entry.term + self.original = entry.original + self.definition = entry.definition + self.locale = entry.locale + self.flags = entry.flags + self.category = entry.category + + def to_dict(self): + parsed_flags = str(self.flags) if len(self.flags) > 0 else "None" + data = { + "title": self.term, + "description": self.definition, + "fields": [ + {"name": "Original", "value": self.original or "None", "inline": True}, + {"name": "Flags", "value": parsed_flags, "inline": True}, + {"name": "Category", "value": self.category, "inline": True}, + ], + } + return data + + +class PronounsInclusiveEmbedEntry: + __slots__ = ("instead_of", "say", "because", "categories", "clarification") + + def __init__(self, entry: PronounsInclusiveEntry): + self.instead_of = entry.instead_of + self.say = entry.say + self.because = entry.because + self.categories = entry.categories + self.clarification = entry.clarification + + def to_dict(self): + desc = f"Instead of [{self.instead_of}], you should say [{self.say}] because [{self.because}]." + data = { + "title": f"Instead of ..., say {self.say}", + "description": desc, + "fields": [ + { + "name": "Clarification", + "value": self.clarification or "None", + "inline": True, + }, + { + "name": "Categories", + "value": self.categories or "None", + "inline": True, + }, + ], + } + return data + + +class PronounsNounsEmbedEntry: + __slots__ = ("masc", "fem", "neutr", "masc_plural", "fem_plural", "neutr_plural") + + def __init__(self, entry: PronounsNounsEntry): + self.masc = entry.masc + self.fem = entry.fem + self.neutr = entry.neutr + self.masc_plural = entry.masc_plural + self.fem_plural = entry.fem_plural + self.neutr_plural = entry.neutr_plural + + def to_dict(self): + desc = f"**Masc**: {self.masc}\n**Fem**: {self.fem}\n**Neutr**: {self.neutr}\n" + desc += f"**Masc Plural**: {self.masc_plural}\n**Fem Plural**: {self.fem_plural}\n**Neutr Plural**: {self.neutr_plural}" + data = { + "title": f"{self.masc} -- {self.fem} -- {self.neutr}", + "description": desc, + } + return data diff --git a/Bot/Libs/ui/pronouns/pages.py b/Bot/Libs/ui/pronouns/pages.py new file mode 100644 index 00000000..2d9a477c --- /dev/null +++ b/Bot/Libs/ui/pronouns/pages.py @@ -0,0 +1,109 @@ +from typing import List, Optional, TypedDict, Union + +import discord +from discord.ext.commands import Context +from Libs.utils.pages import EmbedListSource, KumikoPages + +from .embed_entries import ( + PronounsInclusiveEmbedEntry, + PronounsNounsEmbedEntry, + PronounsTermsEmbedEntry, +) +from .structs import PronounsInclusiveEntry, PronounsNounsEntry, PronounsTermsEntry + + +class PronounsTermsPages(KumikoPages): + def __init__( + self, entries: List[PronounsTermsEntry], *, ctx: Context, per_page: int = 1 + ): + converted = [PronounsTermsEmbedEntry(entry).to_dict() for entry in entries] + super().__init__(EmbedListSource(converted, per_page=per_page), ctx=ctx) + self.embed = discord.Embed(colour=discord.Colour.from_rgb(255, 125, 212)) + + +class PronounsInclusivePages(KumikoPages): + def __init__( + self, entries: List[PronounsInclusiveEntry], *, ctx: Context, per_page: int = 1 + ): + converted = [PronounsInclusiveEmbedEntry(entry).to_dict() for entry in entries] + super().__init__(EmbedListSource(converted, per_page=per_page), ctx=ctx) + self.embed = discord.Embed(colour=discord.Colour.from_rgb(255, 125, 212)) + + +class PronounsNounsPages(KumikoPages): + def __init__( + self, entries: List[PronounsNounsEntry], *, ctx: Context, per_page: int = 1 + ): + converted = [PronounsNounsEmbedEntry(entry).to_dict() for entry in entries] + super().__init__(EmbedListSource(converted, per_page=per_page), ctx=ctx) + self.embed = discord.Embed(colour=discord.Colour.from_rgb(255, 125, 212)) + + +class SimpleItemPages(KumikoPages): + def __init__(self, entries, *, ctx: Context, per_page: int = 1): + super().__init__(EmbedListSource(entries, per_page=per_page), ctx=ctx) + self.embed = discord.Embed(colour=discord.Colour.og_blurple()) + + +class PronounsPageLaDiffEntry(TypedDict): + term: str + original: Union[str, None] + definition: str + locale: str + approved: int + base_id: Optional[str] + author_id: str + deleted: int + flags: List[str] + category: str + key: str + author: str + + +class PronounsPageEntry(TypedDict): + term: str + original: Union[str, None] + definition: str + locale: str + approved: int + base_id: Optional[str] + author_id: str + deleted: int + flags: List[str] + category: str + key: str + author: str + versions: List[PronounsPageLaDiffEntry] + + +class PPEntry: + def __init__(self, entry: PronounsPageEntry): + self.dict_entry = entry + + def to_dict(self): + data = { + "title": self.dict_entry["term"], + "description": self.dict_entry["definition"], + "fields": [ + { + "name": "Original", + "value": self.dict_entry["original"], + "inline": True, + }, + {"name": "Locale", "value": self.dict_entry["locale"], "inline": True}, + { + "name": "Category", + "value": self.dict_entry["category"], + "inline": True, + }, + ], + } + return data + + +class PPPages(SimpleItemPages): + def __init__( + self, entries: List[PronounsPageEntry], *, ctx: Context, per_page: int = 1 + ): + converted = [PPEntry(entry).to_dict() for entry in entries] + super().__init__(converted, per_page=per_page, ctx=ctx) diff --git a/Bot/Libs/ui/pronouns/profile_pages.py b/Bot/Libs/ui/pronouns/profile_pages.py new file mode 100644 index 00000000..1549547c --- /dev/null +++ b/Bot/Libs/ui/pronouns/profile_pages.py @@ -0,0 +1,127 @@ +from typing import Any, Dict, Optional + +import discord +from discord.ext import commands, menus +from langcodes import Language +from Libs.utils.pages import KumikoPages + +from .structs import PronounsProfileEntry +from .utils import determine_bold, parse_opinion, parse_words + + +class PronounsProfilePageSource(menus.PageSource): + def __init__(self, locale: str): + self.locale = locale + + def is_paginating(self) -> bool: + # This forces the buttons to appear even in the front page + return True + + def get_max_pages(self) -> Optional[int]: + # There's only one actual page in the front page + # However we need at least 2 to show all the buttons + return 2 + + async def get_page(self, page_number: int) -> Any: + # The front page is a dummy + self.index = page_number + return self + + async def format_page(self, menu, page): + entry = menu.entries[self.locale] + menu.embed.title = entry.username + menu.embed.set_thumbnail(url=entry.avatar) + menu.embed.set_footer( + text="\U00002764 = Yes | \U0001f61b = Jokingly | \U0001f465 = Only if we're close | \U0001f44c = Okay | \U0001f6ab = Nope" + ) + menu.embed.description = "" + if self.index == 0: + parsed_names = ", ".join( + [ + f"{determine_bold(value.value, value.opinion)} ({parse_opinion(value.opinion)})" + for value in entry.names + ] + ) + parsed_flags = ", ".join([flag for flag in entry.flags]) + parsed_pronouns = ", ".join( + [ + f"{determine_bold(value.value, value.opinion)} ({parse_opinion(value.opinion)})" + for value in entry.pronouns + ] + ) + parsed_relationships = ( + "\n".join( + [ + f"{member.username} (Relationship: {member.relationship} | Mutual: {member.mutual})" + for member in entry.circle + ] + ) + if entry.circle is not None + else "None" + ) + menu.embed.description += f"{entry.description}\n\n" + menu.embed.description += ( + f"**Name(s)**: {parsed_names}\n**Pronouns**: {parsed_pronouns}\n" + ) + menu.embed.description += f"**Flags**: {parsed_flags}\n**Age**: {entry.age or '.'}\n**Timezone**: {(entry.timezone or '.')}\n" + menu.embed.description += f"**Relationships**:\n{parsed_relationships}\n" + elif self.index == 1: + parsed = parse_words(entry.words) + menu.embed.description = parsed + return menu.embed + + +class PronounsProfileLangMenu(discord.ui.Select["PronounsProfilePages"]): + def __init__(self, entries: Dict[str, PronounsProfileEntry]): + super().__init__(placeholder="Select a language") + self.entries = entries + self.__fill_options() + + def __fill_options(self): + for entry in self.entries.keys(): + lang = Language.get(entry) + lang_name = lang.display_name(entry) + self.add_option(label=f"{lang_name}", value=entry) + + async def callback(self, interaction: discord.Interaction): + assert self.view is not None + value = self.values[0] + if value == "en": + await self.view.rebind(PronounsProfilePageSource(locale="en"), interaction) + else: + await self.view.rebind(PronounsProfilePageSource(locale=value), interaction) + + +class PronounsProfilePages(KumikoPages): + def __init__( + self, + entries: Dict[str, PronounsProfileEntry], + *, + ctx: commands.Context, + per_page: int = 1, + ): + self.entries = entries + super().__init__(PronounsProfilePageSource(locale="en"), ctx=ctx, compact=True) + self.add_cats() + + self.embed = discord.Embed(colour=discord.Colour.from_rgb(255, 125, 212)) + + def add_cats(self): + self.clear_items() + self.add_item(PronounsProfileLangMenu(self.entries)) + self.fill_items() + + async def rebind( + self, + source: menus.PageSource, + interaction: discord.Interaction, + to_page: int = 0, + ) -> None: + self.source = source + self.current_page = 0 + + await self.source._prepare_once() + page = await self.source.get_page(0) + kwargs = await self.get_kwargs_from_page(page) + self._update_labels(0) + await interaction.response.edit_message(**kwargs, view=self) diff --git a/Bot/Libs/ui/pronouns/structs.py b/Bot/Libs/ui/pronouns/structs.py new file mode 100644 index 00000000..7f4bfaa4 --- /dev/null +++ b/Bot/Libs/ui/pronouns/structs.py @@ -0,0 +1,61 @@ +from typing import List, Union + +import msgspec + + +class PronounsProfileCircleEntry(msgspec.Struct): + username: str + avatar: str + mutual: bool + relationship: str + + +class PronounsValuesEntry(msgspec.Struct): + value: str + opinion: str + + +class PronounsWordsEntry(msgspec.Struct): + header: Union[str, None] + values: List[PronounsValuesEntry] + + +class PronounsProfileEntry(msgspec.Struct): + username: str + avatar: str + locale: str + names: List[PronounsValuesEntry] + pronouns: List[PronounsValuesEntry] + description: str + age: Union[int, None] + links: List[str] + flags: List[str] + words: List[PronounsWordsEntry] + timezone: Union[str, None] + circle: Union[List[PronounsProfileCircleEntry], None] + + +class PronounsTermsEntry(msgspec.Struct): + term: str + original: Union[str, None] + definition: str + locale: str + flags: str + category: str + + +class PronounsInclusiveEntry(msgspec.Struct): + instead_of: str + say: str + because: str + categories: str + clarification: Union[str, None] + + +class PronounsNounsEntry(msgspec.Struct): + masc: str + fem: str + neutr: str + masc_plural: str + fem_plural: str + neutr_plural: str diff --git a/Bot/Libs/ui/pronouns/utils.py b/Bot/Libs/ui/pronouns/utils.py new file mode 100644 index 00000000..3c1f6f51 --- /dev/null +++ b/Bot/Libs/ui/pronouns/utils.py @@ -0,0 +1,65 @@ +from typing import List + +from .structs import PronounsTermsEntry, PronounsWordsEntry + + +def parse_opinion(opinion: str) -> str: + data = { + "yes": "\U00002764", + "jokingly": "\U0001f61b", + "close": "\U0001f465", + "meh": "\U0001f44c", + "no": "\U0001f6ab", + } + return data[opinion] + + +def determine_bold(value: str, opinion: str) -> str: + if "yes" in opinion: + return f"**{value}**" + return f"{value}" + + +def parse_words(words: List[PronounsWordsEntry]) -> str: + result = "" + for word in words: + if word.header is not None: + result += f"\n**{word.header}**\n" + else: + result += "\n" + result += ", ".join( + [ + f"{determine_bold(value.value, value.opinion)} ({parse_opinion(value.opinion)})" + for value in word.values + ] + ) + result += "\n" + return result + + +class PronounsTermsEmbedEntry: + __slots__ = ("term", "original", "definition", "locale", "flags", "category") + + def __init__(self, entry: PronounsTermsEntry): + self.term = entry.term + self.original = entry.original + self.definition = entry.definition + self.locale = entry.locale + self.flags = entry.flags + self.category = entry.category + + def to_dict(self): + data = { + "title": self.term, + "description": self.definition, + "fields": [ + {"name": "Original", "value": self.original, "inline": True}, + { + "name": "Flags", + "value": ", ".join(self.flags).rstrip(","), + "inline": True, + }, + {"name": "Category", "value": self.category, "inline": True}, + ], + } + return data diff --git a/Bot/Libs/ui/reddit/__init__.py b/Bot/Libs/ui/reddit/__init__.py new file mode 100644 index 00000000..1d6e3a35 --- /dev/null +++ b/Bot/Libs/ui/reddit/__init__.py @@ -0,0 +1,4 @@ +from .pages import RedditMemePages, RedditPages +from .structs import RedditEntry, RedditMemeEntry + +__all__ = ["RedditEntry", "RedditPages", "RedditMemePages", "RedditMemeEntry"] diff --git a/Bot/Libs/ui/reddit/pages.py b/Bot/Libs/ui/reddit/pages.py new file mode 100644 index 00000000..6a1c8a38 --- /dev/null +++ b/Bot/Libs/ui/reddit/pages.py @@ -0,0 +1,24 @@ +from typing import List + +import discord +from discord.ext.commands import Context +from Libs.utils.pages import EmbedListSource, KumikoPages + +from .structs import RedditEntry, RedditMemeEntry +from .utils import RedditMemePageEntry, RedditPageEntry + + +class RedditPages(KumikoPages): + def __init__(self, entries: List[RedditEntry], *, ctx: Context, per_page: int = 1): + converted = [RedditPageEntry(entry).to_dict() for entry in entries] + super().__init__(EmbedListSource(converted, per_page=per_page), ctx=ctx) + self.embed = discord.Embed(colour=discord.Colour.from_rgb(255, 125, 212)) + + +class RedditMemePages(KumikoPages): + def __init__( + self, entries: List[RedditMemeEntry], *, ctx: Context, per_page: int = 1 + ): + converted = [RedditMemePageEntry(entry).to_dict() for entry in entries] + super().__init__(EmbedListSource(converted, per_page=per_page), ctx=ctx) + self.embed = discord.Embed(colour=discord.Colour.from_rgb(255, 125, 212)) diff --git a/Bot/Libs/ui/reddit/structs.py b/Bot/Libs/ui/reddit/structs.py new file mode 100644 index 00000000..81fed32c --- /dev/null +++ b/Bot/Libs/ui/reddit/structs.py @@ -0,0 +1,25 @@ +import msgspec + + +class RedditEntry(msgspec.Struct): + title: str + description: str + image_url: str + author: str + upvotes: int + nsfw: bool + flair: str + num_of_comments: int + post_permalink: str + created_utc: int + + +class RedditMemeEntry(msgspec.Struct): + title: str + url: str + author: str + subreddit: str + ups: int + nsfw: bool + spoiler: bool + reddit_url: str diff --git a/Bot/Libs/ui/reddit/utils.py b/Bot/Libs/ui/reddit/utils.py new file mode 100644 index 00000000..cec0cd32 --- /dev/null +++ b/Bot/Libs/ui/reddit/utils.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from discord.utils import format_dt + +from .structs import RedditEntry, RedditMemeEntry + + +class RedditPageEntry: + def __init__(self, entry: RedditEntry): + self.entry = entry + + def to_dict(self): + data = { + "title": self.entry.title, + "description": self.entry.description, + "image": self.entry.image_url, + "fields": [ + {"name": "Author", "value": self.entry.author}, + {"name": "Upvotes", "value": self.entry.upvotes}, + {"name": "NSFW", "value": self.entry.nsfw}, + {"name": "Flair", "value": self.entry.flair}, + {"name": "Number of Comments", "value": self.entry.num_of_comments}, + { + "name": "Reddit URL", + "value": f"https://reddit.com{self.entry.post_permalink}", + }, + { + "name": "Created At", + "value": format_dt(datetime.fromtimestamp(self.entry.created_utc)), + }, + ], + } + return data + + +class RedditMemePageEntry: + def __init__(self, entries: RedditMemeEntry): + self.entry = entries + + def to_dict(self): + data = { + "title": self.entry.title, + "image": self.entry.url, + "fields": [ + {"name": "Author", "value": self.entry.author}, + {"name": "Subreddit", "value": self.entry.subreddit}, + {"name": "Upvotes", "value": self.entry.ups}, + {"name": "NSFW", "value": self.entry.nsfw}, + {"name": "Spoiler", "value": self.entry.spoiler}, + {"name": "Reddit URL", "value": self.entry.reddit_url}, + ], + } + return data diff --git a/Bot/Libs/ui/redirects/__init__.py b/Bot/Libs/ui/redirects/__init__.py new file mode 100644 index 00000000..4354dbcf --- /dev/null +++ b/Bot/Libs/ui/redirects/__init__.py @@ -0,0 +1,3 @@ +from .views import ConfirmResolvedView + +__all__ = ["ConfirmResolvedView"] diff --git a/Bot/Libs/ui/redirects/views.py b/Bot/Libs/ui/redirects/views.py new file mode 100644 index 00000000..a240dd55 --- /dev/null +++ b/Bot/Libs/ui/redirects/views.py @@ -0,0 +1,53 @@ +from typing import Union + +import discord +from Libs.cog_utils.redirects import mark_as_resolved + + +class ConfirmResolvedView(discord.ui.View): + def __init__( + self, + thread: discord.Thread, + author: Union[discord.User, discord.Member], + *args, + **kwargs + ) -> None: + super().__init__(*args, **kwargs) + self.thread = thread + self.author = author + + @discord.ui.button( + label="Confirm", + style=discord.ButtonStyle.green, + emoji="<:greenTick:596576670815879169>", + ) + async def confirm( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + if interaction.user.id != self.author.id: + await interaction.response.send_message( + "You are not the author of the thread", ephemeral=True + ) + return + + # Avoid relocking locked threads + if self.thread.locked: + return + + await interaction.response.send_message( + "Marking this as solved. Next time you can mark it resolved yourself by using the command `>resolved`" + ) + assert interaction.message is not None + await interaction.message.add_reaction(discord.PartialEmoji(name="\U00002705")) + await mark_as_resolved(self.thread, self.author) + + @discord.ui.button( + label="Cancel", + style=discord.ButtonStyle.red, + emoji="<:redTick:596576672149667840>", + ) + async def cancel( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await interaction.delete_original_response() + self.stop() diff --git a/Bot/Libs/utils/__init__.py b/Bot/Libs/utils/__init__.py index 547accc0..dee7a3e8 100644 --- a/Bot/Libs/utils/__init__.py +++ b/Bot/Libs/utils/__init__.py @@ -1,5 +1,6 @@ from .checks import is_admin, is_manager, is_mod -from .converters import JobName, PinAllFlags, PinName, PrefixConverter +from .connection_checks import ensure_postgres_conn, ensure_redis_conn +from .converters import CheckLegitUser, JobName, PinAllFlags, PinName, PrefixConverter from .embeds import ( CancelledActionEmbed, ConfirmEmbed, @@ -9,16 +10,19 @@ LeaveEmbed, SuccessActionEmbed, ) -from .greedy_formatter import formatGreedy +from .greedy_formatter import format_greedy from .kumiko_logger import KumikoLogger from .member_utils import get_or_fetch_member -from .prefix import get_prefix, validatePrefix +from .message_constants import MessageConstants +from .prefix import get_prefix from .rank_utils import calc_petals, calc_rank +from .time import human_timedelta from .utils import ( - encodeDatetime, - parseDatetime, - parseSubreddit, - parseTimeStr, + encode_datetime, + is_docker, + parse_datetime, + parse_subreddit, + parse_time_str, setup_ssl, ) @@ -26,16 +30,15 @@ "PrefixConverter", "PinName", "PinAllFlags", - "parseDatetime", - "encodeDatetime", + "parse_datetime", + "encode_datetime", "Embed", "ErrorEmbed", - "parseSubreddit", - "parseTimeStr", - "formatGreedy", + "parse_subreddit", + "parse_time_str", + "format_greedy", "KumikoLogger", "get_prefix", - "validatePrefix", "ConfirmEmbed", "SuccessActionEmbed", "CancelledActionEmbed", @@ -49,4 +52,10 @@ "is_mod", "is_admin", "setup_ssl", + "CheckLegitUser", + "is_docker", + "human_timedelta", + "ensure_postgres_conn", + "ensure_redis_conn", + "MessageConstants", ] diff --git a/Bot/Libs/utils/checks.py b/Bot/Libs/utils/checks.py index 94ce7a80..f9c92d51 100644 --- a/Bot/Libs/utils/checks.py +++ b/Bot/Libs/utils/checks.py @@ -11,7 +11,7 @@ # There is really no used of creating my own system when there is one out there already async def check_guild_permissions( ctx: commands.Context, perms: dict[str, bool], *, check=all -): +) -> bool: is_owner = await ctx.bot.is_owner(ctx.author) if is_owner: return True diff --git a/Bot/Libs/utils/connection_checks.py b/Bot/Libs/utils/connection_checks.py new file mode 100644 index 00000000..afd4e55b --- /dev/null +++ b/Bot/Libs/utils/connection_checks.py @@ -0,0 +1,40 @@ +import logging +from typing import Literal + +import asyncpg +import redis.asyncio as redis +from redis.asyncio.connection import ConnectionPool + + +async def ensure_postgres_conn(pool: asyncpg.Pool) -> Literal[True]: + """Ensures that the current connection pulled from the PostgreSQL pool can be run. + + Args: + pool (asyncpg.Pool): The connection pool to get connections from. + + Returns: + Literal[True]: If successful, the coroutine will return True, otherwise it will raise an exception + """ + logger = logging.getLogger("discord") + async with pool.acquire() as conn: + res = conn.is_closed() + if res is False: + logger.info("PostgreSQL server is up") + return True + + +async def ensure_redis_conn(redis_pool: ConnectionPool) -> Literal[True]: + """Pings the Redis server to check if it's open or not + + Args: + connection_pool (Union[ConnectionPool, None]): The supplied connection pool. If none, it will be created automatically + + Returns: + Literal[True]: If successful, the coroutine will return True, otherwise it will raise an exception + """ + logger = logging.getLogger("discord") + r: redis.Redis = redis.Redis(connection_pool=redis_pool) + res = await r.ping() + if res: + logger.info("Sucessfully connected to the Redis server") + return True diff --git a/Bot/Libs/utils/converters.py b/Bot/Libs/utils/converters.py index feddadd8..223dc122 100644 --- a/Bot/Libs/utils/converters.py +++ b/Bot/Libs/utils/converters.py @@ -20,16 +20,13 @@ async def convert(self, ctx: commands.Context, argument: str) -> str: converted = await super().convert(ctx, argument) lower = converted.lower().strip() - # if not lower: - # raise commands.BadArgument("Missing tag name.") - if len(lower) > 100: raise commands.BadArgument("Tag name is a maximum of 100 characters.") first_word, _, _ = lower.partition(" ") # get tag command. - root: commands.GroupMixin = ctx.bot.get_command("pins") # type: ignore + root: commands.GroupMixin = ctx.bot.get_command("pins") if first_word in root.all_commands: raise commands.BadArgument("This tag name starts with a reserved word.") @@ -45,16 +42,12 @@ async def convert(self, ctx: commands.Context, argument: str) -> str: converted = await super().convert(ctx, argument) lower = converted.lower().strip() - # if not lower: - # raise commands.BadArgument("Missing job name.") - if len(lower) > 100: raise commands.BadArgument("Job name is a maximum of 100 characters.") first_word, _, _ = lower.partition(" ") - # get tag command. - root: commands.GroupMixin = ctx.bot.get_command("jobs") # type: ignore + root: commands.GroupMixin = ctx.bot.get_command("jobs") if first_word in root.all_commands: raise commands.BadArgument("This Job name starts with a reserved word.") @@ -67,3 +60,24 @@ class PinAllFlags(commands.FlagConverter): description="Whether to dump all pins in that server", aliases=["a"], ) + + +class CheckLegitUser(commands.clean_content): + def __init__(self, *, lower: bool = False): + self.lower: bool = lower + super().__init__() + + async def convert(self, ctx: commands.Context, argument: str): + converted = await super().convert(ctx, argument) + user_id = int(converted) + + if ctx.guild is None: + raise commands.BadArgument( + "This is being used in a DM. Doesn't work that way" + ) + + member = ctx.guild.get_member(user_id) + if member is None: + raise commands.BadArgument("This user doesn't exist in this server") + + return member.id diff --git a/Bot/Libs/utils/greedy_formatter.py b/Bot/Libs/utils/greedy_formatter.py index c0601614..2c4f49a0 100644 --- a/Bot/Libs/utils/greedy_formatter.py +++ b/Bot/Libs/utils/greedy_formatter.py @@ -1,7 +1,7 @@ from typing import List -def formatGreedy(list: List[str]) -> str: +def format_greedy(list: List[str]) -> str: """Formats a Greedy list into a human-readable string For example, if we had a list of ["a", "b", "c"], it would return "a, b, and c". diff --git a/Bot/Libs/utils/help/__init__.py b/Bot/Libs/utils/help/__init__.py index 4423cad1..e31ebff9 100644 --- a/Bot/Libs/utils/help/__init__.py +++ b/Bot/Libs/utils/help/__init__.py @@ -1,4 +1,3 @@ -from .kumiko_help import KumikoHelp from .kumiko_help_paginated import KumikoHelpPaginated -__all__ = ["KumikoHelpPaginated", "KumikoHelp"] +__all__ = ["KumikoHelpPaginated"] diff --git a/Bot/Libs/utils/help/kumiko_help.py b/Bot/Libs/utils/help/kumiko_help.py deleted file mode 100644 index b111c3f6..00000000 --- a/Bot/Libs/utils/help/kumiko_help.py +++ /dev/null @@ -1,105 +0,0 @@ -import contextlib - -from discord.ext import commands -from Libs.utils import Embed - - -class KumikoHelp(commands.HelpCommand): - def __init__(self): - super().__init__( # create our class with some aliases and cooldown - command_attrs={ - "help": "The help command for the bot", - "cooldown": commands.CooldownMapping.from_cooldown( - 1, 3.0, commands.BucketType.user - ), - "aliases": ["commands"], - } - ) - - async def send(self, **kwargs): - """a shortcut to sending to get_destination""" - await self.get_destination().send(**kwargs) - - async def send_bot_help(self, mapping): - """triggers when a `help` is called""" - ctx = self.context - embed = Embed(title=f"{ctx.me.display_name} Help") - embed.set_thumbnail(url=ctx.me.display_avatar) - usable = 0 - - for ( - cog, - cmds, - ) in mapping.items(): # iterating through our mapping of cog: commands - if filtered_commands := await self.filter_commands(cmds): - # if no commands are usable in this category, we don't want to display it - amount_commands = len(filtered_commands) - usable += amount_commands - if cog: # getting attributes dependent on if a cog exists or not - name = cog.qualified_name - description = cog.description or "No description" - else: - name = "No" - description = "Commands with no category" - - embed.add_field( - name=f"{name} Category [{amount_commands}]", value=description - ) - - # embed.description = f"{len(bot.commands)} commands | {usable} usable" - - await self.send(embed=embed) - - async def send_command_help(self, command): - """triggers when a `help ` is called""" - signature = self.get_command_signature( - command - ) # get_command_signature gets the signature of a command in [optional] - embed = Embed(title=signature, description=command.help or "No help found...") - - if cog := command.cog: - embed.add_field(name="Category", value=cog.qualified_name) - - can_run = "No" - # command.can_run to test if the cog is usable - with contextlib.suppress(commands.CommandError): - if await command.can_run(self.context): - can_run = "Yes" - - embed.add_field(name="Usable", value=can_run) - - if command._buckets and ( - cooldown := command._buckets._cooldown - ): # use of internals to get the cooldown of the command - embed.add_field( - name="Cooldown", - value=f"{cooldown.rate} per {cooldown.per:.0f} seconds", - ) - - await self.send(embed=embed) - - async def send_help_embed( - self, title, description, commands - ): # a helper function to add commands to an embed - embed = Embed(title=title, description=description or "No help found...") - - if filtered_commands := await self.filter_commands(commands): - for command in filtered_commands: - embed.add_field( - name=self.get_command_signature(command), - value=command.help or "No help found...", - ) - - await self.send(embed=embed) - - async def send_group_help(self, group): - """triggers when a `help ` is called""" - title = self.get_command_signature(group) - await self.send_help_embed(title, group.help, group.commands) - - async def send_cog_help(self, cog): - """triggers when a `help ` is called""" - title = cog.qualified_name or "No" - await self.send_help_embed( - f"{title} Category", cog.description, cog.get_commands() - ) diff --git a/Bot/Libs/utils/help/kumiko_help_paginated.py b/Bot/Libs/utils/help/kumiko_help_paginated.py index eb7e879f..9c483eec 100644 --- a/Bot/Libs/utils/help/kumiko_help_paginated.py +++ b/Bot/Libs/utils/help/kumiko_help_paginated.py @@ -4,18 +4,9 @@ import discord from discord.ext import commands, menus +from Libs.utils import MessageConstants from Libs.utils.pages import KumikoPages -# class BotCategories(discord.ui.Select): -# def __init__(self, cogs: List[commands.Cog]) -> None: -# options = [ -# discord.SelectOption(label=cog.qualified_name or "No", description=cog.description) -# for cog in cogs if cog.qualified_name not in ["DevTools", "ErrorHandler", "IPCServer"] -# ] -# super().__init__(placeholder="Select a category...", options=options) - -# async def callback(self, interaction: discord.Interaction): - # RGB Colors: # Pink (255, 161, 231) - Used for the main bot page # Lavender (197, 184, 255) - Used for cog and group pages @@ -139,7 +130,7 @@ async def rebind( await self.source._prepare_once() page = await self.source.get_page(0) - kwargs = await self._get_kwargs_from_page(page) + kwargs = await self.get_kwargs_from_page(page) self._update_labels(0) await interaction.response.edit_message(**kwargs, view=self) @@ -163,7 +154,6 @@ def format_page(self, menu: HelpMenu, page: Any): embed = discord.Embed( title="Bot Help", colour=discord.Colour.from_rgb(255, 161, 231) ) - # embed.description = "help" embed.description = inspect.cleandoc( f""" Hello! Welcome to the help page. @@ -180,7 +170,6 @@ def format_page(self, menu: HelpMenu, page: Any): inline=False, ) - # created_at = time.format_dt(menu.ctx.bot.user.created_at, 'F') if self.index == 0: embed.add_field( name="About Kumiko", @@ -292,7 +281,9 @@ def common_command_formatting(self, embed_like, command): if command.description: embed_like.description = f"{command.description}\n\n{command.help}" else: - embed_like.description = command.help or "No help found..." + embed_like.description = ( + command.help or MessageConstants.NO_HELP_FOUND.value + ) async def send_command_help(self, command): # No pagination necessary for a single command. @@ -313,135 +304,3 @@ async def send_group_help(self, group): self.common_command_formatting(source, group) menu = HelpMenu(source, ctx=self.context) await menu.start() - - # def __init__(self) -> None: - # super().__init__( - # command_attrs={ - # "help": "The help command for the bot", - # "cooldown": commands.CooldownMapping.from_cooldown( - # 1, 3.0, commands.BucketType.user - # ), - # "aliases": ["commands"], - # } - # ) - - # async def send(self, **kwargs) -> None: - # """a shortcut to sending to get_destination""" - # await self.get_destination().send(**kwargs) - - # async def help_embed( - # self, title: str, description: str, commands: List[commands.Command] - # ) -> None: - # """The default help embed builder - - # Mainly used so we don't repeat ourselves when building help embeds - - # Args: - # title (str): The title of the embed. Usually the name of the cog, group, etc - # description (str): The description of the embed. Usually the desc of the cog or group - # commands (List[commands.Command]): List of commands - # """ - # filteredCommands = await self.filter_commands(commands) - # fieldSource = [ - # (self.get_command_signature(command), command.help or "No help found...") - # for command in filteredCommands - # ] - # sources = FieldPageSource( - # entries=fieldSource, - # per_page=5, - # inline=False, - # clear_description=False, - # title=title or "No", - # description=description or "No help found...", - # ) - # pages = KumikoPages(source=sources, ctx=self.context) - # await pages.start() - - # async def send_bot_help( - # self, mapping: Mapping[Optional[commands.Cog], List[commands.Command]] - # ) -> None: - # """Generates the help embed when the default help command is called - - # Args: - # mapping (Mapping[Optional[commands.Cog], List[commands.Command]]): Mapping of cogs to commands - # """ - # ctx = self.context - # embed = Embed(title=f"{ctx.me.display_name} Help") - # embed.set_thumbnail(url=ctx.me.display_avatar) - # embed.description = f"{ctx.me.display_name} is a multipurpose bot built with freedom and choice in mind." - # usable = 0 - - # for ( - # cog, - # cmds, - # ) in mapping.items(): # iterating through our mapping of cog: commands - # if filtered_commands := await self.filter_commands(cmds): - # # if no commands are usable in this category, we don't want to display it - # amount_commands = len(filtered_commands) - # usable += amount_commands - # if cog: # getting attributes dependent on if a cog exists or not - # name = cog.qualified_name - # description = cog.description or "No description" - # else: - # name = "No" - # description = "Commands with no category" - - # embed.add_field( - # name=f"{name} Category [{amount_commands}]", value=description - # ) - - # # embed.description = f"{len(ctx.commands)} commands | {usable} usable" - - # await self.send(embed=embed) - - # async def send_command_help(self, command: commands.Command) -> None: - # """Triggers when a `help ` is called - - # Args: - # command (commands.Command): The command to get help for - # """ - # signature = self.get_command_signature( - # command - # ) # get_command_signature gets the signature of a command in [optional] - # embed = Embed(title=signature, description=command.help or "No help found...") - - # if cog := command.cog: - # embed.add_field(name="Category", value=cog.qualified_name) - - # can_run = "No" - # # command.can_run to test if the cog is usable - # with contextlib.suppress(commands.CommandError): - # if await command.can_run(self.context): - # can_run = "Yes" - - # embed.add_field(name="Usable", value=can_run) - - # if command._buckets and ( - # cooldown := command._buckets._cooldown - # ): # use of internals to get the cooldown of the command - # embed.add_field( - # name="Cooldown", - # value=f"{cooldown.rate} per {cooldown.per:.0f} seconds", - # ) - - # await self.send(embed=embed) - - # async def send_cog_help(self, cog: commands.Cog) -> None: - # """Send the help command when a `help ` is called - - # Args: - # cog (commands.Cog): The cog requested - # """ - # title = cog.qualified_name or "No" - # await self.help_embed( - # title=f"{title} Category", - # description=cog.description, - # commands=cog.get_commands(), - # ) - - # async def send_group_help(self, group): - # """triggers when a `help ` is called""" - # title = self.get_command_signature(group) - # await self.help_embed( - # title=title, description=group.help, commands=group.commands - # ) diff --git a/Bot/Libs/utils/kumiko_logger.py b/Bot/Libs/utils/kumiko_logger.py index 0e6641c8..db1036c1 100644 --- a/Bot/Libs/utils/kumiko_logger.py +++ b/Bot/Libs/utils/kumiko_logger.py @@ -5,6 +5,9 @@ from typing import Optional, Type, TypeVar import discord +from cysystemd import journal + +from .utils import is_docker BE = TypeVar("BE", bound=BaseException) @@ -14,8 +17,8 @@ def __init__(self) -> None: self.self = self def filter(self, record: logging.LogRecord) -> bool: - matchRegex = r"(connection\s[open|closed])" - if bool(re.search(matchRegex, record.msg)): + match_regex = r"(connection\s[open|closed])" + if bool(re.search(match_regex, record.msg)): return False return True @@ -44,6 +47,8 @@ def __enter__(self) -> None: ) handler.setFormatter(fmt) self.log.addHandler(handler) + if not is_docker(): + self.log.addHandler(journal.JournaldLogHandler()) discord.utils.setup_logging(formatter=fmt) def __exit__( diff --git a/Bot/Libs/utils/message_constants.py b/Bot/Libs/utils/message_constants.py new file mode 100644 index 00000000..114b1d8e --- /dev/null +++ b/Bot/Libs/utils/message_constants.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class MessageConstants(Enum): + NO_DM = "You can't use this command in private messages or DMs" + TIMEOUT = "You took too long. Goodbye." + NO_JOB = "You either don't own this job or the job doesn't exist. Try again." + NO_REASON = "No reason provided" + NO_PERM_JOB = ( + "Either you don't own any jobs or you have no permission to delete those jobs" + ) + NO_HELP_FOUND = "No help found..." diff --git a/Bot/Libs/utils/pages/paginator.py b/Bot/Libs/utils/pages/paginator.py index d0d1442c..f6d555af 100644 --- a/Bot/Libs/utils/pages/paginator.py +++ b/Bot/Libs/utils/pages/paginator.py @@ -1,6 +1,5 @@ from __future__ import annotations -import traceback from typing import Any, Dict, Optional import discord @@ -50,7 +49,7 @@ def fill_items(self) -> None: self.add_item(self.numbered_page) self.add_item(self.stop_pages) - async def _get_kwargs_from_page(self, page: int) -> Dict[str, Any]: + async def get_kwargs_from_page(self, page: int) -> Dict[str, Any]: value = await discord.utils.maybe_coroutine(self.source.format_page, self, page) if isinstance(value, dict): return value @@ -66,7 +65,7 @@ async def show_page( ) -> None: page = await self.source.get_page(page_number) self.current_page = page_number - kwargs = await self._get_kwargs_from_page(page) + kwargs = await self.get_kwargs_from_page(page) self._update_labels(page_number) if kwargs: if interaction.response.is_done(): @@ -146,31 +145,31 @@ async def on_error( "An unknown error occurred, sorry", ephemeral=True ) - try: - exc = "".join( - traceback.format_exception( - type(error), error, error.__traceback__, chain=False - ) - ) - embed = discord.Embed( - title=f"{self.source.__class__.__name__} Error", - description=f"```py\n{exc}\n```", - timestamp=interaction.created_at, - colour=0xCC3366, - ) - embed.add_field( - name="User", value=f"{interaction.user} ({interaction.user.id})" - ) - embed.add_field( - name="Guild", value=f"{interaction.guild} ({interaction.guild_id})" - ) - embed.add_field( - name="Channel", - value=f"{interaction.channel} ({interaction.channel_id})", - ) - await self.ctx.bot.stats_webhook.send(embed=embed) - except discord.HTTPException: - pass + # try: + # exc = "".join( + # traceback.format_exception( + # type(error), error, error.__traceback__, chain=False + # ) + # ) + # embed = discord.Embed( + # title=f"{self.source.__class__.__name__} Error", + # description=f"```py\n{exc}\n```", + # timestamp=interaction.created_at, + # colour=0xCC3366, + # ) + # embed.add_field( + # name="User", value=f"{interaction.user} ({interaction.user.id})" + # ) + # embed.add_field( + # name="Guild", value=f"{interaction.guild} ({interaction.guild_id})" + # ) + # embed.add_field( + # name="Channel", + # value=f"{interaction.channel} ({interaction.channel_id})", + # ) + # await self.ctx.bot.stats_webhook.send(embed=embed) # Probably will integrate this later + # except discord.HTTPException: + # pass async def start( self, *, content: Optional[str] = None, ephemeral: bool = False @@ -184,7 +183,7 @@ async def start( await self.source._prepare_once() page = await self.source.get_page(0) - kwargs = await self._get_kwargs_from_page(page) + kwargs = await self.get_kwargs_from_page(page) if content: kwargs.setdefault("content", content) diff --git a/Bot/Libs/utils/postgresql/__init__.py b/Bot/Libs/utils/postgresql/__init__.py deleted file mode 100644 index 9ef1b553..00000000 --- a/Bot/Libs/utils/postgresql/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# from .ctx import PrismaSessionManager -from .ensure_open_conns import ensureOpenPostgresConn -__all__ = ["ensureOpenPostgresConn"] diff --git a/Bot/Libs/utils/postgresql/ensure_open_conns.py b/Bot/Libs/utils/postgresql/ensure_open_conns.py deleted file mode 100644 index cb05380b..00000000 --- a/Bot/Libs/utils/postgresql/ensure_open_conns.py +++ /dev/null @@ -1,21 +0,0 @@ -import logging -from typing import Literal - -import asyncpg - - -async def ensureOpenPostgresConn(conn_pool: asyncpg.Pool) -> Literal[True]: - """Ensures that the current connection pulled from the pool can be run. - - Args: - conn_pool (asyncpg.Pool): The connection pool to get connections from. - - Returns: - Literal[True]: If successful, the coroutine will return True, otherwise it will raise an exception - """ - logger = logging.getLogger("discord") - async with conn_pool.acquire() as conn: - connStatus = conn.is_closed() - if connStatus is False: - logger.info("PostgreSQL server is up") - return True diff --git a/Bot/Libs/utils/prefix.py b/Bot/Libs/utils/prefix.py index 993bcd3c..2049e286 100644 --- a/Bot/Libs/utils/prefix.py +++ b/Bot/Libs/utils/prefix.py @@ -2,57 +2,28 @@ import discord -# TODO - Prevent people from setting up `/` prefixes. Doesn't help -# removed the type hinting bc of circular imports -# if there is a way to solve it, then it would be back - async def get_prefix(bot, msg: discord.Message) -> List[str]: if msg.guild is None: return bot.default_prefix - # return commands.when_mentioned_or(bot.default_prefix)(bot, msg) - cachedPrefix = bot.prefixes.get(msg.guild.id) - if cachedPrefix is None: + cached_prefix = bot.prefixes.get(msg.guild.id) + if cached_prefix is None: async with bot.pool.acquire() as conn: query = """ SELECT prefix FROM guild WHERE id = $1; """ - updateQuery = """ + update_query = """ UPDATE guild SET prefix = $1 WHERE id = $2; """ - fetchPrefix = await conn.fetchval(query, msg.guild.id) - if fetchPrefix: - bot.prefixes[msg.guild.id] = fetchPrefix - # return commands.when_mentioned_or(bot.prefixes[msg.guild.id])(bot, msg) + fetch_prefix = await conn.fetchval(query, msg.guild.id) + if fetch_prefix: + bot.prefixes[msg.guild.id] = fetch_prefix return bot.prefixes[msg.guild.id] else: - await conn.execute(updateQuery, [bot.default_prefix], msg.guild.id) + await conn.execute(update_query, [bot.default_prefix], msg.guild.id) bot.prefixes[msg.guild.id] = bot.default_prefix return bot.prefixes[msg.guild.id] - # return commands.when_mentioned_or(bot.prefixes[msg.guild.id])(bot, msg) else: return bot.prefixes[msg.guild.id] - # return commands.when_mentioned_or(bot.prefixes[msg.guild.id])(bot, msg) - - -def validatePrefix(prefixes: List[str], new_prefix: str) -> bool: - """Validates whether the prefix given is valid or not. - - The rules for the prefix being valid goes as follows: - - 1. The new prefix must not be in the prefixes list - 2. The new prefix cannot be "/". - a. If a prefix containing "/" is followed with another character (eg "/e"), this is considered valid - - Args: - prefixes (List[str]): The list of prefixes associated with the guild. This is usually found within the prefix cache - new_prefix (str): The new prefix to validate - - Returns: - bool: Whether the prefix is valid or not - """ - return new_prefix in prefixes or ( - new_prefix.startswith("/") and len(new_prefix) == 1 - ) diff --git a/Bot/Libs/utils/redis/__init__.py b/Bot/Libs/utils/redis/__init__.py deleted file mode 100644 index d6d53d20..00000000 --- a/Bot/Libs/utils/redis/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .connections import ensureOpenRedisConn - -__all__ = ["ensureOpenRedisConn"] diff --git a/Bot/Libs/utils/redis/connections.py b/Bot/Libs/utils/redis/connections.py deleted file mode 100644 index 1b9767f2..00000000 --- a/Bot/Libs/utils/redis/connections.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging -from typing import Literal - -import redis.asyncio as redis -from redis.asyncio.connection import ConnectionPool - -logger = logging.getLogger("discord") - - -async def ensureOpenRedisConn(redis_pool: ConnectionPool) -> Literal[True]: - """Pings the Redis server to check if it's open or not - - Args: - connection_pool (Union[ConnectionPool, None]): The supplied connection pool. If none, it will be created automatically - - Returns: - Literal[True]: If successful, the coroutine will return True, otherwise it will raise an exception - """ - r: redis.Redis = redis.Redis(connection_pool=redis_pool) - resultPing = await r.ping() - if resultPing: - logger.info("Sucessfully connected to the Redis server") - return True diff --git a/Bot/Libs/utils/time.py b/Bot/Libs/utils/time.py new file mode 100644 index 00000000..fbd73cb1 --- /dev/null +++ b/Bot/Libs/utils/time.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import datetime +from typing import Optional, Sequence + +from dateutil.relativedelta import relativedelta + + +class Plural: + def __init__(self, value: int): + self.value: int = value + + def __format__(self, format_spec: str) -> str: + v = self.value + singular, sep, plural = format_spec.partition("|") + plural = plural or f"{singular}s" + if abs(v) != 1: + return f"{v} {plural}" + return f"{v} {singular}" + + +def human_join(seq: Sequence[str], delim: str = ", ", final: str = "or") -> str: + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +# The old system does work, but as noted, can be inaccurate +# This system is from RDanny and should provide more accurate results +def human_timedelta( + dt: datetime.datetime, + *, + source: Optional[datetime.datetime] = None, + accuracy: Optional[int] = 3, + brief: bool = False, + suffix: bool = True, +) -> str: + now = source or datetime.datetime.now(datetime.timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + + if now.tzinfo is None: + now = now.replace(tzinfo=datetime.timezone.utc) + + # Microsecond free zone + now = now.replace(microsecond=0) + dt = dt.replace(microsecond=0) + + # This implementation uses relativedelta instead of the much more obvious + # divmod approach with seconds because the seconds approach is not entirely + # accurate once you go over 1 week in terms of accuracy since you have to + # hardcode a month as 30 or 31 days. + # A query like "11 months" can be interpreted as "!1 months and 6 days" + if dt > now: + delta = relativedelta(dt, now) + output_suffix = "" + else: + delta = relativedelta(now, dt) + output_suffix = " ago" if suffix else "" + + attrs = [ + ("year", "y"), + ("month", "mo"), + ("day", "d"), + ("hour", "h"), + ("minute", "m"), + ("second", "s"), + ] + + output = [] + for attr, brief_attr in attrs: + elem = getattr(delta, attr + "s") + if not elem: + continue + + if attr == "day": + weeks = delta.weeks + if weeks: + elem -= weeks * 7 + if not brief: + output.append(format(Plural(weeks), "week")) + else: + output.append(f"{weeks}w") + + if elem <= 0: + continue + + if brief: + output.append(f"{elem}{brief_attr}") + else: + output.append(format(Plural(elem), attr)) + + if accuracy is not None: + output = output[:accuracy] + + if len(output) == 0: + return "now" + else: + if not brief: + return human_join(output, final="and") + output_suffix + else: + return " ".join(output) + output_suffix diff --git a/Bot/Libs/utils/utils.py b/Bot/Libs/utils/utils.py index c7dc8998..35760602 100644 --- a/Bot/Libs/utils/utils.py +++ b/Bot/Libs/utils/utils.py @@ -1,14 +1,17 @@ +import os import re import ssl from datetime import datetime, timedelta -from typing import Any, Dict, Union +from typing import Any, Dict, Optional, TypeVar, Union import ciso8601 +T = TypeVar("T", str, None) + # From https://stackoverflow.com/questions/4628122/how-to-construct-a-timedelta-object-from-a-simple-string # Answer: https://stackoverflow.com/a/51916936 # datetimeParseRegex = re.compile(r'^((?P[\.\d]+?)d)?((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$') -datetimeParseRegex = re.compile( +datetime_regex = re.compile( r"^((?P[\.\d]+?)w)? *" r"^((?P[\.\d]+?)d)? *" r"((?P[\.\d]+?)h)? *" @@ -17,7 +20,7 @@ ) -def parseDatetime(datetime: Union[datetime, str]) -> datetime: +def parse_datetime(datetime: Union[datetime, str]) -> datetime: """Parses a datetime object or a string into a datetime object Args: @@ -31,7 +34,7 @@ def parseDatetime(datetime: Union[datetime, str]) -> datetime: return datetime -def encodeDatetime(dict: Dict[str, Any]) -> Dict[str, Any]: +def encode_datetime(dict: Dict[str, Any]) -> Dict[str, Any]: """Takes a dictionary and encodes all datetime objects into ISO 8601 strings Args: @@ -46,7 +49,7 @@ def encodeDatetime(dict: Dict[str, Any]) -> Dict[str, Any]: return dict -def parseSubreddit(subreddit: Union[str, None]) -> str: +def parse_subreddit(subreddit: Union[str, None]) -> str: """Parses a subreddit name to be used in a reddit url Args: @@ -60,7 +63,7 @@ def parseSubreddit(subreddit: Union[str, None]) -> str: return re.sub(r"^[r/]{2}", "", subreddit, re.IGNORECASE) -def parseTimeStr(time_str: str) -> Union[timedelta, None]: +def parse_time_str(time_str: str) -> Union[timedelta, None]: """Parse a time string e.g. (2h13m) into a timedelta object. Taken straight from https://stackoverflow.com/a/4628148 @@ -71,7 +74,7 @@ def parseTimeStr(time_str: str) -> Union[timedelta, None]: Returns: datetime.timedelta: A datetime.timedelta object """ - parts = datetimeParseRegex.match(time_str) + parts = datetime_regex.match(time_str) if not parts: return parts = parts.groupdict() @@ -82,8 +85,32 @@ def parseTimeStr(time_str: str) -> Union[timedelta, None]: return timedelta(**time_params) -def setup_ssl() -> ssl.SSLContext: - sslctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - sslctx.check_hostname = False - sslctx.verify_mode = ssl.CERT_NONE +def setup_ssl( + ca_path: Union[str, None], + cert_path: str, + key_path: Union[str, None], + key_password: Union[str, None], +) -> ssl.SSLContext: + sslctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path) + sslctx.check_hostname = True + sslctx.load_cert_chain(cert_path, key_path, key_password) return sslctx + + +def is_docker() -> bool: + path = "/proc/self/cgroup" + return os.path.exists("/.dockerenv") or ( + os.path.isfile(path) and any("docker" in line for line in open(path)) + ) + + +def tick(opt: Optional[bool], label: Optional[str] = None) -> str: + lookup = { + True: "<:greenTick:330090705336664065>", + False: "<:redTick:330090723011592193>", + None: "<:greyTick:563231201280917524>", + } + emoji = lookup.get(opt, "<:redTick:330090723011592193>") + if label is not None: + return f"{emoji}: {label}" + return emoji diff --git a/Bot/kumikobot.py b/Bot/kumikobot.py index a7cdab45..d4e82645 100644 --- a/Bot/kumikobot.py +++ b/Bot/kumikobot.py @@ -27,6 +27,10 @@ KUMIKO_TOKEN = os.environ["KUMIKO_TOKEN"] DEV_MODE = os.getenv("DEV_MODE") in ("True", "TRUE") SSL = os.getenv("SSL") in ("True", "TRUE") +SSL_CA = os.getenv("SSL_CA") +SSL_CERT = os.environ["SSL_CERT"] +SSL_KEY = os.getenv("SSL_KEY") +SSL_KEY_PASSWORD = os.getenv("SSL_KEY_PASSWORD") POSTGRES_URI = os.environ["POSTGRES_URI"] REDIS_URI = os.environ["REDIS_URI"] @@ -41,7 +45,14 @@ async def main() -> None: command_timeout=60, max_size=20, min_size=20, - ssl=setup_ssl() if SSL is True else None, + ssl=setup_ssl( + ca_path=SSL_CA, + cert_path=SSL_CERT, + key_path=SSL_KEY, + key_password=SSL_KEY_PASSWORD, + ) + if SSL is True + else None, ) as pool, KumikoCPManager(uri=REDIS_URI, max_size=25) as redis_pool: async with KumikoCore( intents=intents, diff --git a/Bot/kumikocore.py b/Bot/kumikocore.py index b24a5c67..700b1cb1 100644 --- a/Bot/kumikocore.py +++ b/Bot/kumikocore.py @@ -5,12 +5,10 @@ import asyncpg import discord from aiohttp import ClientSession -from Cogs import EXTENSIONS +from Cogs import EXTENSIONS, VERSION from discord.ext import commands -from Libs.utils import get_prefix +from Libs.utils import ensure_postgres_conn, ensure_redis_conn, get_prefix from Libs.utils.help import KumikoHelpPaginated -from Libs.utils.postgresql import ensureOpenPostgresConn -from Libs.utils.redis import ensureOpenRedisConn from lru import LRU from redis.asyncio.connection import ConnectionPool @@ -82,6 +80,15 @@ def redis_pool(self) -> ConnectionPool: """ return self._redis_pool + @property + def version(self) -> str: + """The version of Kumiko + + Returns: + str: The version of Kumiko + """ + return str(VERSION) + # It is preffered in this case to keep an LRU cache instead of a regular Dict cache # For example, if an running instance keeps 100 entries ({guild_id: prefix}) # then this would take up too much memory. @@ -103,14 +110,14 @@ def prefixes(self) -> LRU: """ return self._prefixes - async def fsWatcher(self) -> None: - cogsPath = SyncPath(__file__).parent.joinpath("Cogs") - async for changes in awatch(cogsPath): - changesList = list(changes)[0] - if changesList[0].modified == 2: - reloadFile = SyncPath(changesList[1]) - self.logger.info(f"Reloading extension: {reloadFile.name[:-3]}") - await self.reload_extension(f"Cogs.{reloadFile.name[:-3]}") + async def fs_watcher(self) -> None: + cogs_path = SyncPath(__file__).parent.joinpath("Cogs") + async for changes in awatch(cogs_path): + changes_list = list(changes)[0] + if changes_list[0].modified == 2: + reload_file = SyncPath(changes_list[1]) + self.logger.info(f"Reloading extension: {reload_file.name[:-3]}") + await self.reload_extension(f"Cogs.{reload_file.name[:-3]}") async def setup_hook(self) -> None: def stop(): @@ -122,14 +129,16 @@ def stop(): self.logger.debug(f"Loaded extension: {cog}") await self.load_extension(cog) - self.loop.create_task(ensureOpenPostgresConn(self._pool)) - self.loop.create_task(ensureOpenRedisConn(self._redis_pool)) + self.loop.create_task(ensure_postgres_conn(self._pool)) + self.loop.create_task(ensure_redis_conn(self._redis_pool)) if self.dev_mode is True and _fsw is True: self.logger.info("Dev mode is enabled. Loading Jishaku and FSWatcher") - self.loop.create_task(self.fsWatcher()) + self.loop.create_task(self.fs_watcher()) await self.load_extension("jishaku") async def on_ready(self): - currUser = None if self.user is None else self.user.name - self.logger.info(f"{currUser} is fully ready!") + if not hasattr(self, "uptime"): + self.uptime = discord.utils.utcnow() + curr_user = None if self.user is None else self.user.name + self.logger.info(f"{curr_user} is fully ready!") diff --git a/Envs/dev.env b/Envs/dev.env index 2792abac..4c6bf7da 100644 --- a/Envs/dev.env +++ b/Envs/dev.env @@ -12,7 +12,7 @@ DEV_MODE=True # For the migrations system # DO NOT TOUCH THIS UNLESS IT IS TO UPGRADE -TARGET_REVISION=rev10 +TARGET_REVISION=rev13 # Search Cog API Keys # THESE WILL BREAK THE COMMANDS IF YOU DO NOT HAVE THE KEYS HERE diff --git a/Envs/docker.env b/Envs/docker.env index 42676334..0c9114f0 100644 --- a/Envs/docker.env +++ b/Envs/docker.env @@ -8,7 +8,7 @@ KUMIKO_TOKEN=token # For the migrations system # DO NOT TOUCH THIS UNLESS IT IS TO UPGRADE -TARGET_REVISION=rev10 +TARGET_REVISION=rev13 # Search Cog API Keys # THESE WILL BREAK THE COMMANDS IF YOU DO NOT HAVE THE KEYS HERE diff --git a/Envs/prod.env b/Envs/prod.env index 68159171..facb40cc 100644 --- a/Envs/prod.env +++ b/Envs/prod.env @@ -9,11 +9,16 @@ KUMIKO_TOKEN=token # For the migrations system # DO NOT TOUCH THIS UNLESS IT IS TO UPGRADE -TARGET_REVISION=rev10 +TARGET_REVISION=rev13 # Enable SSL for production servers -# SSL does decrease performance +# SSL does decrease performance, but generally will be much more secure +# SSL mode is verify-full SSL=False +SSL_CA=ca.pem +SSL_CERT=cert.pem +SSL_KEY=key.pem +SSL_KEY_PASSWORD=passwd # Search Cog API Keys # THESE WILL BREAK THE COMMANDS IF YOU DO NOT HAVE THE KEYS HERE diff --git a/Kumiko-Docs/requirements.txt b/Kumiko-Docs/requirements.txt index bb6b3da8..cca30e52 100644 --- a/Kumiko-Docs/requirements.txt +++ b/Kumiko-Docs/requirements.txt @@ -1,43 +1,43 @@ alabaster==0.7.13 ; python_version >= "3.8" and python_version < "4.0" babel==2.12.1 ; python_version >= "3.8" and python_version < "4.0" beautifulsoup4==4.12.2 ; python_version >= "3.8" and python_version < "4.0" -certifi==2023.07.22 ; python_version >= "3.8" and python_version < "4.0" -charset-normalizer==3.1.0 ; python_version >= "3.8" and python_version < "4.0" +certifi==2023.7.22 ; python_version >= "3.8" and python_version < "4.0" +charset-normalizer==3.2.0 ; python_version >= "3.8" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0" contourpy==1.1.0 ; python_version >= "3.8" and python_version < "4.0" cycler==0.11.0 ; python_version >= "3.8" and python_version < "4.0" docutils==0.20.1 ; python_version >= "3.8" and python_version < "4.0" -fonttools==4.40.0 ; python_version >= "3.8" and python_version < "4.0" -furo==2023.5.20 ; python_version >= "3.8" and python_version < "4.0" +fonttools==4.42.0 ; python_version >= "3.8" and python_version < "4.0" +furo==2023.7.26 ; python_version >= "3.8" and python_version < "4.0" idna==3.4 ; python_version >= "3.8" and python_version < "4.0" imagesize==1.4.1 ; python_version >= "3.8" and python_version < "4.0" -importlib-metadata==6.7.0 ; python_version >= "3.8" and python_version < "3.10" -importlib-resources==5.12.0 ; python_version >= "3.8" and python_version < "3.10" +importlib-metadata==6.8.0 ; python_version >= "3.8" and python_version < "3.10" +importlib-resources==6.0.1 ; python_version >= "3.8" and python_version < "3.10" jinja2==3.1.2 ; python_version >= "3.8" and python_version < "4.0" kiwisolver==1.4.4 ; python_version >= "3.8" and python_version < "4.0" livereload==2.6.3 ; python_version >= "3.8" and python_version < "4.0" markdown-it-py==3.0.0 ; python_version >= "3.8" and python_version < "4.0" markupsafe==2.1.3 ; python_version >= "3.8" and python_version < "4.0" -matplotlib==3.7.1 ; python_version >= "3.8" and python_version < "4.0" +matplotlib==3.7.2 ; python_version >= "3.8" and python_version < "4.0" mdit-py-plugins==0.4.0 ; python_version >= "3.8" and python_version < "4.0" mdurl==0.1.2 ; python_version >= "3.8" and python_version < "4.0" myst-parser==2.0.0 ; python_version >= "3.8" and python_version < "4.0" numpy==1.24.4 ; python_version >= "3.8" and python_version < "4.0" packaging==23.1 ; python_version >= "3.8" and python_version < "4.0" -pillow==9.5.0 ; python_version >= "3.8" and python_version < "4.0" -pygments==2.15.1 ; python_version >= "3.8" and python_version < "4.0" -pyparsing==3.1.0 ; python_version >= "3.8" and python_version < "4.0" +pillow==10.0.0 ; python_version >= "3.8" and python_version < "4.0" +pygments==2.16.1 ; python_version >= "3.8" and python_version < "4.0" +pyparsing==3.0.9 ; python_version >= "3.8" and python_version < "4.0" python-dateutil==2.8.2 ; python_version >= "3.8" and python_version < "4.0" pytz==2023.3 ; python_version >= "3.8" and python_version < "3.9" -pyyaml==6.0 ; python_version >= "3.8" and python_version < "4.0" +pyyaml==6.0.1 ; python_version >= "3.8" and python_version < "4.0" requests==2.31.0 ; python_version >= "3.8" and python_version < "4.0" six==1.16.0 ; python_version >= "3.8" and python_version < "4.0" snowballstemmer==2.2.0 ; python_version >= "3.8" and python_version < "4.0" soupsieve==2.4.1 ; python_version >= "3.8" and python_version < "4.0" sphinx-autobuild==2021.3.14 ; python_version >= "3.8" and python_version < "4.0" -sphinx-basic-ng==1.0.0b1 ; python_version >= "3.8" and python_version < "4.0" +sphinx-basic-ng==1.0.0b2 ; python_version >= "3.8" and python_version < "4.0" sphinx-copybutton==0.5.2 ; python_version >= "3.8" and python_version < "4.0" -sphinx==7.0.1 ; python_version >= "3.8" and python_version < "4.0" +sphinx==7.1.2 ; python_version >= "3.8" and python_version < "4.0" sphinxcontrib-applehelp==1.0.4 ; python_version >= "3.8" and python_version < "4.0" sphinxcontrib-devhelp==1.0.2 ; python_version >= "3.8" and python_version < "4.0" sphinxcontrib-htmlhelp==2.0.1 ; python_version >= "3.8" and python_version < "4.0" @@ -45,6 +45,6 @@ sphinxcontrib-jsmath==1.0.1 ; python_version >= "3.8" and python_version < "4.0" sphinxcontrib-qthelp==1.0.3 ; python_version >= "3.8" and python_version < "4.0" sphinxcontrib-serializinghtml==1.1.5 ; python_version >= "3.8" and python_version < "4.0" sphinxext-opengraph==0.8.2 ; python_version >= "3.8" and python_version < "4.0" -tornado==6.3.2 ; python_version >= "3.8" and python_version < "4.0" -urllib3==2.0.3 ; python_version >= "3.8" and python_version < "4.0" -zipp==3.15.0 ; python_version >= "3.8" and python_version < "3.10" +tornado==6.3.3 ; python_version >= "3.8" and python_version < "4.0" +urllib3==2.0.4 ; python_version >= "3.8" and python_version < "4.0" +zipp==3.16.2 ; python_version >= "3.8" and python_version < "3.10" diff --git a/Kumiko-Docs/source/guides/dev/dev-contributing.rst b/Kumiko-Docs/source/guides/dev/dev-contributing.rst index ca4c47ff..b6a15c3b 100644 --- a/Kumiko-Docs/source/guides/dev/dev-contributing.rst +++ b/Kumiko-Docs/source/guides/dev/dev-contributing.rst @@ -21,13 +21,14 @@ Coding Style Variables ^^^^^^^^^^ -Most of the code written uses ``camelCasing`` for variables, ``PascalCasing`` for classes, and ``snake_casing`` for args. To sum it up: -- ``camelCasing`` for variables +Kumiko follows PEP8 naming conventions and standards. To sum it up: + +- ``snake_casing`` for variables, args, kwargs, and files - ``PascalCasing`` for classes -- ``snake_casing`` for args - ``ALL_CAPS`` for constants -- ``kebab-casing`` for files + +Ruff is used to lint and check whether the code meets PEP8 standards or not. In order to learn more about PEP8, see `this `_ guide. Formatting ^^^^^^^^^^^ @@ -55,7 +56,7 @@ Example Cog: self.bot = bot @commands.hybrid_command(name="hello") - async def myCommand(self, ctx: Context): + async def my_command(self, ctx: Context): """This is an example of a description for a slash command""" await ctx.send(f"Hello {ctx.user.name}!") diff --git a/Migrations/20230726_rev8_up_rev9.sql b/Migrations/20230726_rev8_up_rev9.sql index 3a5307c9..8ba5eb42 100644 --- a/Migrations/20230726_rev8_up_rev9.sql +++ b/Migrations/20230726_rev8_up_rev9.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS user_inv ( guild_id BIGINT, amount_owned INT DEFAULT 0, item_id INT, + UNIQUE (owner_id, item_id), FOREIGN KEY (item_id) REFERENCES eco_item (id) ON DELETE CASCADE ON UPDATE NO ACTION ); diff --git a/Migrations/20230801_rev9_up_rev10.sql b/Migrations/20230801_rev9_up_rev10.sql index 45c6d280..ee8a96ee 100644 --- a/Migrations/20230801_rev9_up_rev10.sql +++ b/Migrations/20230801_rev9_up_rev10.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS user_item_relations ( id SERIAL PRIMARY KEY, item_id INT, user_id BIGINT REFERENCES eco_user (id) ON DELETE CASCADE ON UPDATE NO ACTION, + UNIQUE (item_id, user_id), FOREIGN KEY (item_id) REFERENCES eco_item (id) ON DELETE CASCADE ON UPDATE NO ACTION ); diff --git a/Migrations/20230803_rev10_up_rev11.sql b/Migrations/20230803_rev10_up_rev11.sql new file mode 100644 index 00000000..030c1d99 --- /dev/null +++ b/Migrations/20230803_rev10_up_rev11.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS auction_house ( + id SERIAL PRIMARY KEY, + item_id INT, + user_id BIGINT, + guild_id BIGINT, + amount_listed INT, + listed_price INT, + listed_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'utc'), + UNIQUE (item_id, user_id), + FOREIGN KEY (item_id) REFERENCES eco_item (id) ON DELETE CASCADE ON UPDATE NO ACTION, + FOREIGN KEY (user_id) REFERENCES eco_user (id) ON DELETE CASCADE ON UPDATE NO ACTION +); + +CREATE TABLE IF NOT EXISTS ah_user_bridge ( + id SERIAL PRIMARY KEY, + user_id BIGINT REFERENCES eco_user (id) ON DELETE CASCADE ON UPDATE NO ACTION, + ah_item_id INT, + UNIQUE (user_id, ah_item_id), + FOREIGN KEY (ah_item_id) REFERENCES auction_house (id) ON DELETE CASCADE ON UPDATE NO ACTION +); + +CREATE INDEX IF NOT EXISTS auction_house_user_idx ON auction_house (user_id); +CREATE INDEX IF NOT EXISTS auction_house_item_idx ON auction_house (item_id); + +CREATE INDEX IF NOT EXISTS auction_house_listed_at ON auction_house (listed_at); +CREATE INDEX IF NOT EXISTS ah_user_bridge_user_idx ON ah_user_bridge (user_id); +CREATE INDEX IF NOT EXISTS ah_user_bridge_ah_item_idx ON ah_user_bridge (ah_item_id); diff --git a/Migrations/20230812_rev11_up_rev12.sql b/Migrations/20230812_rev11_up_rev12.sql new file mode 100644 index 00000000..2a1b7614 --- /dev/null +++ b/Migrations/20230812_rev11_up_rev12.sql @@ -0,0 +1,17 @@ +-- This migration is made to drop unused tables + +DROP INDEX IF EXISTS ah_user_bridge_user_idx; +DROP INDEX IF EXISTS ah_user_bridge_ah_item_idx; +DROP TABLE IF EXISTS ah_user_bridge; + +DROP INDEX IF EXISTS user_item_relations_item_idx; +DROP INDEX IF EXISTS user_item_relations_user_idx; +DROP TABLE IF EXISTS user_item_relations; + +DROP INDEX IF EXISTS guild_eco_user_uniq_idx; +DROP INDEX IF EXISTS guild_eco_user_uniq_user_idx; +DROP TABLE IF EXISTS guild_eco_user; + +DROP INDEX IF EXISTS job_output_item_id_idx; +DROP INDEX IF EXISTS job_output_job_id_idx; +DROP TABLE IF EXISTS job_output; \ No newline at end of file diff --git a/Migrations/20230815_rev12_up_rev13.sql b/Migrations/20230815_rev12_up_rev13.sql new file mode 100644 index 00000000..505c86f1 --- /dev/null +++ b/Migrations/20230815_rev12_up_rev13.sql @@ -0,0 +1 @@ +ALTER TABLE guild ADD COLUMN redirects BOOLEAN DEFAULT TRUE; diff --git a/README.md b/README.md index 54f46c86..83206f70 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ -[![Required Python Version](https://img.shields.io/badge/Python-3.8%20|%203.9%20|%203.10%20|%203.11-blue?logo=python&logoColor=white)](https://github.com/No767/Kumiko/blob/dev/pyproject.toml) [![CodeQL](https://github.com/No767/Kumiko/actions/workflows/codeql-analysis.yml/badge.svg?branch=dev)](https://github.com/No767/Kumiko/actions/workflows/codeql-analysis.yml) [![Snyk](https://github.com/No767/Kumiko/actions/workflows/snyk.yml/badge.svg?branch=dev)](https://github.com/No767/Kumiko/actions/workflows/snyk.yml) [![Lint](https://github.com/No767/Kumiko/actions/workflows/lint.yml/badge.svg)](https://github.com/No767/Kumiko/actions/workflows/lint.yml) [![Docker Build](https://github.com/No767/Kumiko/actions/workflows/docker-build.yml/badge.svg)](https://github.com/No767/Kumiko/actions/workflows/docker-build.yml) [![Tests](https://github.com/No767/Kumiko/actions/workflows/tests.yml/badge.svg)](https://github.com/No767/Kumiko/actions/workflows/tests.yml) ![Read the Docs](https://img.shields.io/readthedocs/kumiko?label=Docs&logo=readthedocs&logoColor=white) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/950cd812f1e04f0d813bb0298fdaa225)](https://www.codacy.com/gh/No767/Kumiko/dashboard?utm_source=github.com&utm_medium=referral&utm_content=No767/Kumiko&utm_campaign=Badge_Grade) [![codecov](https://codecov.io/gh/No767/Kumiko/branch/dev/graph/badge.svg?token=CwcMp3LIFx)](https://codecov.io/gh/No767/Kumiko) [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/No767/Kumiko?label=Release&logo=github&sort=semver)](https://github.com/No767/Kumiko/releases) ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/no767/kumiko?label=Docker%20Release&logo=docker&logoColor=white&sort=semver) [![GitHub License](https://img.shields.io/github/license/No767/Rin?label=License&logo=github)](https://github.com/No767/Kumiko/blob/dev/LICENSE) [![Kumiko](https://img.shields.io/badge/Kumiko-Oumae-white)](https://hibike-euphonium.fandom.com/wiki/Kumiko_Oumae) +[![Required Python Version](https://img.shields.io/badge/Python-3.8%20|%203.9%20|%203.10%20|%203.11-blue?logo=python&logoColor=white)](https://github.com/No767/Kumiko/blob/dev/pyproject.toml) [![CodeQL](https://github.com/No767/Kumiko/actions/workflows/codeql-analysis.yml/badge.svg?branch=dev)](https://github.com/No767/Kumiko/actions/workflows/codeql-analysis.yml) [![Snyk](https://github.com/No767/Kumiko/actions/workflows/snyk.yml/badge.svg?branch=dev)](https://github.com/No767/Kumiko/actions/workflows/snyk.yml) [![Lint](https://github.com/No767/Kumiko/actions/workflows/lint.yml/badge.svg)](https://github.com/No767/Kumiko/actions/workflows/lint.yml) [![Docker Build](https://github.com/No767/Kumiko/actions/workflows/docker-build.yml/badge.svg)](https://github.com/No767/Kumiko/actions/workflows/docker-build.yml) [![Tests](https://github.com/No767/Kumiko/actions/workflows/tests.yml/badge.svg)](https://github.com/No767/Kumiko/actions/workflows/tests.yml) ![Read the Docs](https://img.shields.io/readthedocs/kumiko?label=Docs&logo=readthedocs&logoColor=white) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/950cd812f1e04f0d813bb0298fdaa225)](https://www.codacy.com/gh/No767/Kumiko/dashboard?utm_source=github.com&utm_medium=referral&utm_content=No767/Kumiko&utm_campaign=Badge_Grade) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=No767_Kumiko&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=No767_Kumiko) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=No767_Kumiko&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=No767_Kumiko) [![codecov](https://codecov.io/gh/No767/Kumiko/branch/dev/graph/badge.svg?token=CwcMp3LIFx)](https://codecov.io/gh/No767/Kumiko) [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/No767/Kumiko?label=Release&logo=github&sort=semver)](https://github.com/No767/Kumiko/releases) ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/no767/kumiko?label=Docker%20Release&logo=docker&logoColor=white&sort=semver) [![GitHub License](https://img.shields.io/github/license/No767/Rin?label=License&logo=github)](https://github.com/No767/Kumiko/blob/dev/LICENSE) [![Kumiko](https://img.shields.io/badge/Kumiko-Oumae-white)](https://hibike-euphonium.fandom.com/wiki/Kumiko_Oumae) A multipurpose Discord bot built with freedom and choice in mind @@ -40,9 +40,9 @@ Kumiko uses both a prefixed command and slash commands. The currently supported ## Inviting the Bot -Currently under beta stages. Not ready for release yet. Currently Kumiko is under v0, which means it's beta-level software as of now, and thus should not be used in production. Kumiko is subject to breaking changes within this version. Kumiko will be ready to be invited once it reaches to v1, which is production-ready software. +Currently Kumiko is nearly ready for release. Although Kumiko is still under v0, which means that there could be chances of major breaking changes, the latest releases are stable and production ready. Kumiko will be soon production ready and expect an invite for Kumiko fairly soon. -> Beta production versions of Kumiko may soon be releasing for public testing... Keep an eye out for that! +> Staging versions of Kumiko may soon be releasing for public testing... Keep an eye out for that! ## Support diff --git a/Requirements/dev.txt b/Requirements/dev.txt new file mode 100644 index 00000000..dcc3eb26 --- /dev/null +++ b/Requirements/dev.txt @@ -0,0 +1,53 @@ +aiodns==3.0.0 ; python_version >= "3.8" and python_version < "4.0" +aiofiles==0.8.0 ; python_version >= "3.8" and python_version < "4.0" +aiohttp==3.8.5 ; python_version >= "3.8" and python_version < "4.0" +aiosignal==1.3.1 ; python_version >= "3.8" and python_version < "4.0" +aiosqlite==0.17.0 ; python_version >= "3.8" and python_version < "4.0" +async-timeout==4.0.3 ; python_version >= "3.8" and python_version < "4.0" +asyncpg-trek==0.3.1 ; python_version >= "3.8" and python_version < "4" +asyncpg==0.28.0 ; python_version >= "3.8" and python_version < "4.0" +asyncpraw==7.7.1 ; python_version >= "3.8" and python_version < "4.0" +asyncprawcore==2.3.0 ; python_version >= "3.8" and python_version < "4.0" +attrs==23.1.0 ; python_version >= "3.8" and python_version < "4.0" +backoff==2.2.1 ; python_version >= "3.8" and python_version < "4.0" +better-ipc==2.0.3 ; python_version >= "3.8" and python_version < "4.0" +brotli==1.0.9 ; python_version >= "3.8" and python_version < "4.0" +certifi==2023.7.22 ; python_version >= "3.8" and python_version < "4.0" +cffi==1.15.1 ; python_version >= "3.8" and python_version < "4.0" +charset-normalizer==3.2.0 ; python_version >= "3.8" and python_version < "4.0" +ciso8601==2.3.0 ; python_version >= "3.8" and python_version < "4.0" +cysystemd==1.5.4 ; python_version >= "3.8" and python_version < "4" +cython==3.0.0 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "win32" +discord-ext-menus @ git+https://github.com/Rapptz/discord-ext-menus@8686b5d1bbc1d3c862292eb436ab630d6e9c9b53 ; python_version >= "3.8" and python_version < "4.0" +discord-py==2.3.2 ; python_version >= "3.8" and python_version < "4.0" +discord-py[voice]==2.3.2 ; python_version >= "3.8" and python_version < "4.0" +faust-cchardet==2.1.19 ; python_version >= "3.8" and python_version < "4.0" +frozenlist==1.4.0 ; python_version >= "3.8" and python_version < "4.0" +gql[aiohttp]==3.4.1 ; python_version >= "3.8" and python_version < "4.0" +graphql-core==3.2.3 ; python_version >= "3.8" and python_version < "4" +hiredis==2.2.3 ; python_version >= "3.8" and python_version < "4.0" +idna==3.4 ; python_version >= "3.8" and python_version < "4.0" +langcodes[data]==3.3.0 ; python_version >= "3.8" and python_version < "4.0" +language-data==1.1 ; python_version >= "3.8" and python_version < "4.0" +lru-dict==1.2.0 ; python_version >= "3.8" and python_version < "4.0" +marisa-trie==0.7.8 ; python_version >= "3.8" and python_version < "4.0" +msgspec==0.18.0 ; python_version >= "3.8" and python_version < "4.0" +multidict==6.0.4 ; python_version >= "3.8" and python_version < "4.0" +orjson==3.9.4 ; python_version >= "3.8" and python_version < "4.0" +psutil==5.9.5 ; python_version >= "3.8" and python_version < "4.0" +pycares==4.3.0 ; python_version >= "3.8" and python_version < "4.0" +pycparser==2.21 ; python_version >= "3.8" and python_version < "4.0" +pynacl==1.5.0 ; python_version >= "3.8" and python_version < "4.0" +python-dateutil==2.8.2 ; python_version >= "3.8" and python_version < "4.0" +python-dotenv==1.0.0 ; python_version >= "3.8" and python_version < "4.0" +redis[hiredis]==4.6.0 ; python_version >= "3.8" and python_version < "4.0" +requests==2.31.0 ; python_version >= "3.8" and python_version < "4.0" +setuptools==68.0.0 ; python_version >= "3.8" and python_version < "4.0" +six==1.16.0 ; python_version >= "3.8" and python_version < "4.0" +typing-extensions==4.7.1 ; python_version >= "3.8" and python_version < "4.0" +update-checker==0.18.0 ; python_version >= "3.8" and python_version < "4.0" +urllib3==2.0.4 ; python_version >= "3.8" and python_version < "4.0" +uvloop==0.17.0 ; python_version >= "3.8" and python_version < "4.0" and sys_platform != "win32" +websockets==11.0.3 ; python_version >= "3.8" and python_version < "4.0" +winloop==0.0.6 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "win32" +yarl==1.9.2 ; python_version >= "3.8" and python_version < "4.0" diff --git a/Requirements/prod.txt b/Requirements/prod.txt index 44e86718..dcc3eb26 100644 --- a/Requirements/prod.txt +++ b/Requirements/prod.txt @@ -3,7 +3,7 @@ aiofiles==0.8.0 ; python_version >= "3.8" and python_version < "4.0" aiohttp==3.8.5 ; python_version >= "3.8" and python_version < "4.0" aiosignal==1.3.1 ; python_version >= "3.8" and python_version < "4.0" aiosqlite==0.17.0 ; python_version >= "3.8" and python_version < "4.0" -async-timeout==4.0.2 ; python_version >= "3.8" and python_version < "4.0" +async-timeout==4.0.3 ; python_version >= "3.8" and python_version < "4.0" asyncpg-trek==0.3.1 ; python_version >= "3.8" and python_version < "4" asyncpg==0.28.0 ; python_version >= "3.8" and python_version < "4.0" asyncpraw==7.7.1 ; python_version >= "3.8" and python_version < "4.0" @@ -16,26 +16,34 @@ certifi==2023.7.22 ; python_version >= "3.8" and python_version < "4.0" cffi==1.15.1 ; python_version >= "3.8" and python_version < "4.0" charset-normalizer==3.2.0 ; python_version >= "3.8" and python_version < "4.0" ciso8601==2.3.0 ; python_version >= "3.8" and python_version < "4.0" +cysystemd==1.5.4 ; python_version >= "3.8" and python_version < "4" cython==3.0.0 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "win32" discord-ext-menus @ git+https://github.com/Rapptz/discord-ext-menus@8686b5d1bbc1d3c862292eb436ab630d6e9c9b53 ; python_version >= "3.8" and python_version < "4.0" -discord-py==2.3.1 ; python_version >= "3.8" and python_version < "4.0" -discord-py[voice]==2.3.1 ; python_version >= "3.8" and python_version < "4.0" -faust-cchardet==2.1.18 ; python_version >= "3.8" and python_version < "4.0" +discord-py==2.3.2 ; python_version >= "3.8" and python_version < "4.0" +discord-py[voice]==2.3.2 ; python_version >= "3.8" and python_version < "4.0" +faust-cchardet==2.1.19 ; python_version >= "3.8" and python_version < "4.0" frozenlist==1.4.0 ; python_version >= "3.8" and python_version < "4.0" gql[aiohttp]==3.4.1 ; python_version >= "3.8" and python_version < "4.0" graphql-core==3.2.3 ; python_version >= "3.8" and python_version < "4" hiredis==2.2.3 ; python_version >= "3.8" and python_version < "4.0" idna==3.4 ; python_version >= "3.8" and python_version < "4.0" +langcodes[data]==3.3.0 ; python_version >= "3.8" and python_version < "4.0" +language-data==1.1 ; python_version >= "3.8" and python_version < "4.0" lru-dict==1.2.0 ; python_version >= "3.8" and python_version < "4.0" +marisa-trie==0.7.8 ; python_version >= "3.8" and python_version < "4.0" +msgspec==0.18.0 ; python_version >= "3.8" and python_version < "4.0" multidict==6.0.4 ; python_version >= "3.8" and python_version < "4.0" -orjson==3.9.2 ; python_version >= "3.8" and python_version < "4.0" +orjson==3.9.4 ; python_version >= "3.8" and python_version < "4.0" psutil==5.9.5 ; python_version >= "3.8" and python_version < "4.0" pycares==4.3.0 ; python_version >= "3.8" and python_version < "4.0" pycparser==2.21 ; python_version >= "3.8" and python_version < "4.0" pynacl==1.5.0 ; python_version >= "3.8" and python_version < "4.0" +python-dateutil==2.8.2 ; python_version >= "3.8" and python_version < "4.0" python-dotenv==1.0.0 ; python_version >= "3.8" and python_version < "4.0" redis[hiredis]==4.6.0 ; python_version >= "3.8" and python_version < "4.0" requests==2.31.0 ; python_version >= "3.8" and python_version < "4.0" +setuptools==68.0.0 ; python_version >= "3.8" and python_version < "4.0" +six==1.16.0 ; python_version >= "3.8" and python_version < "4.0" typing-extensions==4.7.1 ; python_version >= "3.8" and python_version < "4.0" update-checker==0.18.0 ; python_version >= "3.8" and python_version < "4.0" urllib3==2.0.4 ; python_version >= "3.8" and python_version < "4.0" diff --git a/changelog.md b/changelog.md index 3c8255d3..f0fea396 100644 --- a/changelog.md +++ b/changelog.md @@ -1,8 +1,10 @@ -# 🛠️ Kumiko v0.10.2 🛠️ +# ✨ Kumiko v0.11.0 ✨ -This release fixes issues with the marketplace module and others. +The final feature release before bugfixing and staging tests begin. This release includes changes such as the introduction of the auction house, pronouns module, and others. These features are considered to be stable but will contain bugs and issues. Please report any bugs you find to the [issue tracker](https://github.com/No767/Kumiko/issues) if you find one. -For the full list of changes, please see them here: [`v0.10.1...v0.10.2`](https://github.com/No767/Kumiko/compare/v0.10.1...v0.10.2) +For the full list of changes, please see them here: [`v0.10.2...v0.11.0`](https://github.com/No767/Kumiko/compare/v0.10.2...v0.11.0) + +> As a side note, I (Noelle) will not be working on Kumiko as actively as before due to college and work so releases will not be as frequent as before. ## :boom: Breaking Changes :boom: @@ -10,24 +12,72 @@ For the full list of changes, please see them here: [`v0.10.1...v0.10.2`](https: ## ✨ TD;LR -- Fixed the owner-issue relationship bug +- Implement the Auction House module (#390) +- Implement the Redirects module +- Kumiko is fully up to PEP8 standards ## 🛠️ Changes -- Fixed the owner-issue relationship bug (this was an issue with the marketplace where if someone made a job output and others bought it, it would throw errors) -- Applied foreign key constraints for item ids -- Changed the user_inv from an 1-n relationship to m-m relationship -- Fixed the prefix duplicates bug (before this, admins could set duplicate prefixes and it would work) -- Updated `Requirements/prod.txt` requirements +- Use discord.py styled versioning schema +- Use RoboDanny styled uptime tracker +- Optimize error handler stack +- Support systemd journal handlers +- Use msgspec as a replacement to attrs and faster serialization/deserialization +- Upgrade redis stack to 7.2.0-RC3 +- Use SonarCloud to enforce code quality +- Move connection checks into one file +- Use `verify-full` for SSL PostgreSQL connections +- Condense Reddit cog +- Start tasks in `cog_load` instead of constructor +- Ensure that the codebase is linted through better Pyright and Ruff configs +- Use grouped unique keys to enforce M-M relations +- Update docs to reflect PEP8 standards +- Improved traceback formatting for backwards compatibility +- Use `msgspec.Struct` instead of `attrs` for faster serializations +- Improve Redis caching +- Improve event logs by using better redis caching and structs instead +- Provide more fixes to the jobs module + ## ✨ Additions -- None +- Implement message constants enums +- Use SonarCloud to enforce code quality +- Implement Auction House, refund, and pronouns commands +- Add buttons within inventory in order to access AH easier +- Systemd journal handler +- Leaderboard command +- Implement dictionary module (supports both English and Japanese) +- Implement a purchase command for the Auctions module +- More test coverage +- Implement the Redirects module (allows you to redirect overlapping conversations into threads) +- Implement the `resolved` command to mark a thread as resolved. Also works on fourms +- Add `FUNDING.yml` (because I am a broke college student and I need money) +- Simple healthcheck ipc endpoint + ## ➖ Removals -- Removed `toml` (not from stdlib) +- Old help command +- Dead code / commented out code +- Unused tables and indexes (the m-m tables got dropped) + # ⬆️ Dependabot Updates -- (Security) Update certifi to 2023.07.22 (fixes CVE-2023-37920) \ No newline at end of file +- \[pip](deps-dev)\: Bump sphinx from 7.0.1 to 7.1.0 (#394) (@dependabot) +- \[pip](deps-dev)\: Bump furo from 2023.5.20 to 2023.7.26 (#397) (@dependabot) +- \[pip](deps-dev)\: Bump sphinx from 7.1.0 to 7.1.1 (#398) (@dependabot) +- \[pip](deps-dev)\: Bump ruff from 0.0.280 to 0.0.282 (#399) (@dependabot) +- \[pip](deps-dev)\: Bump sphinx from 7.1.1 to 7.1.2 (#402) (@dependabot) +- \[pip](deps-dev)\: Bump pyright from 1.1.318 to 1.1.320 (#403) (@dependabot) +- \[pip](deps)\: Bump orjson from 3.9.2 to 3.9.3 (#404) (@dependabot) +- \[pip](deps)\: Bump orjson from 3.9.3 to 3.9.4 (#405) (@dependabot) +- \[pip](deps-dev)\: Bump ruff from 0.0.282 to 0.0.283 (#406) (@dependabot) +- \[pip](deps)\: Bump faust-cchardet from 2.1.18 to 2.1.19 (#407) (@dependabot) +- \[pip](deps-dev)\: Bump ruff from 0.0.283 to 0.0.284 (#408) (@dependabot) +- \[pip](deps-dev)\: Bump pyright from 1.1.320 to 1.1.321 (#409) (@dependabot) +- \[pip](deps)\: Bump msgspec from 0.17.0 to 0.18.0 (#410) (@dependabot) +- \[pip](deps)\: Bump discord-py from 2.3.1 to 2.3.2 (#411) (@dependabot) +- \[pip](deps-dev)\: Bump pyright from 1.1.321 to 1.1.322 (#414) (@dependabot) +- \[Actions](deps)\: Bump actions/setup-node from 3.7.0 to 3.8.0 (#415) (@dependabot) \ No newline at end of file diff --git a/codecov.yml b/codecov.yml index 56f6e8b2..2d5631c8 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,4 +8,6 @@ ignore: - "Bot/Libs/utils/prefix.py" # This contains an coroutine that we can't even test to begin with - "Bot/Libs/cog_utils/pins/pin_utils.py" # Contains untestable code. - "Bot/Cogs/**" # There are just too much code to test here - - "Bot/Libs/utils/checks.py" # Contains untestable code, but may be tested later \ No newline at end of file + - "Bot/Libs/utils/checks.py" # Contains untestable code, but may be tested later + - "Bot/Libs/utils/time.py" # too lazy to test + - "Bot/Libs/utils/message_constants.py" # Literally just constants so there is no need to test \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 57ae4485..944bfeb6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -224,13 +224,13 @@ wheel = ">=0.23.0,<1.0" [[package]] name = "async-timeout" -version = "4.0.2" +version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, - {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] [[package]] @@ -612,13 +612,13 @@ pycparser = "*" [[package]] name = "cfgv" -version = "3.3.1" +version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.8" files = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] [[package]] @@ -861,71 +861,63 @@ test-no-images = ["pytest", "pytest-cov", "wurlitzer"] [[package]] name = "coverage" -version = "7.2.7" +version = "7.3.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, + {file = "coverage-7.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5"}, + {file = "coverage-7.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51"}, + {file = "coverage-7.3.0-cp310-cp310-win32.whl", hash = "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527"}, + {file = "coverage-7.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1"}, + {file = "coverage-7.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f"}, + {file = "coverage-7.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f"}, + {file = "coverage-7.3.0-cp311-cp311-win32.whl", hash = "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482"}, + {file = "coverage-7.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70"}, + {file = "coverage-7.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b"}, + {file = "coverage-7.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b"}, + {file = "coverage-7.3.0-cp312-cp312-win32.whl", hash = "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321"}, + {file = "coverage-7.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479"}, + {file = "coverage-7.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1"}, + {file = "coverage-7.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985"}, + {file = "coverage-7.3.0-cp38-cp38-win32.whl", hash = "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9"}, + {file = "coverage-7.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543"}, + {file = "coverage-7.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba"}, + {file = "coverage-7.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54"}, + {file = "coverage-7.3.0-cp39-cp39-win32.whl", hash = "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3"}, + {file = "coverage-7.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e"}, + {file = "coverage-7.3.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0"}, + {file = "coverage-7.3.0.tar.gz", hash = "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865"}, ] [package.dependencies] @@ -945,6 +937,26 @@ files = [ {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, ] +[[package]] +name = "cysystemd" +version = "1.5.4" +description = "systemd wrapper in Cython" +optional = false +python-versions = ">3.6, <4" +files = [ + {file = "cysystemd-1.5.4-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a7fc9b2758100735b199104e2a88cdd67a0e0d01fb12d152f05cd81cbdc72ee5"}, + {file = "cysystemd-1.5.4-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:47fc80de81fa72a7a292c7997d232c8d06cbef145d317b504fa0569088c0058e"}, + {file = "cysystemd-1.5.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bd1db82dfa5a6b8020b32547fbd03dd745fca3019e2b66c7c3d8fb2793cc9e77"}, + {file = "cysystemd-1.5.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:e36f2b3766e7e2a6fb682591fe74d298be352ab4384d0b1105cff447d9b07214"}, + {file = "cysystemd-1.5.4-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:5106222b6e5819dd58d6b3ded4240f757c94777cd1bcf0e2c09ac444320acf75"}, + {file = "cysystemd-1.5.4-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:46b8f0ac26f41e7f1e1a40332e0cfbeb1f4db6348e13fb7c40a6c73a7ff551ba"}, + {file = "cysystemd-1.5.4-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:192b2617c7ba5941fde72b5714216573e9ecb9d25c94bc37d8a74119e38b86c7"}, + {file = "cysystemd-1.5.4-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:1c5809a6046b971be81aa131310386fa57c46a0b4eb1a4f6f4a348fbbda46685"}, + {file = "cysystemd-1.5.4-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:453e99dc8f0394a00418b6b27b166831e15a4351ca942e17c5d0f21679f7210e"}, + {file = "cysystemd-1.5.4-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:5d5c88574c6d85f58c14c3c5c97b6149d94a2c332eba7bc3df4da7ece72f9fa0"}, + {file = "cysystemd-1.5.4.tar.gz", hash = "sha256:8c08ff5b4ec2bef8abbca72ea99f1fb180ce7786fbfd72003cca50709ee1c3c3"}, +] + [[package]] name = "cython" version = "3.0.0" @@ -1032,13 +1044,13 @@ resolved_reference = "8686b5d1bbc1d3c862292eb436ab630d6e9c9b53" [[package]] name = "discord-py" -version = "2.3.1" +version = "2.3.2" description = "A Python wrapper for the Discord API" optional = false python-versions = ">=3.8.0" files = [ - {file = "discord.py-2.3.1-py3-none-any.whl", hash = "sha256:149652f24da299706270bf8c03c2fcf80cf1caf3a480744c61d5b001688b380d"}, - {file = "discord.py-2.3.1.tar.gz", hash = "sha256:8eb4fe66b5d503da6de3a8425e23012711dc2fbcd7a782107a92beac15ee3459"}, + {file = "discord.py-2.3.2-py3-none-any.whl", hash = "sha256:9da4679fc3cb10c64b388284700dc998663e0e57328283bbfcfc2525ec5960a6"}, + {file = "discord.py-2.3.2.tar.gz", hash = "sha256:4560f70f2eddba7e83370ecebd237ac09fbb4980dc66507482b0c0e5b8f76b9c"}, ] [package.dependencies] @@ -1096,13 +1108,13 @@ test = ["pytest", "pytest-asyncio"] [[package]] name = "exceptiongroup" -version = "1.1.2" +version = "1.1.3" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, - {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] [package.extras] @@ -1110,64 +1122,93 @@ test = ["pytest (>=6)"] [[package]] name = "faust-cchardet" -version = "2.1.18" +version = "2.1.19" description = "cChardet is high speed universal character encoding detector." optional = false python-versions = "*" files = [ - {file = "faust-cchardet-2.1.18.tar.gz", hash = "sha256:d374eecad23c68383e549c83e90b16788bde6b0354fd5ece4020b046df5a7c7e"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6a5444ac3a2edc47b6140e95f253db7870806456bc29307ff81df73767f0a01d"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:175e17d106d903187bd65f0ba65ba6f35b2128bbc3a33ebae24f65870ac3931e"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2077a966136248fed078b1800f1b7bb67362134f4ee46be074e683170e383f10"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b35e04d9a9d1ce42a4e4ecc4b89c3927bacc2f2cf46fa7f751fd5afd6061fea"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cab4d617e06e279156d49002d8e988debf39ffe4d3c55da7621d4b1ad5e66a7"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c05cec6fff567e3a192117b6c0fd6d95c52f072e739d36c0636a70f27a7855fb"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a83e195e7a0e8336042041e95a77554540f33968a9946be96c74c8b403ed54c4"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f72fb710faddf550d4083b027ec6a184267404c7d8cec056d837f2bbcefbdcab"}, - {file = "faust_cchardet-2.1.18-cp310-cp310-win_amd64.whl", hash = "sha256:3aa61101c03701f051bce9e10145d8a9a4ed43e366e9bb8c059fcd8e3b55dc0a"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eb62a62caabcaa9a534fd8918837895e9c7c82526acc8a2275f2638be15fa8cb"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8cf986b2e1c07b777918e26472fd98f2843bdb5eb369e301b961c7ef3257ced"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecab71c89b3f24e6a140829fa4c1b7f9a2cb9cb3e4fcfd5e47a7f63a22c2e860"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f71456780f3f047bcd048194565fb92274f788ae893f66f1e07229999ff1848c"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94179454a5bf433dbe5822c2f159d80865ed558decbdb7a947b67447cf5675c7"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d9c1896abb3735a25703c7f4eba5215fc52630f22372c7755f6cc50e8eecd7d6"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b5b629158c0e0c02dd1e29f60ee680b3a76523458969564132525b63f9c32ae1"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c3b3c4b116a93395a8b318eb3ce858f0918864f430d5364d344b33a27a7b2a8a"}, - {file = "faust_cchardet-2.1.18-cp311-cp311-win_amd64.whl", hash = "sha256:48bcb2bd854c5644273ab73dd5ec68d39f0ce35d7dff73a62e40dbd4c9a75a0f"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8c1218cd7204234121358359f9f2b917b5af24fbb91dd0f8878df86b51290086"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e376251fe8ad6dfc8ccdeb7ecb9b49c3737d16b0aecca221cdd42e6ff27e36"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16ff97ce6bf32f73887494eeb528df78a7ed93c54f47710950a8f56e0d0cf8d0"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d778683491145d6fde78eda78eef12a297f014f4c0dbdbda49996c414187dacc"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:019411216573a57e5729b395007e54e8d941a76dc063f4b1dde4776b5c28344f"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:50e7ca83ae849d9e9dfeb1fb7f7e30734470156369c1a0146d775d7c721cb4bd"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:11872f36f76c0c7f3f3e04cdf487787eb61b5c8dbd1cbcb948e208155d254772"}, - {file = "faust_cchardet-2.1.18-cp36-cp36m-win_amd64.whl", hash = "sha256:5906cc7170c027511a50de44ec342c9656c24d918d1ddcc047e30932b07668d5"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:187c8b9f26ff72d8478c98acf940f8abd46078a0bea5ae0a365a13e5f3676dfc"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f29c8328c5cd074f1afa253903a39a5340baa59ae9f49fbfad9c035f26d182d"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee12b427a23b36f24500d16a235a7fb1b601b4631e4470c5c7dc6dec6e88c103"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83443540bcbcc67880a5b65f70d9864b6ceb6b51c20c4e1283f289ba013ec741"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fea83fb69b3a9e9fd37ebdeb7cb575bae9f07f238e5532b4ef9c406b99e0dd20"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ec8d4afa961bd9552ce822891b2e4f1071d2e0df3afb8577d4bc4c444d2d6d93"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5ab91981e8a1b6e73deb22dd318d10f015d304710baf033d12b8e2f7ac8ec219"}, - {file = "faust_cchardet-2.1.18-cp37-cp37m-win_amd64.whl", hash = "sha256:5afa23fe8dba344d99318715b0b97131dac77eec073b757afa6cf46c68acefad"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a5321da21c34c9f055a154a4395d0ab599914d8a3c5ff1b7c07fa62e25c7428d"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:439f238a818ee3fd38169c8dd34fb52fb50ed4260382ad0eda2bdd80a791c7eb"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccf2c2595189a11911a02da594e6068287ad23d3e9cb754c9ce24e2d43575da0"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6afdc31efb0b9c5aeea4e2c35e65194ccc837108ba7f9108f3d6371f33186c5"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c5dc522f98dd0e40b12e97af114a9f0f7848252ea08366b56da324f57cb52d5"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:757f74a0d36ff956a1d6ab36932c528b8edb28ba6faa8142480338cc126c9a56"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5c7b8a42820a2923e0edc79c0f6c0511e21359165e7f39842f8810afb1bfe065"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0f8df4e3ce93cd5211ac720578e60bbab63d25dde23248d5ceaae45f46554942"}, - {file = "faust_cchardet-2.1.18-cp38-cp38-win_amd64.whl", hash = "sha256:b76d650d1023812b71c369bcb3ae3728c4f55e2313298ddb63b85b6e2d2dbc26"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8349643d41d50fbf39815fc38e9c8a0aeb2c98674daa137fe4d33cdda8223aec"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0982ae31b3d46c3158509ec786cd430de7045aae61f31795583dbec0e7f4b26d"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b92430d49b8f9cd524ddb768204fc39cfc90b7cf77252adfe6ccbfdb8c9e0c3e"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b478c5c9e6b4055e563b83dd050123c5c7ef0ff17d7ce7e727ff0f7fea02ba"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3116ecfd42f53f5da446c385f5f0beaa3ce18adc3d251d71232b2754da81daaa"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:80b1cf0cef666806a943afbc86fd06cfe935a1bd106a997057f9d1eabf7c0b66"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:53a0f37a084af23e6e2c3b3458f0bb28606f6272ede27cbc157be5e80705cca5"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:aa7a8f5aa819dbe52d3c24cd0b1fed40ec5a2a33a8b68f04f2721599593c201c"}, - {file = "faust_cchardet-2.1.18-cp39-cp39-win_amd64.whl", hash = "sha256:abe79a1e1c7a68b0905c7c0d872ba759e02dce142e6a735b1d0a4dc990a631cd"}, + {file = "faust-cchardet-2.1.19.tar.gz", hash = "sha256:f89386297cde0c8e0f5e21464bc2d6d0e4a4fc1b1d77cdb238ca24d740d872e0"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fd76c4c39fde29d8d8a0a79d63c15cd698d79a6cd21350f27a55c5f700ae37a0"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:749d5293531d10ed6f9f7055d37dded2294c849371904bf3a4dc93c544bd8b29"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033d0cbbdb7d30775c895be0b8625854f0bc53c1b0bd43b382bc37d165a85072"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32298550400afe008bf5071975bfdeef051db6d266aa6c0b5cd9db86c87091a8"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab0c4a6c26558daf7bf8a125ae9f46226557dbdb9bf185ff8824960dea9e3c23"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b07fcb7a1269807289ac42917d18ad4b6e25385428293d9f972fb4bd2f0240a9"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:974af1b3a27a7b015571600ca941c9d2b127d23b47bb02039c56063b30c34431"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89121579c5606ea9f5b9dee43edcad99f8e3bf14973127fdd564fd42931a01ac"}, + {file = "faust_cchardet-2.1.19-cp310-cp310-win_amd64.whl", hash = "sha256:ad0c52de878464242fee94f743879b9dfd5789ef612e022b54ec0f468c87f4ca"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90fa1ec45ec7afd0ecc523287c26858bc5e57ec8180880b9ae390460f65fe197"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6812ab2295f826c24e31e561e77a6044cdcb5841df685fb870d3dd573c0abec3"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34b9540f4ec63315902053bb328de6e367f1a3d6f42fcff4acf6ccd33b0f02f9"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e27c7c561db5ba1df87466003b6a76c88698efde2300cbfff62902d2a749f26"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7789695f100c6e60fead2c50277ad6d22b25b66b85ee091b5b7398a82ee98ea"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ddd7b1e40695fe4d3c42879ea91a96ea8552c757250109ddd80b8270ad49baa3"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6f79cf808f92f7c910a1922bdba4d4c711dda0ce7aff0cb8503c47c277c99d06"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cd10370deebd340da20d94f7d5af70b7c17b78c90f8f98a0e11cbb45232d754c"}, + {file = "faust_cchardet-2.1.19-cp311-cp311-win_amd64.whl", hash = "sha256:f19f128b00b81b3e50f1e6fc6e177e0976e5d9b8ec24c047b68ae6e8118db309"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8ce7889e03ba7550099127ffc9b27f1c799c75cdf3698ea3a60e86dadff4bea4"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da1be0b5281f77e79009cf535a218892c24d87483a7a21a7f85d113c6e1da466"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2d088af4711241bc24d858d2fd54854f0a7a969e9890b6dea5512be28d3f83a"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5eded4811f4a6d5a12ee5eed3c0b95312b0644ca504966043fdcc2f20637d7d5"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a194b7d63073ebf8bdc2a1c76451bbbf6b1d31191d2d04b02b14335df5523840"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0f808d12233fe92de881e4d232c0acd52f0123804bcd6d711db3a46b5e09ecf6"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:171191fbc41b541012f5bd18efb3b1434c789f4eedb664332178715b70377372"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5e6c400186d8ec37365e9b22f322bb98d70aff0798da7377b2580f9169358a40"}, + {file = "faust_cchardet-2.1.19-cp312-cp312-win_amd64.whl", hash = "sha256:a41cc69686450b7402a2e87703389cc9d6738a9658781ddb9acf7b52fea068f7"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:29f72f7ba7fb6a238e072596f0fc6731f770ddb415957ed951969236cab05e4c"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:533dcb28107b255d8122268f2e76f0e1cbb1c1e437d522850cd67077fe36f16a"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb649e25b7d5d594035dba3fb807ebc59d8dd433a434a8f5afa22c62f9deb0d0"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f2e2ac15168c77a0a34bd1c1118ed85837cc3c922124e3bdc58f5a49ca3644d"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:225495cc44f9f698456ccd325f105f2be206996fc743128de9e5afa866687879"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:600903f05ed0e2a70e557c30a629b6ba879d7eee259ae2d3cd27b907da09f139"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7b4b2a21af19d9034a31e2c990f5a572d00b5f5b758ef2cee0282e8545eba2ab"}, + {file = "faust_cchardet-2.1.19-cp36-cp36m-win_amd64.whl", hash = "sha256:da7ea1892ac51a1f96c1431ccfa0e9c99d6dda8a434c778a6da1ba12b2f5fa25"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5b150e1a3bf78e6680755902f1bb880caadd93a472ac460ebc0e6f55b3b31d78"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99c42ed30a9b431633f729e24831ccc8a0d8c40b92789e72075197582424f84"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c23252578dd3be28e3e81b84702e2174e3b34da303128dc662d92fa217ee169"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d705e5ae8cba8cfa6af28bda60a99d2f846752abc9dc4cdb5d2354e9a628008"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0930c2adccd871ab7d39d4919d1cd5368556ea3bf7c96365e282125f22636692"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d99c2d5735f48b4c80417322f9a36287d454e9dbdf683caa1f990f9a3b2cfb5c"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d14aaeb2ab67250e0b39e550b9554e97c3c92f4611590058d975556bc5f0408a"}, + {file = "faust_cchardet-2.1.19-cp37-cp37m-win_amd64.whl", hash = "sha256:05573988162fcb4d92966946561094bc9d8e73cdb6cd697473b12b234bf1a828"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6091daffd040575f4cd384346a71f130eddb0e0fe3be1b9c2bb412eb6dcc34c0"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:908b278fa3d38b3c01c5c0933523be23e6f5bfba649314864eb9a6262b407488"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8357587d56757582a1851a389159dd6234ba03af418119be0d43e18086538c8"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec258ee3ecbaae39293343c735218a4750d0230a3e56c1bc1a2c7710d555e16f"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16cad2e3e81fd125f6ee00deb58afb5270b01f45db2a46e92701aa95d7fe2b50"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:fef6c9217e5fde444cec776aaf0d99b397dc59b6e379cdad86eb6c21c6844e8c"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d8bfaa533b92f7813c43bfb81ed0b418a939d808946aa6b160f8d29ba384a089"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:998d7f4ad7bed4f879216bc658e738ca5f1a89a573f770914a16db5e26d160da"}, + {file = "faust_cchardet-2.1.19-cp38-cp38-win_amd64.whl", hash = "sha256:9d2eba1a31dfb8df5cc9ad42e9a8ead510f03314e5f09094f89cc164b82f47a4"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:97d97a03165c2238147c76ff5d1fa4fd676721f1563407073a4ef00191beacc6"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1398d438bb718adc4b04828c2f790dd195db14b529533a7daf6fc60572231f04"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fe6ae7c65825042f9db03ff1edb8b93b50db68d40b3eaa9340d22eb4f9d1e7d"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7196a7a27c14f0b7a26beaf78bde6de7a2999d18a5ced74b55c01d6b85c54eef"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d350349ea9024476b146134da40fb2696ce53827a6e7681cd11a3f4413bb53bc"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5de8d35909c9a2186307a340525e67ebc5f84f808db6d67676a4d5ce134e0bfc"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:283cdc978ca2149a77d92433ff15da4cb471924642a6c0add16c13f4afb9b4af"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d27de578ef1e50d6bea5119abbf9056179f9ba799233a93632097485962882f4"}, + {file = "faust_cchardet-2.1.19-cp39-cp39-win_amd64.whl", hash = "sha256:e783e06f04bd2f35432c5735bee70ba0a1fd7bf33628c598ad865fa6a7d80fd1"}, + {file = "faust_cchardet-2.1.19-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0d5c5b245afbf89a4254abcdda93d7da3a8a58614aa0aed211e8d6e726c11b91"}, + {file = "faust_cchardet-2.1.19-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64b3d9559a3c88baa7de0300e4d131b395903d0a9f32412aae15619791199527"}, + {file = "faust_cchardet-2.1.19-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8706fc2ce4392cf18fb823f0a805213bae103c1eb3659792d7ab690c5589ea39"}, + {file = "faust_cchardet-2.1.19-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0dbf865b5a7f03d967294efb435de6eede51e74a8af31abc6e959b87b90b0d3a"}, + {file = "faust_cchardet-2.1.19-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:873089b385009c672c7364aaf281f39601af739218d812a8d41b6ac3a96e83d4"}, + {file = "faust_cchardet-2.1.19-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b24c4d5c8eb12f09955e10566c9d2b372eb869060aca9999bd2e38770129eb7e"}, + {file = "faust_cchardet-2.1.19-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa5d8a290fe66a40582d0034c1ab6c8934231474f9361641f1206585716994fc"}, + {file = "faust_cchardet-2.1.19-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70511edd3be6d2537f47527762f44e9aeaf0d135cb78b2ba7a0d7e283baaaa01"}, + {file = "faust_cchardet-2.1.19-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba438330d8017016f0bb74d81d504bf9c5ab58211cbc35e71695cbf6858fca4b"}, + {file = "faust_cchardet-2.1.19-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f81c2f643b2e09a09119554a5f9d60f40a70320b6519e9a6634ae93d05c01c0f"}, + {file = "faust_cchardet-2.1.19-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:17d8aa6575d0b81fffc4b05a97f5ffe79a2dac585689517c9410b6ce4678981a"}, + {file = "faust_cchardet-2.1.19-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5447f1c9b133abbcc97b87c5d29825567b76384088783b6171eefbca120e44ca"}, + {file = "faust_cchardet-2.1.19-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0b3b8897ed14f338310a998a86d7671004bbe27dcd99451f2f9d73e0f04d110"}, + {file = "faust_cchardet-2.1.19-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:396094d4875fcb30a573c71b6e8f44260ed89cf4e957769c50e84f453827d96d"}, + {file = "faust_cchardet-2.1.19-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:9df42a1bc3f244ed219529c668d4b42e24243114bcb120229ebb7c44a829c648"}, + {file = "faust_cchardet-2.1.19-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a36f56c921536abcfdb560cbc02dca9fc637c48e58585699105e5ea8c6f558bc"}, + {file = "faust_cchardet-2.1.19-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce81f69a3dee0bf73470bac31c8bcc29d05b981874b84c46e5599374b9f20d4f"}, + {file = "faust_cchardet-2.1.19-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd130c3feca879ef80c5090aefcd0cd4f22da80040c82206de666ca5eb2ee5e3"}, + {file = "faust_cchardet-2.1.19-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e6fa0a2ca80d6d3ce7dae158eb85ae3d784583aed22b064ffebca592be5e46"}, + {file = "faust_cchardet-2.1.19-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:dfe3d4360711cdc4ed5b913ab56b99fa03daa945e05bd7755eaaf7e1d8a3fa94"}, ] [[package]] @@ -1187,45 +1228,45 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p [[package]] name = "fonttools" -version = "4.41.1" +version = "4.42.0" description = "Tools to manipulate font files" optional = false python-versions = ">=3.8" files = [ - {file = "fonttools-4.41.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a7bbb290d13c6dd718ec2c3db46fe6c5f6811e7ea1e07f145fd8468176398224"}, - {file = "fonttools-4.41.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec453a45778524f925a8f20fd26a3326f398bfc55d534e37bab470c5e415caa1"}, - {file = "fonttools-4.41.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2071267deaa6d93cb16288613419679c77220543551cbe61da02c93d92df72f"}, - {file = "fonttools-4.41.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e3334d51f0e37e2c6056e67141b2adabc92613a968797e2571ca8a03bd64773"}, - {file = "fonttools-4.41.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cac73bbef7734e78c60949da11c4903ee5837168e58772371bd42a75872f4f82"}, - {file = "fonttools-4.41.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:edee0900cf0eedb29d17c7876102d6e5a91ee333882b1f5abc83e85b934cadb5"}, - {file = "fonttools-4.41.1-cp310-cp310-win32.whl", hash = "sha256:2a22b2c425c698dcd5d6b0ff0b566e8e9663172118db6fd5f1941f9b8063da9b"}, - {file = "fonttools-4.41.1-cp310-cp310-win_amd64.whl", hash = "sha256:547ab36a799dded58a46fa647266c24d0ed43a66028cd1cd4370b246ad426cac"}, - {file = "fonttools-4.41.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:849ec722bbf7d3501a0e879e57dec1fc54919d31bff3f690af30bb87970f9784"}, - {file = "fonttools-4.41.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:38cdecd8f1fd4bf4daae7fed1b3170dfc1b523388d6664b2204b351820aa78a7"}, - {file = "fonttools-4.41.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ae64303ba670f8959fdaaa30ba0c2dabe75364fdec1caeee596c45d51ca3425"}, - {file = "fonttools-4.41.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14f3ccea4cc7dd1b277385adf3c3bf18f9860f87eab9c2fb650b0af16800f55"}, - {file = "fonttools-4.41.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:33191f062549e6bb1a4782c22a04ebd37009c09360e2d6686ac5083774d06d95"}, - {file = "fonttools-4.41.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:704bccd69b0abb6fab9f5e4d2b75896afa48b427caa2c7988792a2ffce35b441"}, - {file = "fonttools-4.41.1-cp311-cp311-win32.whl", hash = "sha256:4edc795533421e98f60acee7d28fc8d941ff5ac10f44668c9c3635ad72ae9045"}, - {file = "fonttools-4.41.1-cp311-cp311-win_amd64.whl", hash = "sha256:aaaef294d8e411f0ecb778a0aefd11bb5884c9b8333cc1011bdaf3b58ca4bd75"}, - {file = "fonttools-4.41.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3d1f9471134affc1e3b1b806db6e3e2ad3fa99439e332f1881a474c825101096"}, - {file = "fonttools-4.41.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:59eba8b2e749a1de85760da22333f3d17c42b66e03758855a12a2a542723c6e7"}, - {file = "fonttools-4.41.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9b3cc10dc9e0834b6665fd63ae0c6964c6bc3d7166e9bc84772e0edd09f9fa2"}, - {file = "fonttools-4.41.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2c2964bdc827ba6b8a91dc6de792620be4da3922c4cf0599f36a488c07e2b2"}, - {file = "fonttools-4.41.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7763316111df7b5165529f4183a334aa24c13cdb5375ffa1dc8ce309c8bf4e5c"}, - {file = "fonttools-4.41.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b2d1ee95be42b80d1f002d1ee0a51d7a435ea90d36f1a5ae331be9962ee5a3f1"}, - {file = "fonttools-4.41.1-cp38-cp38-win32.whl", hash = "sha256:f48602c0b3fd79cd83a34c40af565fe6db7ac9085c8823b552e6e751e3a5b8be"}, - {file = "fonttools-4.41.1-cp38-cp38-win_amd64.whl", hash = "sha256:b0938ebbeccf7c80bb9a15e31645cf831572c3a33d5cc69abe436e7000c61b14"}, - {file = "fonttools-4.41.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e5c2b0a95a221838991e2f0e455dec1ca3a8cc9cd54febd68cc64d40fdb83669"}, - {file = "fonttools-4.41.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:891cfc5a83b0307688f78b9bb446f03a7a1ad981690ac8362f50518bc6153975"}, - {file = "fonttools-4.41.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73ef0bb5d60eb02ba4d3a7d23ada32184bd86007cb2de3657cfcb1175325fc83"}, - {file = "fonttools-4.41.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f240d9adf0583ac8fc1646afe7f4ac039022b6f8fa4f1575a2cfa53675360b69"}, - {file = "fonttools-4.41.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bdd729744ae7ecd7f7311ad25d99da4999003dcfe43b436cf3c333d4e68de73d"}, - {file = "fonttools-4.41.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b927e5f466d99c03e6e20961946314b81d6e3490d95865ef88061144d9f62e38"}, - {file = "fonttools-4.41.1-cp39-cp39-win32.whl", hash = "sha256:afce2aeb80be72b4da7dd114f10f04873ff512793d13ce0b19d12b2a4c44c0f0"}, - {file = "fonttools-4.41.1-cp39-cp39-win_amd64.whl", hash = "sha256:1df1b6f4c7c4bc8201eb47f3b268adbf2539943aa43c400f84556557e3e109c0"}, - {file = "fonttools-4.41.1-py3-none-any.whl", hash = "sha256:952cb405f78734cf6466252fec42e206450d1a6715746013f64df9cbd4f896fa"}, - {file = "fonttools-4.41.1.tar.gz", hash = "sha256:e16a9449f21a93909c5be2f5ed5246420f2316e94195dbfccb5238aaa38f9751"}, + {file = "fonttools-4.42.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c456d1f23deff64ffc8b5b098718e149279abdea4d8692dba69172fb6a0d597"}, + {file = "fonttools-4.42.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:150122ed93127a26bc3670ebab7e2add1e0983d30927733aec327ebf4255b072"}, + {file = "fonttools-4.42.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e82d776d2e93f88ca56567509d102266e7ab2fb707a0326f032fe657335238"}, + {file = "fonttools-4.42.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58c1165f9b2662645de9b19a8c8bdd636b36294ccc07e1b0163856b74f10bafc"}, + {file = "fonttools-4.42.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2d6dc3fa91414ff4daa195c05f946e6a575bd214821e26d17ca50f74b35b0fe4"}, + {file = "fonttools-4.42.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fae4e801b774cc62cecf4a57b1eae4097903fced00c608d9e2bc8f84cd87b54a"}, + {file = "fonttools-4.42.0-cp310-cp310-win32.whl", hash = "sha256:b8600ae7dce6ec3ddfb201abb98c9d53abbf8064d7ac0c8a0d8925e722ccf2a0"}, + {file = "fonttools-4.42.0-cp310-cp310-win_amd64.whl", hash = "sha256:57b68eab183fafac7cd7d464a7bfa0fcd4edf6c67837d14fb09c1c20516cf20b"}, + {file = "fonttools-4.42.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0a1466713e54bdbf5521f2f73eebfe727a528905ff5ec63cda40961b4b1eea95"}, + {file = "fonttools-4.42.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3fb2a69870bfe143ec20b039a1c8009e149dd7780dd89554cc8a11f79e5de86b"}, + {file = "fonttools-4.42.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae881e484702efdb6cf756462622de81d4414c454edfd950b137e9a7352b3cb9"}, + {file = "fonttools-4.42.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27ec3246a088555629f9f0902f7412220c67340553ca91eb540cf247aacb1983"}, + {file = "fonttools-4.42.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8ece1886d12bb36c48c00b2031518877f41abae317e3a55620d38e307d799b7e"}, + {file = "fonttools-4.42.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:10dac980f2b975ef74532e2a94bb00e97a95b4595fb7f98db493c474d5f54d0e"}, + {file = "fonttools-4.42.0-cp311-cp311-win32.whl", hash = "sha256:83b98be5d291e08501bd4fc0c4e0f8e6e05b99f3924068b17c5c9972af6fff84"}, + {file = "fonttools-4.42.0-cp311-cp311-win_amd64.whl", hash = "sha256:e35bed436726194c5e6e094fdfb423fb7afaa0211199f9d245e59e11118c576c"}, + {file = "fonttools-4.42.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c36c904ce0322df01e590ba814d5d69e084e985d7e4c2869378671d79662a7d4"}, + {file = "fonttools-4.42.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d54e600a2bcfa5cdaa860237765c01804a03b08404d6affcd92942fa7315ffba"}, + {file = "fonttools-4.42.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01cfe02416b6d416c5c8d15e30315cbcd3e97d1b50d3b34b0ce59f742ef55258"}, + {file = "fonttools-4.42.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f81ed9065b4bd3f4f3ce8e4873cd6a6b3f4e92b1eddefde35d332c6f414acc3"}, + {file = "fonttools-4.42.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:685a4dd6cf31593b50d6d441feb7781a4a7ef61e19551463e14ed7c527b86f9f"}, + {file = "fonttools-4.42.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:329341ba3d86a36e482610db56b30705384cb23bd595eac8cbb045f627778e9d"}, + {file = "fonttools-4.42.0-cp38-cp38-win32.whl", hash = "sha256:4655c480a1a4d706152ff54f20e20cf7609084016f1df3851cce67cef768f40a"}, + {file = "fonttools-4.42.0-cp38-cp38-win_amd64.whl", hash = "sha256:6bd7e4777bff1dcb7c4eff4786998422770f3bfbef8be401c5332895517ba3fa"}, + {file = "fonttools-4.42.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9b55d2a3b360e0c7fc5bd8badf1503ca1c11dd3a1cd20f2c26787ffa145a9c7"}, + {file = "fonttools-4.42.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0df8ef75ba5791e873c9eac2262196497525e3f07699a2576d3ab9ddf41cb619"}, + {file = "fonttools-4.42.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cd2363ea7728496827658682d049ffb2e98525e2247ca64554864a8cc945568"}, + {file = "fonttools-4.42.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d40673b2e927f7cd0819c6f04489dfbeb337b4a7b10fc633c89bf4f34ecb9620"}, + {file = "fonttools-4.42.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c8bf88f9e3ce347c716921804ef3a8330cb128284eb6c0b6c4b3574f3c580023"}, + {file = "fonttools-4.42.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:703101eb0490fae32baf385385d47787b73d9ea55253df43b487c89ec767e0d7"}, + {file = "fonttools-4.42.0-cp39-cp39-win32.whl", hash = "sha256:f0290ea7f9945174bd4dfd66e96149037441eb2008f3649094f056201d99e293"}, + {file = "fonttools-4.42.0-cp39-cp39-win_amd64.whl", hash = "sha256:ae7df0ae9ee2f3f7676b0ff6f4ebe48ad0acaeeeaa0b6839d15dbf0709f2c5ef"}, + {file = "fonttools-4.42.0-py3-none-any.whl", hash = "sha256:dfe7fa7e607f7e8b58d0c32501a3a7cac148538300626d1b930082c90ae7f6bd"}, + {file = "fonttools-4.42.0.tar.gz", hash = "sha256:614b1283dca88effd20ee48160518e6de275ce9b5456a3134d5f235523fc5065"}, ] [package.extras] @@ -1539,13 +1580,13 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs [[package]] name = "importlib-resources" -version = "6.0.0" +version = "6.0.1" description = "Read resources from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_resources-6.0.0-py3-none-any.whl", hash = "sha256:d952faee11004c045f785bb5636e8f885bed30dc3c940d5d42798a2a4541c185"}, - {file = "importlib_resources-6.0.0.tar.gz", hash = "sha256:4cf94875a8368bd89531a756df9a9ebe1f150e0f885030b461237bc7f2d905f2"}, + {file = "importlib_resources-6.0.1-py3-none-any.whl", hash = "sha256:134832a506243891221b88b4ae1213327eea96ceb4e407a00d790bb0626f45cf"}, + {file = "importlib_resources-6.0.1.tar.gz", hash = "sha256:4359457e42708462b9626a04657c6208ad799ceb41e5c58c57ffa0e6a098a5d4"}, ] [package.dependencies] @@ -1687,6 +1728,37 @@ files = [ {file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"}, ] +[[package]] +name = "langcodes" +version = "3.3.0" +description = "Tools for labeling human languages with IETF language tags" +optional = false +python-versions = ">=3.6" +files = [ + {file = "langcodes-3.3.0-py3-none-any.whl", hash = "sha256:4d89fc9acb6e9c8fdef70bcdf376113a3db09b67285d9e1d534de6d8818e7e69"}, + {file = "langcodes-3.3.0.tar.gz", hash = "sha256:794d07d5a28781231ac335a1561b8442f8648ca07cd518310aeb45d6f0807ef6"}, +] + +[package.dependencies] +language-data = {version = ">=1.1,<2.0", optional = true, markers = "extra == \"data\""} + +[package.extras] +data = ["language-data (>=1.1,<2.0)"] + +[[package]] +name = "language-data" +version = "1.1" +description = "Supplementary data about languages used by the langcodes module" +optional = false +python-versions = ">=3.6" +files = [ + {file = "language_data-1.1-py3-none-any.whl", hash = "sha256:f7ba86fafe099ef213ef597eda483d5227b12446604a61f617122d6c925847d5"}, + {file = "language_data-1.1.tar.gz", hash = "sha256:c1f5283c46bba68befa37505857a3f672497aba0c522b37d99367e911232455b"}, +] + +[package.dependencies] +marisa-trie = ">=0.7.7,<0.8.0" + [[package]] name = "line-profiler" version = "4.0.3" @@ -1858,6 +1930,84 @@ files = [ [package.extras] test = ["pytest"] +[[package]] +name = "marisa-trie" +version = "0.7.8" +description = "Static memory-efficient and fast Trie-like structures for Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "marisa-trie-0.7.8.tar.gz", hash = "sha256:aee3de5f2836074cfd803f1caf16f68390f262ef09cd7dc7d0e8aee9b6878643"}, + {file = "marisa_trie-0.7.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f1cf9d5ead4471b149fdb93a1c84eddaa941d23e67b0782091adc222d198a87"}, + {file = "marisa_trie-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:73296b4d6d8ce2f6bc3898fe84348756beddb10cb56442391d050bff135e9c4c"}, + {file = "marisa_trie-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:782c1515caa603656e15779bc61d5db3b079fa4270ad77f464908796e0d940aa"}, + {file = "marisa_trie-0.7.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49131e51aad530e4d47c716cef1bbef15a4e5b8f75bddfcdd7903f5043ef2331"}, + {file = "marisa_trie-0.7.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45b0a38e015d0149141f028b8892ab518946b828c7931685199549294f5893ca"}, + {file = "marisa_trie-0.7.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a537e0efff1ec880bc212390e97f1d35832a44bd78c96807ddb685d538875096"}, + {file = "marisa_trie-0.7.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5c2a33ede2655f1a6fb840729128cb4bc48829108711f79b7a645b6c0c54b5c2"}, + {file = "marisa_trie-0.7.8-cp310-cp310-win32.whl", hash = "sha256:7200cde8e2040811e98661a60463b296b76a6b224411f8899aa0850085e6af40"}, + {file = "marisa_trie-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:a432607bae139183c7251da7eb22f761440bc07d92eacc9e9f7dc0d87f70c495"}, + {file = "marisa_trie-0.7.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a891d2841da153b98c6c7fbe0a89ea8edbc164bdc96a001f360bdcdd54e2070d"}, + {file = "marisa_trie-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9ab632c5caef23a59cd43c76ab59e325f9eadd1e9c8b1c34005b9756ae716ee"}, + {file = "marisa_trie-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:68087942e95acb5801f2a5e9a874aa57af27a4afb52aca81fe1cbe22b2a2fd38"}, + {file = "marisa_trie-0.7.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ef2c4a5023bb6ddbaf1803187b7fb3108e9955aa9c60564504e5f622517c9e7"}, + {file = "marisa_trie-0.7.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24e873619f61bef6a87c669ae459b79d98822270e8a10b21fc52dddf2acc9a46"}, + {file = "marisa_trie-0.7.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:34189c321f30cefb76a6b20c7f055b3f6cd0bc8378c16ba8b7283fd898bf4ac2"}, + {file = "marisa_trie-0.7.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:396555d5f52dc86c65717052573fa2875e10f9e5dd014f825677beadcaec8248"}, + {file = "marisa_trie-0.7.8-cp311-cp311-win32.whl", hash = "sha256:bfe649b02b6318bac572b86d9ddd8276c594411311f8e5ef2edc4bcd7285a06f"}, + {file = "marisa_trie-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:84991b52a187d09b269c4caefc8b857a81156c44997eec7eac0e2862d108cc20"}, + {file = "marisa_trie-0.7.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0555104fe9f414abb12e967322a13df778b21958d1727470f4c8dedfde76a8f2"}, + {file = "marisa_trie-0.7.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f96531013252bca14f7665f67aa642be113b6c348ada5e167ebf8db27b1551b5"}, + {file = "marisa_trie-0.7.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ed76391b132c6261cfb402c1a08679e635d09a0a142dae2c1744d816f103c7f"}, + {file = "marisa_trie-0.7.8-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:e6232506b4d66da932f70cf359a4c5ba9e086228ccd97b602159e90c6ea53dab"}, + {file = "marisa_trie-0.7.8-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:34f927f2738d0b402b76821895254e6a164d5020042559f7d910f6632829cdfa"}, + {file = "marisa_trie-0.7.8-cp36-cp36m-win32.whl", hash = "sha256:645908879ae8fcadfb51650fc176902b9e68eee9a8c4d4d8c682cf99ce3ff029"}, + {file = "marisa_trie-0.7.8-cp36-cp36m-win_amd64.whl", hash = "sha256:a5bf2912810e135ce1e60a9b56a179ed62258306103bf5dd3186307f5c51b28f"}, + {file = "marisa_trie-0.7.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bd86212d5037973deda057fc29d60e83dca05e68fa1e7ceaf014c513975c7a0d"}, + {file = "marisa_trie-0.7.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f280f059be417cff81ac030db6a002f8a93093c7ca4555e570d43a24ed45514"}, + {file = "marisa_trie-0.7.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ae35c696f3c5b57c5fe4f73725102f3fe884bc658b854d484dfe6d7e72c86f5"}, + {file = "marisa_trie-0.7.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:524c02f398d361aaf85d8f7709b5ac6de68d020c588fb6c087fb171137643c13"}, + {file = "marisa_trie-0.7.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:353113e811ccfa176fbb611b83671f0b3b40f46b3896b096c10e43f65d35916d"}, + {file = "marisa_trie-0.7.8-cp37-cp37m-win32.whl", hash = "sha256:93172a7314d4d5993970dbafb746f23140d3abfa0d93cc174e766a302d125f7d"}, + {file = "marisa_trie-0.7.8-cp37-cp37m-win_amd64.whl", hash = "sha256:579d69981b18f427bd8e540199c4de400a2bd4ae98e96c814a12cbf766e7029b"}, + {file = "marisa_trie-0.7.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:08858920d0e09ca07d239252884fd72db2abb56c35ff463145ffc9c1277a4f34"}, + {file = "marisa_trie-0.7.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a1b4d07158a3f9b4e84ee709a1fa86b9e11f3dd3b1e6fc45493195105a029545"}, + {file = "marisa_trie-0.7.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f0359f392679774d1ff014f12efdf48da5d661e6241531ff55a3ae5a72a1137e"}, + {file = "marisa_trie-0.7.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c1daaa8c38423fbd119db6654f92740d5ee40d1185a2bbc47afae6712b9ebfc"}, + {file = "marisa_trie-0.7.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:266bf4b6e00b4cff2b8618533919d38b883127f4e5c0af0e0bd78a042093dd99"}, + {file = "marisa_trie-0.7.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fd7e71d8d85d04d2a5d23611663b2d322b60c98c2edab7e9ef9a2019f7435c5b"}, + {file = "marisa_trie-0.7.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:66b13382be3c277f32143e6c814344118721c7954b2bfb57f5cfe93d17e63c9e"}, + {file = "marisa_trie-0.7.8-cp38-cp38-win32.whl", hash = "sha256:d75b5d642b3d1e47a0ab649fb5eb6bf3681a5e1d3793c8ea7546586ab72731fd"}, + {file = "marisa_trie-0.7.8-cp38-cp38-win_amd64.whl", hash = "sha256:07c14c88fde8a0ac55139f9fe763dc0deabc4b7950047719ae986ca62135e1fb"}, + {file = "marisa_trie-0.7.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c8df5238c7b29498f4ee24fd3ee25e0129b3c56beaed1dd1628bce0ebac8ec8c"}, + {file = "marisa_trie-0.7.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db2bdc480d83a1a566b3a64027f9fb34eae98bfe45788c41a45e99d430cbf48a"}, + {file = "marisa_trie-0.7.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:80b22bdbebc3e6677e83db1352e4f6d478364107874c031a34a961437ead4e93"}, + {file = "marisa_trie-0.7.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6412c816be723a0f11dd41225a30a08182cf2b3b7b3c882c44335003bde47003"}, + {file = "marisa_trie-0.7.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fcdb7f802db43857df3825c4c11acd14bb380deb961ff91e260950886531400"}, + {file = "marisa_trie-0.7.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5cf04156f38dc46f0f14423f98559c5def7d83f3a30f8a580c27ad3b0311ce76"}, + {file = "marisa_trie-0.7.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c53b1d02f4974ecb52c6e8c6f4f1dbf3a15e79bc3861f4ad48b14e4e77c82342"}, + {file = "marisa_trie-0.7.8-cp39-cp39-win32.whl", hash = "sha256:75317347f20bf05ab2ce5537a90989b1439b5e1752f558aad7b5d6b43194429b"}, + {file = "marisa_trie-0.7.8-cp39-cp39-win_amd64.whl", hash = "sha256:82ba3caed5acfdff6a23d6881cc1927776b7320415261b6b24f48d0a190ab890"}, + {file = "marisa_trie-0.7.8-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:43abd082a21295b04859705b088d15acac8956587557680850e3149a79e36789"}, + {file = "marisa_trie-0.7.8-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0d891f0138e5aecc9c5afb7b0a57c758e22c5b5c7c0edb0a1f21ae933259815"}, + {file = "marisa_trie-0.7.8-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9031184fe2215b591a6cdefe5d6d4901806fd7359e813c485a7ff25ea69d603c"}, + {file = "marisa_trie-0.7.8-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:8ccb3ba8a2a589b8a7aed693d564f20a6d3bbbb552975f904ba311cea6b85706"}, + {file = "marisa_trie-0.7.8-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f49a2cba047e643e5cd295d75de59f1df710c5e919cd376ac06ead513439881b"}, + {file = "marisa_trie-0.7.8-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d37ea556bb99d9b0dfbe8fd6bdb17e91b91d04531be9e3b8b1b7b7f76ea55637"}, + {file = "marisa_trie-0.7.8-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:55a5aea422a4c0c9ef143d3703323f2a43b4a5315fc90bbb6e9ff18544b8d931"}, + {file = "marisa_trie-0.7.8-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d19f363b981fe9b4a302060a8088fd1f00906bc315db24f5d6726b5c309cc47e"}, + {file = "marisa_trie-0.7.8-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e0d51c31fb41b6bc76c1abb7cf2d63a6e0ba7feffc96ea3d92b4d5084d71721a"}, + {file = "marisa_trie-0.7.8-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71ed6286e9d593dac035b8516e7ec35a1b54a7d9c6451a9319e918a8ef722714"}, + {file = "marisa_trie-0.7.8-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc1c1dca06c0fdcca5bb261a09eca2b3bcf41eaeb467caf600ac68e77d3ed2c0"}, + {file = "marisa_trie-0.7.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:891be5569cd6e3a059c2de53d63251aaaef513d68e8d2181f71378f9cb69e1ab"}, +] + +[package.dependencies] +setuptools = "*" + +[package.extras] +test = ["hypothesis", "pytest", "readme-renderer"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -2033,6 +2183,51 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "msgspec" +version = "0.18.0" +description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML." +optional = false +python-versions = ">=3.8" +files = [ + {file = "msgspec-0.18.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec4f95f03e9fcaef942f5b76856ad1b6bace5cc4db4555939ff25262faa5ad63"}, + {file = "msgspec-0.18.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0d28fcd20d07b565f42289bc7791493cf3b602ad41002db3fe5642802bbf137a"}, + {file = "msgspec-0.18.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7bc1386317796508772e95e3747dbea7e4178a24ebba04f33408dd84b6aea44"}, + {file = "msgspec-0.18.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d814fe2b4641085ed4a2fc917b5f407afe550c3d0c00ab190fc1f6fae1c75dd7"}, + {file = "msgspec-0.18.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:92c0db3f81bfda2be43ced32b043e68fa95daa5c7403f0ced26e49815efe681e"}, + {file = "msgspec-0.18.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89070b557ae3f057c9357dc7f2f5fe11000808254e3f079663246ae4b43b2b89"}, + {file = "msgspec-0.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7e0d735205bf9abd7755434233b7ff48db66965ca4d50a59a96421c4425b2507"}, + {file = "msgspec-0.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08c422741e0e1e13404f7497c2b3419999fd1398c095e841f191d78f569361fd"}, + {file = "msgspec-0.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:33950a523536baceed2f252cda32780eb3646a4656ca08c4bea6497d4988e341"}, + {file = "msgspec-0.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3db21b8e7c71f011c90ba6ed0514cf4a95076ae48e7e85d5fd912f6c8d609990"}, + {file = "msgspec-0.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9b59fff158e5a576d68afe3aed040717d25d7edd2c0653c46733dfa0fbfa1c6"}, + {file = "msgspec-0.18.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ee81a859b16698d2f43fe6bc56b7141f6dd936a6a80f52ec80da45fafa3d56ea"}, + {file = "msgspec-0.18.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:722032d40b721bfb0771c8aeba11373bed84c5ed8721cc81360207d67ecfb9ca"}, + {file = "msgspec-0.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e986f68cbcba50a2198052692f530113507fb566f282f40cfdaafee7ae6a307"}, + {file = "msgspec-0.18.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3c3d6147f1368c8ccf0869313c23ffdf874abb7e0033002689edf5bfc048f75d"}, + {file = "msgspec-0.18.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b44ad06f78a4c05860f80bb533893582727777a8cc760573f41e49cfc5cee60c"}, + {file = "msgspec-0.18.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f754b92340188e6e89c51f3fdfab7de0177bcd08919481072c192782ad9ecee5"}, + {file = "msgspec-0.18.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf10ad5928aecaaeb6b2be82b8aef78df786734020bfc1f281ee78485daa2af7"}, + {file = "msgspec-0.18.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:047b00a7e148c02e64cbb65c59512f93fb8e96b71fc0358f12062e0359bef878"}, + {file = "msgspec-0.18.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:719c8b687a0402d2cd1579753e50d903ab53ef0402bbab91bca96d0e3c2b78d5"}, + {file = "msgspec-0.18.0-cp38-cp38-win_amd64.whl", hash = "sha256:cbd16fae31bb5d2ce06d317e5f2736d58690cad310147c2104ff0a98fa63895c"}, + {file = "msgspec-0.18.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e409af3aa63df802fbe9f99fee1bfdb895f2b243c96e1ef9a40793f73625b549"}, + {file = "msgspec-0.18.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:005f708354371c2a7c3c598f4a67d23f73315e3789dfefd2a274f4a11097866d"}, + {file = "msgspec-0.18.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c103a8eb8c01a3594cf3c66fe6d6a4d11e17d52e07bffadff599d87bae4a476"}, + {file = "msgspec-0.18.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5f9d154ff486426733726de321d3993f4d8aa7bbea3812a8716dedc6b867592"}, + {file = "msgspec-0.18.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f78c176e072e5d805be618d62e56cb2d2ca68cb93c0d6bbfeb03418247e529f"}, + {file = "msgspec-0.18.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ef84977a7f8ced0c369a65ffbcd618c341fe4ba0b30bd1348ce8b6e5dc4096b3"}, + {file = "msgspec-0.18.0-cp39-cp39-win_amd64.whl", hash = "sha256:f907fcc782e5fa6f6bb329004993baa00f068b4e964a971a1421e316b6870012"}, + {file = "msgspec-0.18.0.tar.gz", hash = "sha256:edcdc1bf397f1b06a3323ac61daaa5de9c9c6e8a2349024bdf0a267d0b4d24b5"}, +] + +[package.extras] +dev = ["attrs", "coverage", "furo", "gcovr", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli", "tomli-w"] +doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"] +test = ["attrs", "msgpack", "mypy", "pyright", "pytest", "pyyaml", "tomli", "tomli-w"] +toml = ["tomli", "tomli-w"] +yaml = ["pyyaml"] + [[package]] name = "multidict" version = "6.0.4" @@ -2231,57 +2426,67 @@ files = [ [[package]] name = "orjson" -version = "3.9.2" +version = "3.9.4" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.7" files = [ - {file = "orjson-3.9.2-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7323e4ca8322b1ecb87562f1ec2491831c086d9faa9a6c6503f489dadbed37d7"}, - {file = "orjson-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1272688ea1865f711b01ba479dea2d53e037ea00892fd04196b5875f7021d9d3"}, - {file = "orjson-3.9.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b9a26f1d1427a9101a1e8910f2e2df1f44d3d18ad5480ba031b15d5c1cb282e"}, - {file = "orjson-3.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a5ca55b0d8f25f18b471e34abaee4b175924b6cd62f59992945b25963443141"}, - {file = "orjson-3.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:877872db2c0f41fbe21f852ff642ca842a43bc34895b70f71c9d575df31fffb4"}, - {file = "orjson-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a39c2529d75373b7167bf84c814ef9b8f3737a339c225ed6c0df40736df8748"}, - {file = "orjson-3.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:84ebd6fdf138eb0eb4280045442331ee71c0aab5e16397ba6645f32f911bfb37"}, - {file = "orjson-3.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5a60a1cfcfe310547a1946506dd4f1ed0a7d5bd5b02c8697d9d5dcd8d2e9245e"}, - {file = "orjson-3.9.2-cp310-none-win_amd64.whl", hash = "sha256:c290c4f81e8fd0c1683638802c11610b2f722b540f8e5e858b6914b495cf90c8"}, - {file = "orjson-3.9.2-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:02ef014f9a605e84b675060785e37ec9c0d2347a04f1307a9d6840ab8ecd6f55"}, - {file = "orjson-3.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:992af54265ada1c1579500d6594ed73fe333e726de70d64919cf37f93defdd06"}, - {file = "orjson-3.9.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a40958f7af7c6d992ee67b2da4098dca8b770fc3b4b3834d540477788bfa76d3"}, - {file = "orjson-3.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93864dec3e3dd058a2dbe488d11ac0345214a6a12697f53a63e34de7d28d4257"}, - {file = "orjson-3.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16fdf5a82df80c544c3c91516ab3882cd1ac4f1f84eefeafa642e05cef5f6699"}, - {file = "orjson-3.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275b5a18fd9ed60b2720543d3ddac170051c43d680e47d04ff5203d2c6d8ebf1"}, - {file = "orjson-3.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b9aea6dcb99fcbc9f6d1dd84fca92322fda261da7fb014514bb4689c7c2097a8"}, - {file = "orjson-3.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7d74ae0e101d17c22ef67b741ba356ab896fc0fa64b301c2bf2bb0a4d874b190"}, - {file = "orjson-3.9.2-cp311-none-win_amd64.whl", hash = "sha256:6320b28e7bdb58c3a3a5efffe04b9edad3318d82409e84670a9b24e8035a249d"}, - {file = "orjson-3.9.2-cp37-cp37m-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:368e9cc91ecb7ac21f2aa475e1901204110cf3e714e98649c2502227d248f947"}, - {file = "orjson-3.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58e9e70f0dcd6a802c35887f306b555ff7a214840aad7de24901fc8bd9cf5dde"}, - {file = "orjson-3.9.2-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00c983896c2e01c94c0ef72fd7373b2aa06d0c0eed0342c4884559f812a6835b"}, - {file = "orjson-3.9.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ee743e8890b16c87a2f89733f983370672272b61ee77429c0a5899b2c98c1a7"}, - {file = "orjson-3.9.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7b065942d362aad4818ff599d2f104c35a565c2cbcbab8c09ec49edba91da75"}, - {file = "orjson-3.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e46e9c5b404bb9e41d5555762fd410d5466b7eb1ec170ad1b1609cbebe71df21"}, - {file = "orjson-3.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8170157288714678ffd64f5de33039e1164a73fd8b6be40a8a273f80093f5c4f"}, - {file = "orjson-3.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e3e2f087161947dafe8319ea2cfcb9cea4bb9d2172ecc60ac3c9738f72ef2909"}, - {file = "orjson-3.9.2-cp37-none-win_amd64.whl", hash = "sha256:d7de3dbbe74109ae598692113cec327fd30c5a30ebca819b21dfa4052f7b08ef"}, - {file = "orjson-3.9.2-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8cd4385c59bbc1433cad4a80aca65d2d9039646a9c57f8084897549b55913b17"}, - {file = "orjson-3.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a74036aab1a80c361039290cdbc51aa7adc7ea13f56e5ef94e9be536abd227bd"}, - {file = "orjson-3.9.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1aaa46d7d4ae55335f635eadc9be0bd9bcf742e6757209fc6dc697e390010adc"}, - {file = "orjson-3.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e52c67ed6bb368083aa2078ea3ccbd9721920b93d4b06c43eb4e20c4c860046"}, - {file = "orjson-3.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a6cdfcf9c7dd4026b2b01fdff56986251dc0cc1e980c690c79eec3ae07b36e7"}, - {file = "orjson-3.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1882a70bb69595b9ec5aac0040a819e94d2833fe54901e2b32f5e734bc259a8b"}, - {file = "orjson-3.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:fc05e060d452145ab3c0b5420769e7356050ea311fc03cb9d79c481982917cca"}, - {file = "orjson-3.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f8bc2c40d9bb26efefb10949d261a47ca196772c308babc538dd9f4b73e8d386"}, - {file = "orjson-3.9.2-cp38-none-win_amd64.whl", hash = "sha256:3164fc20a585ec30a9aff33ad5de3b20ce85702b2b2a456852c413e3f0d7ab09"}, - {file = "orjson-3.9.2-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7a6ccadf788531595ed4728aa746bc271955448d2460ff0ef8e21eb3f2a281ba"}, - {file = "orjson-3.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3245d230370f571c945f69aab823c279a868dc877352817e22e551de155cb06c"}, - {file = "orjson-3.9.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:205925b179550a4ee39b8418dd4c94ad6b777d165d7d22614771c771d44f57bd"}, - {file = "orjson-3.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0325fe2d69512187761f7368c8cda1959bcb75fc56b8e7a884e9569112320e57"}, - {file = "orjson-3.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:806704cd58708acc66a064a9a58e3be25cf1c3f9f159e8757bd3f515bfabdfa1"}, - {file = "orjson-3.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03fb36f187a0c19ff38f6289418863df8b9b7880cdbe279e920bef3a09d8dab1"}, - {file = "orjson-3.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:20925d07a97c49c6305bff1635318d9fc1804aa4ccacb5fb0deb8a910e57d97a"}, - {file = "orjson-3.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:eebfed53bec5674e981ebe8ed2cf00b3f7bcda62d634733ff779c264307ea505"}, - {file = "orjson-3.9.2-cp39-none-win_amd64.whl", hash = "sha256:869b961df5fcedf6c79f4096119b35679b63272362e9b745e668f0391a892d39"}, - {file = "orjson-3.9.2.tar.gz", hash = "sha256:24257c8f641979bf25ecd3e27251b5cc194cdd3a6e96004aac8446f5e63d9664"}, + {file = "orjson-3.9.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2e83ec1ee66d83b558a6d273d8a01b86563daa60bea9bc040e2c1cb8008de61f"}, + {file = "orjson-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a9e0f140c7d0d52f79553cabd1a471f6a4f187c59742239939f1139258a053"}, + {file = "orjson-3.9.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb429c56ea645e084e34976c2ea0efca7661ee961f61e51405f28bc5a9d1fb24"}, + {file = "orjson-3.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fb7963c17ab347428412a0689f5c89ea480f5d5f7ba3e46c6c2f14f3159ee4"}, + {file = "orjson-3.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:224ad19dcdc21bb220d893807f2563e219319a8891ead3c54243b51a4882d767"}, + {file = "orjson-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4974cc2ebb53196081fef96743c02c8b073242b20a40b65d2aa2365ba8c949df"}, + {file = "orjson-3.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b39747f8e57728b9d8c26bd1d28e9a31c028717617a5938a179244b9436c0b31"}, + {file = "orjson-3.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0a31c2cab0ba86998205c2eba550c178a8b4ee7905cadeb402eed45392edb178"}, + {file = "orjson-3.9.4-cp310-none-win32.whl", hash = "sha256:04cd7f4a4f4cd2fe43d104eb70e7435c6fcbdde7aa0cde4230e444fbc66924d3"}, + {file = "orjson-3.9.4-cp310-none-win_amd64.whl", hash = "sha256:4fdb59cfa00e10c82e09d1c32a9ce08a38bd29496ba20a73cd7f498e3a0a5024"}, + {file = "orjson-3.9.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:daeed2502ddf1f2b29ec8da2fe2ea82807a5c4acf869608ce6c476db8171d070"}, + {file = "orjson-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73d9507a547202f0dd0672e529ce3ca45582d152369c684a9ce75677ce5ae089"}, + {file = "orjson-3.9.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144a3b8c7cbdd301e1b8cd7dd33e3cbfe7b011df2bebd45b84bacc8cb490302d"}, + {file = "orjson-3.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef7119ebc9b76d5e37c330596616c697d1957779c916aec30cefd28df808f796"}, + {file = "orjson-3.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b75f0fc7a64a95027c6f0c70f17969299bdf2b6a85e342b29fc23be2788bad6f"}, + {file = "orjson-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e4b20164809b21966b63e063f894927bc85391e60d0a96fa0bb552090f1319c"}, + {file = "orjson-3.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e7c3b7e29572ef2d845a59853475f40fdabec53b8b7d6effda4bb26119c07f5"}, + {file = "orjson-3.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d73c0fd54a52a1a1abfad69d4f1dfb7048cd0b3ef1828ddb4920ef2d3739d8fb"}, + {file = "orjson-3.9.4-cp311-none-win32.whl", hash = "sha256:e12492ce65cb10f385e70a88badc6046bc720fa7d468db27b7429d85d41beaeb"}, + {file = "orjson-3.9.4-cp311-none-win_amd64.whl", hash = "sha256:3b9f8bf43a5367d5522f80e7d533c98d880868cd0b640b9088c9237306eca6e8"}, + {file = "orjson-3.9.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:0b400cf89c15958cd829c8a4ade8f5dd73588e63d2fb71a00483e7a74e9f92da"}, + {file = "orjson-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d3b6f2706cb324661899901e6b1fcaee4f5aac7d7588306df3f43e68173840"}, + {file = "orjson-3.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3932b06abf49135c93816c74139c7937fa54079fce3f44db2d598859894c344a"}, + {file = "orjson-3.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:562cf24f9f11df8099e0e78859ba6729e7caa25c2f3947cb228d9152946c854b"}, + {file = "orjson-3.9.4-cp312-none-win_amd64.whl", hash = "sha256:a533e664a0e3904307d662c5d45775544dc2b38df6e39e213ff6a86ceaa3d53c"}, + {file = "orjson-3.9.4-cp37-cp37m-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:149d1b7630771222f73ecb024ab5dd8e7f41502402b02015494d429bacc4d5c1"}, + {file = "orjson-3.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:004f0d307473af210717260dab2ddceab26750ef5d2c6b1f7454c33f7bb69f0c"}, + {file = "orjson-3.9.4-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba21fe581a83555024f3cfc9182a2390a61bc50430364855022c518b8ba285a4"}, + {file = "orjson-3.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1fb36efdf2a35286fb87cfaa195fc34621389da1c7b28a8eb51a4d212d60e56d"}, + {file = "orjson-3.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644728d803200d7774164d252a247e2fcb0d19e4ef7a4a19a1a139ae472c551b"}, + {file = "orjson-3.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bae10f4e7a9145b120e37b6456f1d3853a953e5131fe4740a764e46420289f5"}, + {file = "orjson-3.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c416c50f63bfcf453b6e28d1df956938486191fd1a15aeb95107e810e6e219c8"}, + {file = "orjson-3.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:220ca4125416636a3d6b53a77d50434987a83da243f9080ee4cce7ac6a34bb4a"}, + {file = "orjson-3.9.4-cp37-none-win32.whl", hash = "sha256:bcda6179eb863c295eb5ea832676d33ef12c04d227b4c98267876c8322e5a96e"}, + {file = "orjson-3.9.4-cp37-none-win_amd64.whl", hash = "sha256:3d947366127abef192419257eb7db7fcee0841ced2b49ccceba43b65e9ce5e3f"}, + {file = "orjson-3.9.4-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a7d029fc34a516f7eae29b778b30371fcb621134b2acfe4c51c785102aefc6cf"}, + {file = "orjson-3.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c65df12f92e771361dca45765fcac3d97491799ee8ab3c6c5ecf0155a397a313"}, + {file = "orjson-3.9.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b749d06a3d84ac27311cb85fb5e8f965efd1c5f27556ad8fcfd1853c323b4d54"}, + {file = "orjson-3.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:161cc72dd3ff569fd67da4af3a23c0c837029085300f0cebc287586ae3b559e0"}, + {file = "orjson-3.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edcbccfe852d1d3d56cc8bfc5fa3688c866619328a73cb2394e79b29b4ab24d2"}, + {file = "orjson-3.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0725260a12d7102b6e66f9925a027f55567255d8455f8288b02d5eedc8925c3e"}, + {file = "orjson-3.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:53b417cc9465dbb42ec9cd7be744a921a0ce583556315d172a246d6e71aa043b"}, + {file = "orjson-3.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9ab3720fba68cc1c0bad00803d2c5e2c70177da5af12c45e18cc4d14426d56d8"}, + {file = "orjson-3.9.4-cp38-none-win32.whl", hash = "sha256:94d15ee45c2aaed334688e511aa73b4681f7c08a0810884c6b3ae5824dea1222"}, + {file = "orjson-3.9.4-cp38-none-win_amd64.whl", hash = "sha256:336ec8471102851f0699198031924617b7a77baadea889df3ffda6000bd59f4c"}, + {file = "orjson-3.9.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2f57ccb50e9e123709e9f2d7b1a9e09e694e49d1fa5c5585e34b8e3f01929dc3"}, + {file = "orjson-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e876ef36801b3d4d3a4b0613b6144b0b47f13f3043fd1fcdfafd783c174b538"}, + {file = "orjson-3.9.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f009c1a02773bdecdd1157036918fef1da47f7193d4ad599c9edb1e1960a0491"}, + {file = "orjson-3.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0a4cf31bfa94cd235aa50030bef3df529e4eb2893ea6a7771c0fb087e4e53b2"}, + {file = "orjson-3.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c32dea3b27a97ac88783c1eb61ccb531865bf478a37df3707cbc96ca8f34a04"}, + {file = "orjson-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:264637cad35a1755ab90a8ea290076d444deda20753e55a0eb75496a4645f7bc"}, + {file = "orjson-3.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a4f12e9ec62679c3f2717d9ec41b497a2c2af0b1361229db0dc86ef078a4c034"}, + {file = "orjson-3.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c4fcd1ac0b7850f85398fd9fdbc7150ac4e82d2ae6754cc6acaf49ca7c30d79a"}, + {file = "orjson-3.9.4-cp39-none-win32.whl", hash = "sha256:b5b5038187b74e2d33e5caee8a7e83ddeb6a21da86837fa2aac95c69aeb366e6"}, + {file = "orjson-3.9.4-cp39-none-win_amd64.whl", hash = "sha256:915da36bc93ef0c659fa50fe7939d4f208804ad252fc4fc8d55adbbb82293c48"}, + {file = "orjson-3.9.4.tar.gz", hash = "sha256:a4c9254d21fc44526a3850355b89afd0d00ed73bdf902a5ab416df14a61eac6b"}, ] [[package]] @@ -2366,18 +2571,18 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa [[package]] name = "platformdirs" -version = "3.9.1" +version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.9.1-py3-none-any.whl", hash = "sha256:ad8291ae0ae5072f66c16945166cb11c63394c7a3ad1b1bc9828ca3162da8c2f"}, - {file = "platformdirs-3.9.1.tar.gz", hash = "sha256:1b42b450ad933e981d56e59f1b97495428c9bd60698baab9f3eb3d00d5822421"}, + {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, + {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] [package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] [[package]] name = "pluggy" @@ -2518,13 +2723,13 @@ files = [ [[package]] name = "pygments" -version = "2.15.1" +version = "2.16.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.7" files = [ - {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, - {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, ] [package.extras] @@ -2634,13 +2839,13 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pyright" -version = "1.1.318" +version = "1.1.322" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.318-py3-none-any.whl", hash = "sha256:056c1b2e711c3526e32919de1684ae599d34b7ec27e94398858a43f56ac9ba9b"}, - {file = "pyright-1.1.318.tar.gz", hash = "sha256:69dcf9c32d5be27d531750de627e76a7cadc741d333b547c09044278b508db7b"}, + {file = "pyright-1.1.322-py3-none-any.whl", hash = "sha256:1bcddb55c4fca5d3c86eee71db0e8aad80536527f2084284998c6cbceda10e4e"}, + {file = "pyright-1.1.322.tar.gz", hash = "sha256:c8299d8b5d8c6e6f6ea48a77bf330a6df79e23305d21d25043bba8a23c1e1ed8"}, ] [package.dependencies] @@ -2838,28 +3043,28 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.0.280" +version = "0.0.284" description = "An extremely fast Python linter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.0.280-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:48ed5aca381050a4e2f6d232db912d2e4e98e61648b513c350990c351125aaec"}, - {file = "ruff-0.0.280-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:ef6ee3e429fd29d6a5ceed295809e376e6ece5b0f13c7e703efaf3d3bcb30b96"}, - {file = "ruff-0.0.280-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d878370f7e9463ac40c253724229314ff6ebe4508cdb96cb536e1af4d5a9cd4f"}, - {file = "ruff-0.0.280-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83e8f372fa5627eeda5b83b5a9632d2f9c88fc6d78cead7e2a1f6fb05728d137"}, - {file = "ruff-0.0.280-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7008fc6ca1df18b21fa98bdcfc711dad5f94d0fc3c11791f65e460c48ef27c82"}, - {file = "ruff-0.0.280-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fe7118c1eae3fda17ceb409629c7f3b5a22dffa7caf1f6796776936dca1fe653"}, - {file = "ruff-0.0.280-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37359cd67d2af8e09110a546507c302cbea11c66a52d2a9b6d841d465f9962d4"}, - {file = "ruff-0.0.280-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd58af46b0221efb95966f1f0f7576df711cb53e50d2fdb0e83c2f33360116a4"}, - {file = "ruff-0.0.280-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e7c15828d09f90e97bea8feefcd2907e8c8ce3a1f959c99f9b4b3469679f33c"}, - {file = "ruff-0.0.280-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2dae8f2d9c44c5c49af01733c2f7956f808db682a4193180dedb29dd718d7bbe"}, - {file = "ruff-0.0.280-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5f972567163a20fb8c2d6afc60c2ea5ef8b68d69505760a8bd0377de8984b4f6"}, - {file = "ruff-0.0.280-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ffa7347ad11643f29de100977c055e47c988cd6d9f5f5ff83027600b11b9189"}, - {file = "ruff-0.0.280-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a37dab70114671d273f203268f6c3366c035fe0c8056614069e90a65e614bfc"}, - {file = "ruff-0.0.280-py3-none-win32.whl", hash = "sha256:7784e3606352fcfb193f3cd22b2e2117c444cb879ef6609ec69deabd662b0763"}, - {file = "ruff-0.0.280-py3-none-win_amd64.whl", hash = "sha256:4a7d52457b5dfcd3ab24b0b38eefaead8e2dca62b4fbf10de4cd0938cf20ce30"}, - {file = "ruff-0.0.280-py3-none-win_arm64.whl", hash = "sha256:b7de5b8689575918e130e4384ed9f539ce91d067c0a332aedef6ca7188adac2d"}, - {file = "ruff-0.0.280.tar.gz", hash = "sha256:581c43e4ac5e5a7117ad7da2120d960a4a99e68ec4021ec3cd47fe1cf78f8380"}, + {file = "ruff-0.0.284-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:8b949084941232e2c27f8d12c78c5a6a010927d712ecff17231ee1a8371c205b"}, + {file = "ruff-0.0.284-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:a3930d66b35e4dc96197422381dff2a4e965e9278b5533e71ae8474ef202fab0"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d1f7096038961d8bc3b956ee69d73826843eb5b39a5fa4ee717ed473ed69c95"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcaf85907fc905d838f46490ee15f04031927bbea44c478394b0bfdeadc27362"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3660b85a9d84162a055f1add334623ae2d8022a84dcd605d61c30a57b436c32"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0a3218458b140ea794da72b20ea09cbe13c4c1cdb7ac35e797370354628f4c05"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2fe880cff13fffd735387efbcad54ba0ff1272bceea07f86852a33ca71276f4"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1d098ea74d0ce31478765d1f8b4fbdbba2efc532397b5c5e8e5ea0c13d7e5ae"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c79ae3308e308b94635cd57a369d1e6f146d85019da2fbc63f55da183ee29b"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f86b2b1e7033c00de45cc176cf26778650fb8804073a0495aca2f674797becbb"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e37e086f4d623c05cd45a6fe5006e77a2b37d57773aad96b7802a6b8ecf9c910"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d29dfbe314e1131aa53df213fdfea7ee874dd96ea0dd1471093d93b59498384d"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:88295fd649d0aa1f1271441df75bf06266a199497afd239fd392abcfd75acd7e"}, + {file = "ruff-0.0.284-py3-none-win32.whl", hash = "sha256:735cd62fccc577032a367c31f6a9de7c1eb4c01fa9a2e60775067f44f3fc3091"}, + {file = "ruff-0.0.284-py3-none-win_amd64.whl", hash = "sha256:f67ed868d79fbcc61ad0fa034fe6eed2e8d438d32abce9c04b7c4c1464b2cf8e"}, + {file = "ruff-0.0.284-py3-none-win_arm64.whl", hash = "sha256:1292cfc764eeec3cde35b3a31eae3f661d86418b5e220f5d5dba1c27a6eccbb6"}, + {file = "ruff-0.0.284.tar.gz", hash = "sha256:ebd3cc55cd499d326aac17a331deaea29bea206e01c08862f9b5c6e93d77a491"}, ] [[package]] @@ -2924,13 +3129,13 @@ files = [ [[package]] name = "sphinx" -version = "7.1.1" +version = "7.1.2" description = "Python documentation generator" optional = false python-versions = ">=3.8" files = [ - {file = "sphinx-7.1.1-py3-none-any.whl", hash = "sha256:4e6c5ea477afa0fb90815210fd1312012e1d7542589ab251ac9b53b7c0751bce"}, - {file = "sphinx-7.1.1.tar.gz", hash = "sha256:59b8e391f0768a96cd233e8300fe7f0a8dc2f64f83dc2a54336a9a84f428ff4e"}, + {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"}, + {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"}, ] [package.dependencies] @@ -3139,22 +3344,22 @@ files = [ [[package]] name = "tornado" -version = "6.3.2" +version = "6.3.3" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">= 3.8" files = [ - {file = "tornado-6.3.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:c367ab6c0393d71171123ca5515c61ff62fe09024fa6bf299cd1339dc9456829"}, - {file = "tornado-6.3.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b46a6ab20f5c7c1cb949c72c1994a4585d2eaa0be4853f50a03b5031e964fc7c"}, - {file = "tornado-6.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2de14066c4a38b4ecbbcd55c5cc4b5340eb04f1c5e81da7451ef555859c833f"}, - {file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05615096845cf50a895026f749195bf0b10b8909f9be672f50b0fe69cba368e4"}, - {file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b17b1cf5f8354efa3d37c6e28fdfd9c1c1e5122f2cb56dac121ac61baa47cbe"}, - {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:29e71c847a35f6e10ca3b5c2990a52ce38b233019d8e858b755ea6ce4dcdd19d"}, - {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:834ae7540ad3a83199a8da8f9f2d383e3c3d5130a328889e4cc991acc81e87a0"}, - {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6a0848f1aea0d196a7c4f6772197cbe2abc4266f836b0aac76947872cd29b411"}, - {file = "tornado-6.3.2-cp38-abi3-win32.whl", hash = "sha256:7efcbcc30b7c654eb6a8c9c9da787a851c18f8ccd4a5a3a95b05c7accfa068d2"}, - {file = "tornado-6.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:0c325e66c8123c606eea33084976c832aa4e766b7dff8aedd7587ea44a604cdf"}, - {file = "tornado-6.3.2.tar.gz", hash = "sha256:4b927c4f19b71e627b13f3db2324e4ae660527143f9e1f2e2fb404f3a187e2ba"}, + {file = "tornado-6.3.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:502fba735c84450974fec147340016ad928d29f1e91f49be168c0a4c18181e1d"}, + {file = "tornado-6.3.3-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:805d507b1f588320c26f7f097108eb4023bbaa984d63176d1652e184ba24270a"}, + {file = "tornado-6.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd19ca6c16882e4d37368e0152f99c099bad93e0950ce55e71daed74045908f"}, + {file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ac51f42808cca9b3613f51ffe2a965c8525cb1b00b7b2d56828b8045354f76a"}, + {file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71a8db65160a3c55d61839b7302a9a400074c9c753040455494e2af74e2501f2"}, + {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ceb917a50cd35882b57600709dd5421a418c29ddc852da8bcdab1f0db33406b0"}, + {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:7d01abc57ea0dbb51ddfed477dfe22719d376119844e33c661d873bf9c0e4a16"}, + {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9dc4444c0defcd3929d5c1eb5706cbe1b116e762ff3e0deca8b715d14bf6ec17"}, + {file = "tornado-6.3.3-cp38-abi3-win32.whl", hash = "sha256:65ceca9500383fbdf33a98c0087cb975b2ef3bfb874cb35b8de8740cf7f41bd3"}, + {file = "tornado-6.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:22d3c2fa10b5793da13c807e6fc38ff49a4f6e1e3868b0a6f4164768bb8e20f5"}, + {file = "tornado-6.3.3.tar.gz", hash = "sha256:e7d8db41c0181c80d76c982aacc442c0783a2c54d6400fe028954201a2e032fe"}, ] [[package]] @@ -3250,13 +3455,13 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my [[package]] name = "virtualenv" -version = "20.24.2" +version = "20.24.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.2-py3-none-any.whl", hash = "sha256:43a3052be36080548bdee0b42919c88072037d50d56c28bd3f853cbe92b953ff"}, - {file = "virtualenv-20.24.2.tar.gz", hash = "sha256:fd8a78f46f6b99a67b7ec5cf73f92357891a7b3a40fd97637c27f854aae3b9e0"}, + {file = "virtualenv-20.24.3-py3-none-any.whl", hash = "sha256:95a6e9398b4967fbcb5fef2acec5efaf9aa4972049d9ae41f95e0972a683fd02"}, + {file = "virtualenv-20.24.3.tar.gz", hash = "sha256:e5c3b4ce817b0b328af041506a2a299418c98747c4b1e68cb7527e74ced23efc"}, ] [package.dependencies] @@ -3383,13 +3588,13 @@ files = [ [[package]] name = "wheel" -version = "0.41.0" +version = "0.41.1" description = "A built-package format for Python" optional = false python-versions = ">=3.7" files = [ - {file = "wheel-0.41.0-py3-none-any.whl", hash = "sha256:7e9be3bbd0078f6147d82ed9ed957e323e7708f57e134743d2edef3a7b7972a9"}, - {file = "wheel-0.41.0.tar.gz", hash = "sha256:55a0f0a5a84869bce5ba775abfd9c462e3a6b1b7b7ec69d72c0b83d673a5114d"}, + {file = "wheel-0.41.1-py3-none-any.whl", hash = "sha256:473219bd4cbedc62cea0cb309089b593e47c15c4a2531015f94e4e3b9a0f6981"}, + {file = "wheel-0.41.1.tar.gz", hash = "sha256:12b911f083e876e10c595779709f8a88a59f45aacc646492a67fe9ef796c1b47"}, ] [package.extras] @@ -3514,4 +3719,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "68c94eec6128c82ac5801c1fc041e57cfeb043ed4e1500be6d604a03efbb5b32" +content-hash = "186aa68fe752fcaa6bea319c500136d2ce2fa9faf0a67dced372eb74523bb58c" diff --git a/pyproject.toml b/pyproject.toml index 82c51977..db61f15b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Kumiko" -version = "0.10.2" +version = "0.11.0" description = "A multipurpose Discord bot built with freedom and choice in mind" authors = ["No767 <73260931+No767@users.noreply.github.com>"] license = "Apache-2.0" @@ -10,21 +10,25 @@ python = ">=3.8,<4.0" python-dotenv = "^1.0.0" aiodns = "^3.0.0" Brotli = "^1.0.9" -orjson = "^3.9.2" +orjson = "^3.9.4" asyncpraw = "^7.7.1" uvloop = {markers = "sys_platform != \"win32\"", version = "^0.17.0"} gql = { extras = ["aiohttp"], version = "^3.4.1" } better-ipc = "^2.0.3" redis = {extras = ["hiredis"], version = "^4.6.0"} ciso8601 = "^2.3.0" -faust-cchardet = "^2.1.18" -discord-py = {extras = ["voice"], version = "^2.3.1"} +faust-cchardet = "^2.1.19" +discord-py = {extras = ["voice"], version = "^2.3.2"} discord-ext-menus = {git = "https://github.com/Rapptz/discord-ext-menus", rev = "8686b5d1bbc1d3c862292eb436ab630d6e9c9b53"} asyncpg = "^0.28.0" asyncpg-trek = "^0.3.1" lru-dict = "^1.2.0" psutil = "^5.9.5" winloop = {markers = "sys_platform == \"win32\"", version = "^0.0.6"} +cysystemd = "^1.5.4" +python-dateutil = "^2.8.2" +msgspec = "^0.18.0" +langcodes = {extras = ["data"], version = "^3.3.0"} [tool.poetry.group.test.dependencies] pytest = "^7.4.0" @@ -37,17 +41,17 @@ dpytest = "^0.7.0" [tool.poetry.group.dev.dependencies] pre-commit = "^3.3.3" pyinstrument = "^4.5.1" -pyright = "^1.1.318" +pyright = "^1.1.322" watchfiles = "^0.19.0" jishaku = "^2.5.1" -ruff = "^0.0.280" +ruff = "^0.0.284" [tool.poetry.group.docs.dependencies] -sphinx = "^7.0.1" +sphinx = "^7.1.2" myst-parser = "^2.0.0" sphinx-autobuild = "^2021.3.14" -furo = "^2023.5.20" +furo = "^2023.7.26" sphinxext-opengraph = "^0.8.2" sphinx-copybutton = "^0.5.2" @@ -57,16 +61,23 @@ profile = 'black' [tool.pyright] include = ["Bot/**"] -exclude = ["**/__pycache__", "**/.mypy_cache", "**/.dmpypy.json", "Bot/Libs/kumiko_*", "Bot/Unloaded-Cogs", "Bot/Cogs-Old"] -ignore = ["Docker"] +exclude = [ + "**/__pycache__", + "**/.mypy_cache", + "**/.dmpypy.json", + "Kumiko-Docs", + "Docker" +] reportMissingImports = true -reportMissingTypeStubs = false +typeCheckingMode = "basic" +reportUnnecessaryTypeIgnoreComment = "warning" [tool.bandit] skips = ["B311", "B101"] [tool.ruff] -ignore = ["E501"] +ignore = ["E501", "N999", "N801"] +select = ["E", "F", "N"] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/cache_utils/test_event_logs_utils.py b/tests/cache_utils/test_event_logs_utils.py index 8b33d12d..53cf1306 100644 --- a/tests/cache_utils/test_event_logs_utils.py +++ b/tests/cache_utils/test_event_logs_utils.py @@ -18,20 +18,20 @@ def get_data(): @pytest.mark.asyncio async def test_set_or_update_cache(get_data): - connPool = ConnectionPool() + conn_pool = ConnectionPool() key = "cache:kumiko:123:config" - cache = KumikoCache(connPool) - await set_or_update_cache(key=key, redis_pool=connPool, data=get_data) - res = await cache.getJSONCache(key=key) - assert res == get_data # type: ignore + cache = KumikoCache(conn_pool) + await set_or_update_cache(key=key, redis_pool=conn_pool, data=get_data) + res = await cache.get_json_cache(key=key) + assert res == get_data @pytest.mark.asyncio async def test_cached_set_or_update(get_data): - connPool = ConnectionPool() + conn_pool = ConnectionPool() key = "cache:kumiko:1234:config" - cache = KumikoCache(connPool) - res = await cache.setJSONCache(key=key, value=get_data) - await set_or_update_cache(key=key, redis_pool=connPool, data=get_data) - res = await cache.getJSONCache(key=key) + cache = KumikoCache(conn_pool) + await cache.set_json_cache(key=key, value=get_data) + await set_or_update_cache(key=key, redis_pool=conn_pool, data=get_data) + res = await cache.get_json_cache(key=key) assert res == get_data and res["channel_id"] == get_data["channel_id"] # type: ignore diff --git a/tests/db/test_db_conn.py b/tests/db/test_db_conn.py index eae96412..c2e016ed 100644 --- a/tests/db/test_db_conn.py +++ b/tests/db/test_db_conn.py @@ -11,19 +11,17 @@ load_dotenv() import asyncpg -from Libs.utils.postgresql import ensureOpenPostgresConn +from Libs.utils import ensure_postgres_conn @pytest.fixture(scope="session") def get_uri(): pg_uri = os.getenv("POSTGRES_URI") - if pg_uri is None: - return "postgresql://postgres:postgres@localhost:5432/test" return pg_uri @pytest.mark.asyncio async def test_open_postgres_conn(get_uri): async with asyncpg.create_pool(dsn=get_uri) as pool: - res = await ensureOpenPostgresConn(conn_pool=pool) + res = await ensure_postgres_conn(pool=pool) assert res is True diff --git a/tests/dict/test_dict_structs.py b/tests/dict/test_dict_structs.py new file mode 100644 index 00000000..03497a14 --- /dev/null +++ b/tests/dict/test_dict_structs.py @@ -0,0 +1,107 @@ +import sys +from pathlib import Path + +from dotenv import load_dotenv + +path = Path(__file__).parents[2].joinpath("Bot") +sys.path.append(str(path)) + +load_dotenv() + +from Libs.cog_utils.dictionary import * + + +def test_japanese_word_entry(): + word = "桜" + reading = "さくら" + entry = JapaneseWordEntry(word=word, reading=reading) + assert entry.word == word + assert entry.reading == reading + + +def test_english_def(): + # github copilot generated these lol + definition = "a tree that bears pale pink flowers" + synonyms = ["cherry", "cherry tree", "Prunus avium"] + antonyms = ["Prunus serotina", "black cherry", "rum cherry", "rum cherry tree"] + example = "the cherry trees were in full bloom" + + entry = EnglishDef( + definition=definition, synonyms=synonyms, antonyms=antonyms, example=example + ) + assert ( + (entry.definition == definition) + and (entry.synonyms == synonyms) + and (entry.antonyms == antonyms) + and (entry.example == example) + ) + + +def test_english_dict_entry(): + word = "nice" + phonetics = [] + part_of_speech = "nouns" + definition = "a tree that bears pale pink flowers" + synonyms = ["cherry", "cherry tree", "Prunus avium"] + antonyms = ["Prunus serotina", "black cherry", "rum cherry", "rum cherry tree"] + example = "the cherry trees were in full bloom" + entry = EnglishDef( + definition=definition, synonyms=synonyms, antonyms=antonyms, example=example + ) + + edict = EnglishDictEntry( + word=word, + phonetics=phonetics, + part_of_speech=part_of_speech, + definitions=[entry], + ) + assert ( + (edict.word == word) + and (edict.phonetics == phonetics) + and (edict.part_of_speech == part_of_speech) + and (edict.definitions == [entry]) + ) + + +def test_japanese_entry_def(): + english_definitions = ["cherry", "cherry tree", "Prunus avium"] + parts_of_speech = ["nouns"] + tags = ["cherry", "cherry tree", "Prunus avium"] + entry = JapaneseEntryDef( + english_definitions=english_definitions, + parts_of_speech=parts_of_speech, + tags=tags, + ) + assert ( + (entry.english_definitions == english_definitions) + and (entry.parts_of_speech == parts_of_speech) + and (entry.tags == tags) + ) + + +def test_japanese_dict_entry(): + word = "桜" + reading = "さくら" + word_entry = JapaneseWordEntry(word=word, reading=reading) + english_definitions = ["cherry", "cherry tree", "Prunus avium"] + parts_of_speech = ["nouns"] + tags = ["cherry", "cherry tree", "Prunus avium"] + entry_es = JapaneseEntryDef( + english_definitions=english_definitions, + parts_of_speech=parts_of_speech, + tags=tags, + ) + entry = JapaneseDictEntry( + word=[word_entry], + definitions=[entry_es], + is_common=False, + tags=tags, + jlpt=["N5"], + ) + assert ( + (entry.definitions == [entry_es]) + and (entry.tags == tags) + and (entry.jlpt == ["N5"]) + and (entry.is_common == False) + and (entry.word == [word_entry]) + ) diff --git a/tests/exceptions/test_exceptions.py b/tests/exceptions/test_exceptions.py index f0c2892b..a30a8436 100644 --- a/tests/exceptions/test_exceptions.py +++ b/tests/exceptions/test_exceptions.py @@ -1,87 +1,98 @@ -import sys -from pathlib import Path - -import pytest - -path = Path(__file__).parents[2].joinpath("Bot") -sys.path.append(str(path)) - -from Libs.errors import ( - EconomyDisabled, - HTTPError, - ItemNotFoundError, - KumikoException, - NoItemsError, - NotFoundError, - ValidationError, -) - - -def test_kumiko_exception(): - with pytest.raises(KumikoException) as e: - raise KumikoException - assert e.type == KumikoException - - -def test_item_not_found_error(): - with pytest.raises(ItemNotFoundError) as e: - raise ItemNotFoundError - assert e.type == ItemNotFoundError - - -def test_no_items_error(): - with pytest.raises(NoItemsError) as e: - raise NoItemsError - assert e.type == NoItemsError - - -def test_http_error_custom(): - with pytest.raises(HTTPError) as e: - raise HTTPError(status=500, message="Internal Server Error") - - assert ( - (e.type == HTTPError) - and (e.value.status == 500) - and (e.value.message == "Internal Server Error") - ) - - -def test_http_error_default(): - with pytest.raises(HTTPError) as e: - raise HTTPError(status=500, message=None) - - assert ( - (e.type == HTTPError) - and (e.value.status == 500) - and ("HTTP request failed (500)" in str(e.value)) - ) - - -def test_not_found_error(): - with pytest.raises(NotFoundError) as e: - raise NotFoundError - - assert ( - (e.type == NotFoundError) - and (e.value.status == 404) - and ("Resource or endpoint not found" in str(e.value)) - ) - - -def test_validation_error(): - with pytest.raises(ValidationError) as e: - raise ValidationError("There is an validation error") - - assert (e.type == ValidationError) and ( - "There is an validation error" in str(e.value) - ) - - -def test_economy_disabled_error(): - with pytest.raises(EconomyDisabled) as e: - raise EconomyDisabled - - assert (e.type == EconomyDisabled) and ( - "The economy module is disabled in this server. Please ask your server admin to enable it." - in str(e.value) - ) +import sys +from pathlib import Path + +import pytest + +path = Path(__file__).parents[2].joinpath("Bot") +sys.path.append(str(path)) + +from Libs.errors import ( + EconomyDisabledError, + HTTPError, + ItemNotFoundError, + KumikoExceptionError, + NoItemsError, + NotFoundError, + RedirectsDisabledError, + ValidationError, +) + + +def test_kumiko_exception(): + with pytest.raises(KumikoExceptionError) as e: + raise KumikoExceptionError + assert e.type == KumikoExceptionError + + +def test_item_not_found_error(): + with pytest.raises(ItemNotFoundError) as e: + raise ItemNotFoundError + assert e.type == ItemNotFoundError + + +def test_no_items_error(): + with pytest.raises(NoItemsError) as e: + raise NoItemsError + assert e.type == NoItemsError + + +def test_http_error_custom(): + with pytest.raises(HTTPError) as e: + raise HTTPError(status=500, message="Internal Server Error") + + assert ( + (e.type == HTTPError) + and (e.value.status == 500) + and (e.value.message == "Internal Server Error") + ) + + +def test_http_error_default(): + with pytest.raises(HTTPError) as e: + raise HTTPError(status=500, message=None) + + assert ( + (e.type == HTTPError) + and (e.value.status == 500) + and ("HTTP request failed (500)" in str(e.value)) + ) + + +def test_not_found_error(): + with pytest.raises(NotFoundError) as e: + raise NotFoundError + + assert ( + (e.type == NotFoundError) + and (e.value.status == 404) + and ("Resource or endpoint not found" in str(e.value)) + ) + + +def test_validation_error(): + with pytest.raises(ValidationError) as e: + raise ValidationError("There is an validation error") + + assert (e.type == ValidationError) and ( + "There is an validation error" in str(e.value) + ) + + +def test_economy_disabled_error(): + with pytest.raises(EconomyDisabledError) as e: + raise EconomyDisabledError + + assert (e.type == EconomyDisabledError) and ( + "The economy module is disabled in this server. Please ask your server admin to enable it." + in str(e.value) + ) + + +def test_redirects_disabled_error(): + with pytest.raises(RedirectsDisabledError) as e: + raise RedirectsDisabledError + + assert (e.type == RedirectsDisabledError) and ( + "The redirects module is disabled in this server. Please ask your server admin to enable it." + in str(e.value) + ) diff --git a/tests/pronouns/test_pronouns_utils.py b/tests/pronouns/test_pronouns_utils.py new file mode 100644 index 00000000..5032699e --- /dev/null +++ b/tests/pronouns/test_pronouns_utils.py @@ -0,0 +1,17 @@ +import sys +from pathlib import Path + +from dotenv import load_dotenv + +path = Path(__file__).parents[2].joinpath("Bot") +sys.path.append(str(path)) + +load_dotenv() + +from Libs.cog_utils.pronouns import * + + +def test_pronoun(): + pronouns = ["she", "they", "it"] + res = parse_pronouns(pronouns) + assert res == "she/her, they/them, it/its" diff --git a/tests/redis/test_cache_deco.py b/tests/redis/test_cache_deco.py index 4df059f3..fd036a15 100644 --- a/tests/redis/test_cache_deco.py +++ b/tests/redis/test_cache_deco.py @@ -6,42 +6,54 @@ path = Path(__file__).parents[2].joinpath("Bot") sys.path.append(str(path)) -from Libs.cache import cache, cacheJson +from Libs.cache import cache, cache_json from redis.asyncio.connection import ConnectionPool +DATA = "Hello World" + +REDIS_URI = "redis://localhost:6379/0" + @pytest.mark.asyncio async def test_cache_deco(): - connPool = ConnectionPool(max_connections=25) + conn_pool = ConnectionPool(max_connections=25) @cache() - async def testFunc( - id=1235, redis_pool=ConnectionPool.from_url("redis://localhost:6379/0") - ): - return "Hello World" + async def test_func(id=1235, redis_pool=ConnectionPool.from_url(REDIS_URI)): + return DATA - res = await testFunc(1235, connPool) + res = await test_func(1235, conn_pool) assert isinstance(res, str) or isinstance(res, bytes) - # assert ( - # await testFunc(1235, connPool) == "Hello World".encode("utf-8") - # ) and isinstance( - # res, str - # ) # nosec + + +@pytest.mark.asyncio +async def test_cache_deco_caching(): + conn_pool = ConnectionPool(max_connections=25) + + @cache() + async def test_func(id=1235, redis_pool=ConnectionPool.from_url(REDIS_URI)): + return DATA + + res = await test_func(1235, conn_pool) + res2 = await test_func(1235, conn_pool) + assert (isinstance(res, str) or isinstance(res, bytes)) and ( + isinstance(res2, str) or isinstance(res2, bytes) + ) @pytest.mark.asyncio async def test_cache_deco_json(): - connPool = ConnectionPool(max_connections=25) + conn_pool = ConnectionPool(max_connections=25) - @cacheJson(path=".") - async def testFuncJSON( - id=182348478, redis_pool=ConnectionPool.from_url("redis://localhost:6379/0") + @cache_json(path=".") + async def test_func_json( + id=182348478, redis_pool=ConnectionPool.from_url(REDIS_URI) ): - return {"message": "Hello World"} + return {"message": DATA} - res = await testFuncJSON(182348478, connPool) + res = await test_func_json(182348478, conn_pool) assert ( - await testFuncJSON(182348478, connPool) == {"message": "Hello World"} + await test_func_json(182348478, conn_pool) == {"message": DATA} ) and isinstance( # nosec res, dict ) @@ -51,25 +63,25 @@ async def testFuncJSON( # within the decos, there is code that refuses to cache if the return type is not what is needed @pytest.mark.asyncio async def test_cache_deco_invalid(): - connPool = ConnectionPool() + conn_pool = ConnectionPool() @cache() - async def testFuncInvalid(id=2345973453, redis_pool=ConnectionPool()): + async def test_func_invalid(id=2345973453, redis_pool=ConnectionPool()): return 23464354 - res = await testFuncInvalid(2345973453, connPool) - assert await testFuncInvalid(2345973453, connPool) == 23464354 and isinstance( + res = await test_func_invalid(2345973453, conn_pool) + assert await test_func_invalid(2345973453, conn_pool) == 23464354 and isinstance( res, int ) @pytest.mark.asyncio async def test_cache_deco_json_invalid(): - connPool = ConnectionPool() + conn_pool = ConnectionPool() - @cacheJson() - async def testFuncJSONInvalid(id=2345973453, redis_pool=ConnectionPool()): + @cache_json() + async def test_func_json_invalid(id=2345973453, redis_pool=ConnectionPool()): return [1, 2, 3, 4, 5] - res = await testFuncJSONInvalid(2345973453, connPool) + res = await test_func_json_invalid(2345973453, conn_pool) assert 1 in res and isinstance(res, list) # type: ignore diff --git a/tests/redis/test_global_cache.py b/tests/redis/test_global_cache.py index cb03acc6..4b6e12fc 100644 --- a/tests/redis/test_global_cache.py +++ b/tests/redis/test_global_cache.py @@ -18,19 +18,19 @@ async def test_cpm(): def test_creation_cp(): - kumikoCP = KumikoCPManager(uri=REDIS_URI) - connPool = kumikoCP.createPool() - assert isinstance(connPool, ConnectionPool) + kumiko_cp = KumikoCPManager(uri=REDIS_URI) + pool = kumiko_cp.create_pool() + assert isinstance(pool, ConnectionPool) def test_get_cp(): - kumikoCP = KumikoCPManager(uri=REDIS_URI) - connPool = kumikoCP.getConnPool() - assert isinstance(connPool, ConnectionPool) + kumiko_cp = KumikoCPManager(uri=REDIS_URI) + pool = kumiko_cp.get_conn_pool() + assert isinstance(pool, ConnectionPool) def test_created_cp(): - kumikoCP = KumikoCPManager(uri=REDIS_URI) - kumikoCP.createPool() - newConnPool = kumikoCP.getConnPool() - assert isinstance(newConnPool, ConnectionPool) + kumiko_cp = KumikoCPManager(uri=REDIS_URI) + kumiko_cp.create_pool() + new_pool = kumiko_cp.get_conn_pool() + assert isinstance(new_pool, ConnectionPool) diff --git a/tests/redis/test_key_builder.py b/tests/redis/test_key_builder.py index 72aede1e..6cabfa8d 100644 --- a/tests/redis/test_key_builder.py +++ b/tests/redis/test_key_builder.py @@ -4,13 +4,13 @@ path = Path(__file__).parents[2].joinpath("Bot") sys.path.append(str(path)) -from Libs.cache import CommandKeyBuilder +from Libs.cache import command_key_builder def test_commmand_key_builder(): assert ( # nosec - isinstance(CommandKeyBuilder(), str) - and CommandKeyBuilder( + isinstance(command_key_builder(), str) + and command_key_builder( prefix="cache", namespace="kumiko", id=123, command="test" ) == "cache:kumiko:123:test" diff --git a/tests/redis/test_redis_cache.py b/tests/redis/test_redis_cache.py index 256dcb6e..e84777f5 100644 --- a/tests/redis/test_redis_cache.py +++ b/tests/redis/test_redis_cache.py @@ -1,64 +1,105 @@ -import sys -import uuid -from pathlib import Path - -import pytest -from redis.asyncio.connection import ConnectionPool - -path = Path(__file__).parents[2].joinpath("Bot") -sys.path.append(str(path)) - -from Libs.cache import CommandKeyBuilder, KumikoCache - -DATA = "Hello World" -DICT_DATA = {"message": "Hello World"} - - -@pytest.mark.asyncio -async def test_basic_cache(): - key = CommandKeyBuilder(id=None, command=None) - connPool = ConnectionPool().from_url("redis://localhost:6379/0") - cache = KumikoCache(connection_pool=connPool) - await cache.setBasicCache(key=key, value=DATA) - res = await cache.getBasicCache(key=key) - assert (res == DATA.encode("utf-8")) and (isinstance(res, bytes)) # nosec - - -@pytest.mark.asyncio -async def test_json_cache(): - key = CommandKeyBuilder(id=uuid.uuid4(), command=None) - connPool = ConnectionPool().from_url("redis://localhost:6379/0") - cache = KumikoCache(connection_pool=connPool) - await cache.setJSONCache(key=key, value=DICT_DATA) - res = await cache.getJSONCache(key=key, path=".") - assert (res == DICT_DATA) and (isinstance(res, dict)) # nosec - - -@pytest.mark.asyncio -async def test_key_exists(): - key = CommandKeyBuilder(id=12352, command=None) - connPool = ConnectionPool().from_url("redis://localhost:6379/0") - cache = KumikoCache(connection_pool=connPool) - await cache.setBasicCache(key=key, value=DATA) - res = await cache.cacheExists(key=key) - assert res is True # nosec - - -@pytest.mark.asyncio -async def test_get_json_cache_if_none(): - key = CommandKeyBuilder(id=123564343, command="ayo_what_mate") - connPool = ConnectionPool().from_url("redis://localhost:6379/0") - cache = KumikoCache(connection_pool=connPool) - res = await cache.getJSONCache(key=key) - assert res is None - - -@pytest.mark.asyncio -async def test_delete_json_cache(): - key = CommandKeyBuilder(id=123564343453453, command="nicer") - connPool = ConnectionPool().from_url("redis://localhost:6379/0") - cache = KumikoCache(connection_pool=connPool) - await cache.setJSONCache(key=key, value=DATA) - await cache.deleteJSONCache(key=key) - res = await cache.cacheExists(key=key) - assert res is False +import sys +import uuid +from pathlib import Path + +import pytest +from redis.asyncio.connection import ConnectionPool + +path = Path(__file__).parents[2].joinpath("Bot") +sys.path.append(str(path)) + +from Libs.cache import KumikoCache, command_key_builder + +DATA = "Hello World" +DICT_DATA = {"message": DATA} +OTHER_DATA = {"no": "yes"} + +REDIS_URI = "redis://localhost:6379/0" + + +@pytest.mark.asyncio +async def test_basic_cache(): + key = command_key_builder(id=None, command=None) + conn_pool = ConnectionPool().from_url(REDIS_URI) + cache = KumikoCache(connection_pool=conn_pool) + await cache.set_basic_cache(key=key, value=DATA) + res = await cache.get_basic_cache(key=key) + assert (res == DATA.encode("utf-8")) and (isinstance(res, bytes)) # nosec + + +@pytest.mark.asyncio +async def test_json_cache(): + key = command_key_builder(id=uuid.uuid4(), command=None) + conn_pool = ConnectionPool().from_url(REDIS_URI) + cache = KumikoCache(connection_pool=conn_pool) + await cache.set_json_cache(key=key, value=DICT_DATA) + res = await cache.get_json_cache(key=key, path=".") + assert (res == DICT_DATA) and (isinstance(res, dict)) # nosec + + +@pytest.mark.asyncio +async def test_key_exists(): + key = command_key_builder(id=12352, command=None) + conn_pool = ConnectionPool().from_url(REDIS_URI) + cache = KumikoCache(connection_pool=conn_pool) + await cache.set_basic_cache(key=key, value=DATA) + res = await cache.cache_exists(key=key) + assert res is True # nosec + + +@pytest.mark.asyncio +async def test_get_json_cache_if_none(): + key = command_key_builder(id=123564343, command="ayo_what_mate") + conn_pool = ConnectionPool().from_url(REDIS_URI) + cache = KumikoCache(connection_pool=conn_pool) + res = await cache.get_json_cache(key=key) + assert res is None + + +@pytest.mark.asyncio +async def test_delete_json_cache(): + key = command_key_builder(id=123564343453453, command="nicer") + conn_pool = ConnectionPool().from_url(REDIS_URI) + cache = KumikoCache(connection_pool=conn_pool) + await cache.set_json_cache(key=key, value=DATA) + await cache.delete_json_cache(key=key) + res = await cache.cache_exists(key=key) + assert res is False + + +@pytest.mark.asyncio +async def test_merge_json_cache_no_ttl(): + key = "cache:213423425:cache" + cache = KumikoCache(connection_pool=ConnectionPool().from_url(REDIS_URI)) + await cache.merge_json_cache(key=key, path="$", value=DICT_DATA, ttl=None) + res = await cache.get_json_cache(key=key) + assert isinstance(res, dict) and res == DICT_DATA + + +@pytest.mark.asyncio +async def test_merge_json_cache_with_ttl(): + FULL_DATA = {"message": DATA, "testing": "no"} + key = "cache:21342342523423424:cache" + cache = KumikoCache(connection_pool=ConnectionPool().from_url(REDIS_URI)) + await cache.merge_json_cache(key=key, path="$", value=DICT_DATA, ttl=60) + await cache.merge_json_cache(key=key, path="$.testing", value="no", ttl=60) + res = await cache.get_json_cache(key=key) + assert res == FULL_DATA and isinstance(res, dict) + + +@pytest.mark.asyncio +async def test_get_json_list(): + key = "cache:2134234252342342423424:cache" + cache = KumikoCache(connection_pool=ConnectionPool().from_url(REDIS_URI)) + await cache.set_json_cache(key=key, path="$", value=DATA, ttl=None) + res = await cache.get_json_cache(key=key, value_only=False) + assert isinstance(res, list) + + +@pytest.mark.asyncio +async def test_delete_basic_cache(): + key = "cache:99999999999999999999:cache" + cache = KumikoCache(connection_pool=ConnectionPool().from_url(REDIS_URI)) + await cache.set_basic_cache(key=key, value="yo") + await cache.delete_basic_cache(key=key) + assert await cache.cache_exists(key) is False diff --git a/tests/redis/test_redis_conn.py b/tests/redis/test_redis_conn.py index 994e44f8..13340196 100644 --- a/tests/redis/test_redis_conn.py +++ b/tests/redis/test_redis_conn.py @@ -7,11 +7,11 @@ path = Path(__file__).parents[2].joinpath("Bot") sys.path.append(str(path)) -from Libs.utils.redis import ensureOpenRedisConn +from Libs.utils import ensure_redis_conn @pytest.mark.asyncio async def test_open_conn(): - connPool = ConnectionPool().from_url("redis://localhost:6379/0") - res = await ensureOpenRedisConn(redis_pool=connPool) + pool = ConnectionPool().from_url("redis://localhost:6379/0") + res = await ensure_redis_conn(redis_pool=pool) assert res is True # nosec diff --git a/tests/utils/test_converters.py b/tests/utils/test_converters.py index d4e7a5aa..da68b7c1 100644 --- a/tests/utils/test_converters.py +++ b/tests/utils/test_converters.py @@ -50,6 +50,15 @@ async def jobCommand(self, ctx): await ctx.send("hey") +class CheckLegitUserCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.group(name="check-user") + async def check_user(self, ctx, *, user: str): + await ctx.send(f"{user}") + + @pytest_asyncio.fixture async def bot(): # Setup @@ -61,6 +70,7 @@ async def bot(): await b.add_cog(PrefixCog(b)) await b.add_cog(PinCog(b)) await b.add_cog(JobCog(b)) + await b.add_cog(CheckLegitUserCog(b)) dpytest.configure(b) @@ -78,12 +88,11 @@ async def test_valid_prefix(bot): @pytest.mark.asyncio async def test_invalid_prefix(bot): - finalStr = "" + final_str = "" for _ in range(103): - finalStr += "a" + final_str += "a" with pytest.raises(commands.BadArgument) as e: - await dpytest.message(f">prefix {finalStr}") - # assert dpytest.verify().message().content("!") + await dpytest.message(f">prefix {final_str}") assert e.type == commands.BadArgument and "That prefix is too long." in str( e.value ) @@ -92,10 +101,10 @@ async def test_invalid_prefix(bot): @pytest.mark.asyncio async def test_invalid_ping_prefix(bot): user_id = bot.user.id - finalStr = f"<@{user_id}>" + final_str = f"<@{user_id}>" with pytest.raises(commands.BadArgument) as e: - await dpytest.message(f">prefix {finalStr}") + await dpytest.message(f">prefix {final_str}") assert ( e.type == commands.BadArgument and "That is a reserved prefix already in use." in str(e.value) @@ -105,24 +114,30 @@ async def test_invalid_ping_prefix(bot): @pytest.mark.asyncio async def test_invalid_mention_prefix(bot): user_id = bot.user.id - finalStr = f"<@!{user_id}>" + final_str = f"<@!{user_id}>" with pytest.raises(commands.BadArgument) as e: - await dpytest.message(f">prefix {finalStr}") + await dpytest.message(f">prefix {final_str}") assert ( e.type == commands.BadArgument and "That is a reserved prefix already in use." in str(e.value) ) +@pytest.mark.asyncio +async def test_valid_pin_name(bot): + await dpytest.message(">pins command") + assert dpytest.verify().message().content("command") + + @pytest.mark.asyncio async def test_invalid_max_pin_name(bot): - finalStr = "" + final_str = "" for item, idx in enumerate(range(75)): - finalStr += f"{item}{idx}" + final_str += f"{item}{idx}" with pytest.raises(commands.BadArgument) as e: - await dpytest.message(f">pins {finalStr}") + await dpytest.message(f">pins {final_str}") assert ( e.type == commands.BadArgument and "Tag name is a maximum of 100 characters." in str(e.value) @@ -132,21 +147,27 @@ async def test_invalid_max_pin_name(bot): @pytest.mark.asyncio async def test_same_pin_name(bot): with pytest.raises(commands.BadArgument) as e: - await dpytest.message(f">pins pins") + await dpytest.message(">pins pins") assert ( e.type == commands.BadArgument and "This tag name starts with a reserved word." in str(e.value) ) +@pytest.mark.asyncio +async def test_valid_job_name(bot): + await dpytest.message(">jobs job_name") + assert dpytest.verify().message().content("job_name") + + @pytest.mark.asyncio async def test_invalid_max_job_name(bot): - finalStr = "" + final_str = "" for item, idx in enumerate(range(75)): - finalStr += f"{item}{idx}" + final_str += f"{item}{idx}" with pytest.raises(commands.BadArgument) as e: - await dpytest.message(f">jobs {finalStr}") + await dpytest.message(f">jobs {final_str}") assert ( e.type == commands.BadArgument and "Job name is a maximum of 100 characters." in str(e.value) @@ -156,8 +177,14 @@ async def test_invalid_max_job_name(bot): @pytest.mark.asyncio async def test_same_job_name(bot): with pytest.raises(commands.BadArgument) as e: - await dpytest.message(f">jobs jobs") + await dpytest.message(">jobs jobs") assert ( e.type == commands.BadArgument and "This Job name starts with a reserved word." in str(e.value) ) + + +@pytest.mark.asyncio +async def test_valid_check_user(bot): + await dpytest.message(">check-user 454357482102587393") + assert dpytest.verify().message().content("454357482102587393") diff --git a/tests/utils/test_datetime_parse.py b/tests/utils/test_datetime_parse.py index 83621846..219a97ff 100644 --- a/tests/utils/test_datetime_parse.py +++ b/tests/utils/test_datetime_parse.py @@ -6,7 +6,7 @@ sys.path.append(str(path)) import pytest -from Libs.utils import encodeDatetime, parseDatetime, parseTimeStr +from Libs.utils import encode_datetime, parse_datetime, parse_time_str @pytest.fixture(scope="session", autouse=True) @@ -15,28 +15,28 @@ def load_dict(): def test_parse_date_obj(): - currDate = datetime.now(tz=timezone.utc) - res = parseDatetime(datetime=currDate) + date = datetime.now(tz=timezone.utc) + res = parse_datetime(datetime=date) assert isinstance(res, datetime) # nosec def test_parse_date_str(): - currDate = datetime.now(tz=timezone.utc).isoformat() - res = parseDatetime(datetime=currDate) + date = datetime.now(tz=timezone.utc).isoformat() + res = parse_datetime(datetime=date) assert isinstance(res, datetime) # nosec def test_encode_datetime(load_dict): - assert isinstance(encodeDatetime(load_dict)["created_at"], str) # nosec + assert isinstance(encode_datetime(load_dict)["created_at"], str) # nosec def test_parse_time_str(): - assert isinstance(parseTimeStr("2h"), timedelta) + assert isinstance(parse_time_str("2h"), timedelta) def test_parse_time_str_empty(): - assert isinstance(parseTimeStr(""), timedelta) # this should not work... + assert isinstance(parse_time_str(""), timedelta) # this should not work... def test_parse_time_str_invalid(): - assert parseTimeStr("what mate") is None + assert parse_time_str("what mate") is None diff --git a/tests/utils/test_greedy_formatter.py b/tests/utils/test_greedy_formatter.py index 7241a280..6a2a99ea 100644 --- a/tests/utils/test_greedy_formatter.py +++ b/tests/utils/test_greedy_formatter.py @@ -4,22 +4,22 @@ path = Path(__file__).parents[2].joinpath("Bot") sys.path.append(str(path)) -from Libs.utils import formatGreedy +from Libs.utils import format_greedy def test_format_greedy_3plus(): - assert (formatGreedy(["a", "b", "c"]) == "a, b, and c") and ( - formatGreedy(["a", "b", "c", "d"]) == "a, b, c, and d" + assert (format_greedy(["a", "b", "c"]) == "a, b, and c") and ( + format_greedy(["a", "b", "c", "d"]) == "a, b, c, and d" ) def test_format_greedy_2(): - assert formatGreedy(["a", "b"]) == "a and b" + assert format_greedy(["a", "b"]) == "a and b" def test_format_greedy_1(): - assert formatGreedy(["a"]) == "a" + assert format_greedy(["a"]) == "a" def test_format_greedy_empty(): - assert formatGreedy([]) == "" + assert format_greedy([]) == "" diff --git a/tests/utils/test_parse_subreddits.py b/tests/utils/test_parse_subreddits.py index e8f3ef5f..d96a9f89 100644 --- a/tests/utils/test_parse_subreddits.py +++ b/tests/utils/test_parse_subreddits.py @@ -4,16 +4,16 @@ path = Path(__file__).parents[2].joinpath("Bot") sys.path.append(str(path)) -from Libs.utils import parseSubreddit +from Libs.utils import parse_subreddit def test_rslash_egg_irl(): - assert parseSubreddit("r/egg_irl") == "egg_irl" + assert parse_subreddit("r/egg_irl") == "egg_irl" def test_egg_irl(): - assert parseSubreddit("egg_irl") == "egg_irl" + assert parse_subreddit("egg_irl") == "egg_irl" def test_none_subreddit(): - assert parseSubreddit(subreddit=None) == "all" + assert parse_subreddit(subreddit=None) == "all" diff --git a/tests/utils/test_rank_utils.py b/tests/utils/test_rank_utils.py index 9e6dc977..f8060c61 100644 --- a/tests/utils/test_rank_utils.py +++ b/tests/utils/test_rank_utils.py @@ -8,10 +8,10 @@ def test_calc_rank(): - predictedRank = calc_rank(100) - assert predictedRank == 1 + predicted_rank = calc_rank(100) + assert predicted_rank == 1 def test_calc_petals(): - predictedPetals = calc_petals(1) - assert predictedPetals == 579 + predicted_petals = calc_petals(1) + assert predicted_petals == 579 diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py new file mode 100644 index 00000000..47d20dd7 --- /dev/null +++ b/tests/utils/test_utils.py @@ -0,0 +1,14 @@ +import sys +from pathlib import Path + +path = Path(__file__).parents[2].joinpath("Bot") +sys.path.append(str(path)) + +from Libs.utils import is_docker + + +def test_is_docker(): + if is_docker() is False: + assert is_docker() is False + return + assert is_docker() is True