diff --git a/.gitignore b/.gitignore index f107bde..c087db3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ auto-report_stack-trace.txt # For beta purposes cogs/fun.py +cogs/images.py cogs/globalban.py cogs/globalban.i18n.json cogs/handlers.py diff --git a/cogs/admin.py b/cogs/admin.py new file mode 100644 index 0000000..26778c4 --- /dev/null +++ b/cogs/admin.py @@ -0,0 +1,137 @@ +from logging import getLogger +from time import perf_counter +from typing import Literal, Optional + +import discord +from discord import app_commands +from discord.ext import commands + +from core import Context, MyClient, update_slash_localizations + +logger = getLogger(__name__) + + +class Admin(commands.Cog): + def __init__(self, client: MyClient): + self.client: MyClient = client + + @commands.hybrid_command( + hidden=True, name="reload", description="reload_specs-description", usage="reload_specs-usage" + ) + @commands.is_owner() + @app_commands.describe(cog="reload_specs-args-cog-description") + @app_commands.rename(cog="reload_specs-args-cog-name") + async def reload(self, ctx: Context, cog: str): + try: + benchmark = perf_counter() + await self.client.reload_extension(f"cogs.{cog}") + end = perf_counter() - benchmark + await ctx.reply(content=f"Reloaded extension `{cog}` in **{end:.2f}s**") + logger.info(f"{ctx.author.name} reloaded {cog}.py") + except Exception as e: + await ctx.reply(content=f"Failed to reload extension `{cog}`: {e}") + + @commands.hybrid_command(hidden=True, name="load", description="load_specs-description", usage="load_specs-usage") + @commands.is_owner() + @app_commands.describe(cog="load_specs-args-cog-description") + @app_commands.rename(cog="load_specs-args-cog-name") + async def load(self, ctx: Context, cog: str): + try: + benchmark = perf_counter() + await self.client.load_extension(f"cogs.{cog}") + end = perf_counter() - benchmark + await ctx.reply(content=f"Loaded extension `{cog}` in **{end:.2f}s**") + logger.info(f"{ctx.author.name} loaded {cog}.py") + except Exception as e: + await ctx.reply(content=f"Failed to load extension `{cog}`: {e}") + + @commands.hybrid_command( + hidden=True, name="unload", description="unload_specs-description", usage="unload_specs-usage" + ) + @commands.is_owner() + @app_commands.describe(cog="unload_specs-args-cog-description") + @app_commands.rename(cog="unload_specs-args-cog-name") + async def unload(self, ctx: Context, cog: str): + try: + benchmark = perf_counter() + await self.client.unload_extension(f"cogs.{cog}") + end = perf_counter() - benchmark + await ctx.reply(content=f"Unloaded extension `{cog}` in **{end:.2f}s**") + logger.info(f"{ctx.author.name} unloaded {cog}.py") + except Exception as e: + await ctx.reply(content=f"Failed to unload extension `{cog}`: {e}") + + @commands.hybrid_command( + hidden=True, name="l10nreload", description="l10nreload_specs-description", usage="l10nreload_specs-usage" + ) + @commands.is_owner() + @app_commands.describe(path="l10nreload_specs-args-path-description") + @app_commands.rename(path="l10nreload_specs-args-path-name") + async def l10nreload(self, ctx: Context, path: str = "./localization"): + ctx.bot.custom_response.load_localizations(path) + await ctx.reply(content="Reloaded localization files.") + logger.info(f"{ctx.author.name} reloaded localization files.") + + @commands.hybrid_command(hidden=True, name="sync", description="sync_specs-description", usage="sync_specs-usage") + @commands.is_owner() + @app_commands.describe( + guilds="sync_specs-args-guilds-description", + scope="sync_specs-args-scope-description", + ) + @app_commands.rename(guilds="sync_specs-args-guilds-name", scope="sync_specs-args-scope-name") + @app_commands.choices( + scope=[ + app_commands.Choice(name="sync_specs-args-scope-local", value="~"), + app_commands.Choice(name="sync_specs-args-scope-global", value="*"), + app_commands.Choice(name="sync_specs-args-scope-resync", value="^"), + app_commands.Choice(name="sync_specs-args-scope-slash", value="/"), + ] + ) + async def sync( + self, + ctx: Context, + guilds: commands.Greedy[discord.Object] = None, + scope: Optional[Literal["~", "*", "^", "/"]] = None, + ) -> None: + tree: discord.app_commands.CommandTree[ctx.bot] = ctx.bot.tree # type: ignore + benchmark = perf_counter() + + if not guilds: + if scope == "~": + synced = await tree.sync(guild=ctx.guild) + elif scope == "*": + tree.copy_global_to(guild=ctx.guild) + synced = await tree.sync(guild=ctx.guild) + elif scope == "^": + tree.clear_commands(guild=ctx.guild) + await tree.sync(guild=ctx.guild) + synced = [] + elif scope == "/": + update_slash_localizations() + await ctx.reply(content="Reloaded slash localizations") + return + else: + update_slash_localizations() + synced = await tree.sync() + + end = perf_counter() - benchmark + await ctx.reply( + content=f"Synced **{len(synced)}** {'commands' if len(synced) != 1 else 'command'} {'globally' if scope is None else 'to the current guild'}, took **{end:.2f}s**" + ) + else: + update_slash_localizations() + guilds_synced = 0 + for guild in guilds: + try: + await tree.sync(guild=guild) + except discord.HTTPException: + pass + else: + guilds_synced += 1 + + end = perf_counter() - benchmark + await ctx.reply(content=f"Synced the tree to **{guilds_synced}/{len(guilds)}** guilds, took **{end:.2f}s**") + + +async def setup(client: MyClient): + await client.add_cog(Admin(client)) diff --git a/cogs/afk.py b/cogs/afk.py index 95d573c..839dc03 100644 --- a/cogs/afk.py +++ b/cogs/afk.py @@ -1,19 +1,17 @@ -from typing import TYPE_CHECKING, Optional +from typing import Optional import discord from discord import app_commands from discord.ext import commands +from core import Context, MyClient from helpers import CustomMember, CustomUser, regex -if TYPE_CHECKING: - from main import Context, MyClient - @app_commands.guild_only() @commands.guild_only() class AFK(commands.Cog): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client self.custom_response = client.custom_response @@ -86,7 +84,7 @@ async def answer_afk_reason(self, message: discord.Message) -> None: @commands.hybrid_command(name="afk", description="afk_specs-description", usage="afk_specs-usage") @app_commands.rename(reason="afk_specs-args-reason-name") @app_commands.describe(reason="afk_specs-args-reason-description") - async def afk(self, ctx: "Context", reason: Optional[str] = None): + async def afk(self, ctx: Context, reason: Optional[str] = None): if not reason: reason = await self.custom_response("afk.dnd", ctx) @@ -147,5 +145,5 @@ async def afk(self, ctx: "Context", reason: Optional[str] = None): return await ctx.send("afk.on") -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(AFK(client)) diff --git a/cogs/basic.py b/cogs/basic.py index 1deb1fc..4058d99 100644 --- a/cogs/basic.py +++ b/cogs/basic.py @@ -1,20 +1,16 @@ -from __future__ import annotations - from time import perf_counter -from typing import TYPE_CHECKING from discord.ext import commands -if TYPE_CHECKING: - from main import Context, MyClient +from core import Context, MyClient class Basic(commands.Cog, name="Basic"): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client @commands.hybrid_command(name="ping", description="ping_specs-description") - async def ping(self, ctx: "Context"): + async def ping(self, ctx: Context): # Database ping calculation database_start = perf_counter() await self.client.db.execute("SELECT 1") @@ -23,5 +19,5 @@ async def ping(self, ctx: "Context"): await ctx.send("ping", latency=float(self.client.latency), db=float(database)) -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Basic(client)) diff --git a/cogs/economy.py b/cogs/economy.py index 45509ba..7c616cb 100644 --- a/cogs/economy.py +++ b/cogs/economy.py @@ -1,18 +1,15 @@ -from __future__ import annotations - import random -from typing import TYPE_CHECKING, Literal, Optional, Union +from typing import Literal, Optional, Union import discord from discord import app_commands from discord.ext import commands +from discord.ext.localization import Localization +from core import Context, MyClient from helpers import custom_response, random_helper from helpers.custom_args import CustomRole, CustomUser -if TYPE_CHECKING: - from main import Context, MyClient - class ShopItem: def __init__(self, name: str, price: int, description: str, role: discord.Role): @@ -308,7 +305,7 @@ async def set_balance( @app_commands.guild_only() @commands.guild_only() class Economy(commands.GroupCog, name="Economy", group_name="economy"): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client self.helper = EconomyHelper(client) self.custom_response = client.custom_response @@ -337,9 +334,7 @@ async def leaderboard(self, ctx: commands.Context): user = CustomUser.from_user(self.client.get_user(i["user_id"])) number = rows.index(i) + 1 cash, bank = await self.helper.get_balance(i["user_id"], ctx.guild.id, wallet=None) - formatted = discord.ext.localization.Localization.format_strings( - template, user=user, number=number, cash=cash, bank=bank - ) + formatted = Localization.format_strings(template, user=user, number=number, cash=cash, bank=bank) embeds[0].add_field(**formatted) message["embeds"] = custom_response.CustomResponse.convert_embeds(embeds) @@ -347,14 +342,14 @@ async def leaderboard(self, ctx: commands.Context): @commands.hybrid_command(name="work", description="work_specs-description") @commands.cooldown(1, 3600, commands.BucketType.user) # type: ignore - async def work(self, ctx: "Context"): + async def work(self, ctx: Context): amount: int = random.randint(300, 1500) await self.helper.add_money(ctx.author.id, ctx.guild.id, amount) await ctx.send("work", amount=amount) @commands.hybrid_command(name="crime", description="crime_specs-description") - async def crime(self, ctx: "Context"): + async def crime(self, ctx: Context): amount = random.randint(500, 2000) await self.helper.add_money(ctx.author.id, ctx.guild.id, amount) @@ -362,7 +357,7 @@ async def crime(self, ctx: "Context"): @commands.hybrid_command(name="daily", description="daily_specs-description") @commands.cooldown(1, 86400, commands.BucketType.user) # type: ignore - async def daily(self, ctx: "Context"): + async def daily(self, ctx: Context): amount = 5000 await self.helper.add_money(ctx.author.id, ctx.guild.id, amount) @@ -389,7 +384,7 @@ async def daily(self, ctx: "Context"): @commands.has_permissions(administrator=True) async def addmoney( self, - ctx: "Context", + ctx: Context, member: discord.Member, amount: commands.Range[int, 1], account: Literal["cash", "bank"] = "cash", @@ -426,7 +421,7 @@ async def addmoney( @commands.has_permissions(administrator=True) async def removemoney( self, - ctx: "Context", + ctx: Context, member: discord.Member, amount: discord.app_commands.Range[int, 1], account: Literal["cash", "bank"] = "cash", @@ -443,7 +438,7 @@ async def removemoney( @commands.hybrid_command(name="luck", description="luck_specs-description") @commands.cooldown(1, 3600, commands.BucketType.user) # type: ignore - async def luck(self, ctx: "Context"): + async def luck(self, ctx: Context): balance = await self.helper.get_balance(ctx.author.id, ctx.guild.id) minimum_balance = 1000 if balance < minimum_balance: @@ -469,7 +464,7 @@ async def luck(self, ctx: "Context"): @commands.hybrid_command(name="pay", description="pay_specs-description", usage="pay_specs-usage") async def pay( self, - ctx: "Context", + ctx: Context, member: discord.Member, amount: discord.app_commands.Range[int, 1], ): @@ -498,7 +493,7 @@ async def pay( usage="balance_specs-usage", aliases=["bal"], ) - async def balance(self, ctx: "Context", member: Optional[discord.Member]): + async def balance(self, ctx: Context, member: Optional[discord.Member]): member = member or ctx.author cash, bank = await self.helper.get_balance(member.id, ctx.guild.id, wallet=None) @@ -516,7 +511,7 @@ async def balance(self, ctx: "Context", member: Optional[discord.Member]): @app_commands.describe(bet="slots_specs-args-bet-description") @commands.hybrid_command(name="slots", description="slots_specs-description", usage="slots_specs-usage") @commands.cooldown(1, 3600, commands.BucketType.user) # type: ignore - async def slots(self, ctx: "Context", bet: int): + async def slots(self, ctx: Context, bet: int): balance = await self.helper.get_balance(ctx.author.id, ctx.guild.id) if bet > balance or balance < 0: @@ -559,13 +554,13 @@ async def slots(self, ctx: "Context", bet: int): description="deposit_specs-description", usage="deposit_specs-usage", ) - async def deposit(self, ctx: "Context", amount: discord.app_commands.Range[int, 1] = None): + async def deposit(self, ctx: Context, amount: discord.app_commands.Range[int, 1] | None = None): cash, bank = await self.helper.get_balance(ctx.author.id, ctx.guild.id, wallet=None) amount = amount or cash try: amount = int(amount) except ValueError: - if amount.lower() in await self.custom_response("deposit.all", ctx): + if isinstance(amount, str) and amount.lower() in await self.custom_response("deposit.all", ctx): amount = cash else: await ctx.send("deposit.errors.invalid_amount") @@ -591,13 +586,13 @@ async def deposit(self, ctx: "Context", amount: discord.app_commands.Range[int, description="withdraw_specs-description", usage="withdraw_specs-usage", ) - async def withdraw(self, ctx: "Context", amount: discord.app_commands.Range[int, 1] = None): + async def withdraw(self, ctx: Context, amount: discord.app_commands.Range[int, 1] | None = None): cash, bank = await self.helper.get_balance(ctx.author.id, ctx.guild.id, wallet=None) amount = amount or bank try: amount = int(amount) except ValueError: - if amount.lower() in await self.custom_response("withdraw.all", ctx): + if isinstance(amount, str) and amount.lower() in await self.custom_response("withdraw.all", ctx): amount = bank else: await ctx.send("withdraw.errors.invalid_amount") @@ -628,7 +623,7 @@ def __init__(self, client): description="shop_specs-description", fallback="shop_specs-fallback", ) - async def shop(self, ctx: "Context"): + async def shop(self, ctx: Context): row = await self.client.db.fetch("SELECT * FROM shop WHERE guild_id = $1", str(ctx.guild.id)) if not row: return await ctx.send("shop.list.empty") @@ -645,20 +640,20 @@ async def shop(self, ctx: "Context"): if not role: continue item = ShopItem(i["item_name"], i["item_price"], i["item_description"], role) - formatted = discord.ext.localization.Localization.format_strings(template, item=item) + formatted = Localization.format_strings(template, item=item) embeds[0].add_field(**formatted) message["embeds"] = custom_response.CustomResponse.convert_embeds(embeds) await ctx.send(**message) @shop.command(name="buy", description="buy_specs-description", usage="buy_specs-usage") - @app_commands.rename(item="buy_specs-args-item-name") - @app_commands.describe(item="buy_specs-args-item-description") - async def buy(self, ctx: "Context", item: str): + @app_commands.rename(item_name="buy_specs-args-item-name") + @app_commands.describe(item_name="buy_specs-args-item-description") + async def buy(self, ctx: Context, item_name: str): row = await self.client.db.fetchrow( "SELECT * FROM shop WHERE guild_id = $1 AND LOWER(item_name) = $2", ctx.guild.id, - item.lower(), + item_name.lower(), ) if not row: await ctx.send("shop.buy.errors.not_found") @@ -690,24 +685,24 @@ async def buy(self, ctx: "Context", item: str): usage="set_item_specs-usage", ) @app_commands.rename( - item="global-item", + item_name="global-item", price="global-price", role="global-role", description="global-description", ) @app_commands.describe( - item="set_item_specs-args-item-description", + item_name="set_item_specs-args-item-description", price="set_item_specs-args-price-description", role="set_item_specs-args-role-description", description="set_item_specs-args-description-description", ) @app_commands.checks.has_permissions(manage_guild=True, manage_roles=True) @commands.has_permissions(manage_guild=True, manage_roles=True) - async def set_item(self, ctx: "Context", item: str, price: int, description: str, role: discord.Role): + async def set_item(self, ctx: Context, item_name: str, price: int, description: str, role: discord.Role): row = await self.client.db.fetchrow( "SELECT * FROM shop WHERE guild_id = $1 AND LOWER(item_name) = $2", str(ctx.guild.id), - item.lower(), + item_name.lower(), ) if row: await ctx.send("shop.set.errors.already_item") @@ -724,7 +719,7 @@ async def set_item(self, ctx: "Context", item: str, price: int, description: str await self.client.db.execute( "INSERT INTO shop(item_name, item_description, item_price, role, guild_id, creator_id) VALUES($1, $2, $3, $4, $5, $6)", - item, + item_name, description, price, role.id, @@ -732,7 +727,7 @@ async def set_item(self, ctx: "Context", item: str, price: int, description: str ctx.author.id, ) - item = ShopItem(item, price, description, role) + item = ShopItem(item_name, price, description, role) await ctx.send("shop.set.success", item=item) @shop.command( @@ -740,14 +735,14 @@ async def set_item(self, ctx: "Context", item: str, price: int, description: str description="remove_item_specs-description", usage="remove_item_specs-usage", ) - @app_commands.rename(item="global-item") - @app_commands.describe(item="remove_item_specs-args-item-description") + @app_commands.rename(item_name="global-item") + @app_commands.describe(item_name="remove_item_specs-args-item-description") @app_commands.checks.has_permissions(manage_guild=True) - async def remove_item(self, ctx: "Context", item: str): + async def remove_item(self, ctx: Context, item_name: str): row = await self.client.db.fetchrow( "SELECT * FROM shop WHERE guild_id = $1 AND LOWER(item_name) = $2", ctx.guild.id, - item.lower(), + item_name.lower(), ) if not row: await ctx.send("shop.remove.errors.not_found") @@ -771,6 +766,6 @@ async def remove_item(self, ctx: "Context", item: str): await ctx.send("shop.remove.success", item=item) -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Economy(client)) await client.add_cog(Shop(client)) diff --git a/cogs/giveaway.py b/cogs/giveaway.py index 8ffed57..100c604 100644 --- a/cogs/giveaway.py +++ b/cogs/giveaway.py @@ -1,24 +1,20 @@ -from __future__ import annotations - import asyncio import random from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Optional +from typing import Optional import discord from discord import app_commands from discord.ext import commands import helpers +from core import Context, MyClient from helpers import FormatDateTime from main import logger -if TYPE_CHECKING: - from main import Context, MyClient - class Giveaway(commands.Cog, name="Giveaway"): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client self.custom_response = client.custom_response self.active_giveaways = {} @@ -48,7 +44,7 @@ async def load_active_giveaways(self): async def cog_load(self): await self.load_active_giveaways() - async def end_giveaway(self, ctx: "Context" | None, message_id: int, channel_id: int, right_now: bool = False): + async def end_giveaway(self, ctx: Context | None, message_id: int, channel_id: int, right_now: bool = False): if message_id not in self.active_giveaways: return @@ -121,7 +117,7 @@ async def end_giveaway(self, ctx: "Context" | None, message_id: int, channel_id: duration="gw_specs-args-duration-description", prize="gw_specs-args-prize-description", ) - async def giveaway(self, ctx: "Context", duration: str, winners: str = None, *, prize: str = None): + async def giveaway(self, ctx: Context, duration: str, winners: str | None = None, *, prize: str | None = None): try: end_time = datetime.now() + timedelta(seconds=helpers.text_to_seconds(duration)) except (ValueError, TypeError): @@ -170,12 +166,12 @@ async def giveaway(self, ctx: "Context", duration: str, winners: str = None, *, self.client.loop.create_task(self.end_giveaway(ctx, message.id, ctx.channel.id)) @giveaway.command(name="end", description="gw_end-description", usage="gw_end-usage", aliases=["reroll"]) - @app_commands.rename(message_id="gw_end-args-message_id-name") - @app_commands.describe(message_id="gw_end-args-message_id-description") + @app_commands.rename(message="gw_end-args-message_id-name") + @app_commands.describe(message="gw_end-args-message_id-description") @commands.has_permissions(manage_guild=True) - async def endgiveaway(self, ctx, message_id: str): + async def endgiveaway(self, ctx, message: str): try: - message_id = int(message_id) + message_id = int(message) except ValueError: raise commands.BadArgument("message_id") @@ -185,5 +181,5 @@ async def endgiveaway(self, ctx, message_id: str): await self.end_giveaway(ctx, message_id, ctx.channel.id, True) -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Giveaway(client)) diff --git a/cogs/help.py b/cogs/help.py index 74271c6..30269b6 100644 --- a/cogs/help.py +++ b/cogs/help.py @@ -1,20 +1,16 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Literal, Optional, get_args, get_origin +from typing import Any, Literal, Optional, get_args, get_origin import discord from discord.ext import commands from discord.ext.commands._types import BotT +from discord.ext.localization import Localization -from helpers import custom_response -from main import Command - -if TYPE_CHECKING: - from main import Context, MyClient +from core import Command, Context, MyClient +from helpers import CustomResponse class HelpCommand(commands.HelpCommand): - context: "Context" + context: Context def __init__(self): super().__init__() @@ -76,11 +72,11 @@ async def send_bot_help(self, mapping: dict[commands.Cog, list[commands.Command] if not command_signatures: continue - formatted = discord.ext.localization.Localization.format_strings( + formatted = Localization.format_strings( template, module=cog.qualified_name or "-", commands=len(filtered) ) embeds[0].add_field(**formatted) - message["embeds"] = custom_response.CustomResponse.convert_embeds(embeds) + message["embeds"] = CustomResponse.convert_embeds(embeds) await self.get_destination().send(**message) @@ -123,11 +119,9 @@ async def send_group_or_cog_help(self, group_or_cog: commands.Group | commands.C for command in commands_list: if len(embeds[0].fields) >= 25: break - formatted = discord.ext.localization.Localization.format_strings( - template, command=Command.from_command(command, self.context) - ) + formatted = Localization.format_strings(template, command=Command.from_command(command, self.context)) embeds[0].add_field(**formatted) - message["embeds"] = custom_response.CustomResponse.convert_embeds(embeds) + message["embeds"] = CustomResponse.convert_embeds(embeds) await self.get_destination().send(**message) @@ -142,7 +136,7 @@ async def send_error_message(self, error: str, /) -> None: class Help(commands.Cog, command_attrs=dict(hidden=True)): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client help_command = HelpCommand() help_command.custom_response = client.custom_response @@ -150,5 +144,5 @@ def __init__(self, client: "MyClient"): self.client.help_command = help_command -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Help(client)) diff --git a/cogs/info.py b/cogs/info.py index 9912347..de18a66 100644 --- a/cogs/info.py +++ b/cogs/info.py @@ -1,16 +1,15 @@ -from __future__ import annotations - import asyncio import re -from typing import TYPE_CHECKING, Optional +from typing import Optional import discord import pypokedex import requests from discord import app_commands from discord.ext import commands -from emoji import EMOJI_DATA +from emoji.unicode_codes import EMOJI_DATA +from core import Context, MyClient from helpers.custom_args import ( BotInfo, CustomCategoryChannel, @@ -29,12 +28,9 @@ ) from helpers.regex import DISCORD_TEMPLATE -if TYPE_CHECKING: - from main import Context, MyClient - class Info(commands.Cog, name="Information"): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client @commands.hybrid_group(name="info", description="info_specs-description") @@ -42,7 +38,7 @@ def __init__(self, client: "MyClient"): @app_commands.describe(argument="info_specs-args-argument-description") async def info( self, - ctx: "Context", + ctx: Context, argument: discord.User | discord.abc.GuildChannel | discord.Role | discord.Emoji | discord.PartialEmoji, ): if isinstance(argument, discord.User): @@ -59,10 +55,10 @@ async def info( @info.command(name="user", description="userinfo_specs-description") @app_commands.rename(user="userinfo_specs-args-user-name") @app_commands.describe(user="userinfo_specs-args-user-description") - async def user(self, ctx: "Context", user: discord.Member | discord.User | None = None): + async def user(self, ctx: Context, user: discord.Member | discord.User | None = None): user = user or ctx.author - if not ctx.guild: + if not ctx.guild and type(user) is discord.User: await ctx.send("info.user.not_member", member=CustomUser.from_user(user)) return @@ -78,14 +74,14 @@ async def user(self, ctx: "Context", user: discord.Member | discord.User | None @info.command(name="server", description="serverinfo_specs-description") @commands.guild_only() - async def server(self, ctx: "Context"): + async def server(self, ctx: Context): await ctx.send("info.server", server=CustomGuild.from_guild(ctx.guild)) @info.command(name="role", description="roleinfo_specs-description") @commands.guild_only() @app_commands.rename(role="roleinfo_specs-args-role-name") @app_commands.describe(role="roleinfo_specs-args-role-description") - async def role(self, ctx: "Context", role: Optional[discord.Role] = None): + async def role(self, ctx: Context, role: Optional[discord.Role] = None): role = role or ctx.author.top_role if not role: raise commands.BadArgument("role") @@ -94,7 +90,7 @@ async def role(self, ctx: "Context", role: Optional[discord.Role] = None): @info.command(name="ip", description="ipinfo_specs-description") @app_commands.rename(ip_addr="ipinfo_specs-args-ip-name") @app_commands.describe(ip_addr="ipinfo_specs-args-ip-description") - async def ip(self, ctx: "Context", ip_addr: str): + async def ip(self, ctx: Context, ip_addr: str): try: ip_json = await self.client.request(f"https://ipinfo.io/{ip_addr}/json") except RuntimeError: @@ -103,17 +99,17 @@ async def ip(self, ctx: "Context", ip_addr: str): await ctx.send("info.ip", ip=ip) @info.command(name="bot", description="botinfo_specs-description") - async def bot(self, ctx: "Context"): + async def bot(self, ctx: Context): await ctx.send("info.bot", bot=BotInfo(self.client)) @info.command(name="emoji", description="emojiinfo_specs-description") - @app_commands.rename(emoji="emojiinfo_specs-args-emoji-name") - @app_commands.describe(emoji="emojiinfo_specs-args-emoji-description") - async def emoji(self, ctx: "Context", emoji: str): + @app_commands.rename(emoji_name="emojiinfo_specs-args-emoji-name") + @app_commands.describe(emoji_name="emojiinfo_specs-args-emoji-description") + async def emoji(self, ctx: Context, emoji_name: str): try: - emoji = await commands.EmojiConverter().convert(ctx, emoji) + emoji = await commands.EmojiConverter().convert(ctx, emoji_name) except commands.BadArgument: - emoji = discord.PartialEmoji.from_str(emoji) + emoji = discord.PartialEmoji.from_str(emoji_name) if isinstance(emoji, discord.Emoji): await ctx.send("info.emoji.custom_emoji", emoji=CustomEmoji.from_emoji(emoji)) elif isinstance(emoji, discord.PartialEmoji) and emoji.name in EMOJI_DATA: @@ -125,7 +121,7 @@ async def emoji(self, ctx: "Context", emoji: str): @commands.guild_only() @app_commands.rename(channel="chinfo_specs-args-channel-name") @app_commands.describe(channel="chinfo_specs-args-channel-description") - async def channel(self, ctx: "Context", channel: discord.abc.GuildChannel): + async def channel(self, ctx: Context, channel: discord.abc.GuildChannel): if isinstance(channel, discord.TextChannel): await ctx.send("info.channel.text", channel=CustomTextChannel.from_channel(channel)) elif isinstance(channel, discord.VoiceChannel): @@ -145,7 +141,7 @@ async def channel(self, ctx: "Context", channel: discord.abc.GuildChannel): @info.command(name="pokemon", description="pokeinfo_specs-description") @app_commands.rename(pokemon_name="pokeinfo_specs-args-pokemon-name") @app_commands.describe(pokemon_name="pokeinfo_specs-args-pokemon-description") - async def pokemon(self, ctx: "Context", pokemon_name: str): + async def pokemon(self, ctx: Context, pokemon_name: str): try: pokemon = await asyncio.get_event_loop().run_in_executor(None, lambda: pypokedex.get(name=pokemon_name)) # type: ignore except requests.HTTPError: @@ -158,7 +154,7 @@ async def pokemon(self, ctx: "Context", pokemon_name: str): @info.command(name="template", description="tmplteinfo_specs-description") @app_commands.rename(template="tmplteinfo_specs-args-tmpl-name") @app_commands.describe(template="tmplteinfo_specs-args-tmpl-description") - async def template(self, ctx: "Context", template: str): + async def template(self, ctx: Context, template: str): regex = DISCORD_TEMPLATE.search(template) if regex: template_code = regex.group(1) @@ -183,5 +179,5 @@ async def template(self, ctx: "Context", template: str): raise commands.BadArgument("template") -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Info(client)) diff --git a/cogs/log.py b/cogs/log.py index a6d1ca6..9bbae94 100644 --- a/cogs/log.py +++ b/cogs/log.py @@ -1,13 +1,12 @@ -from __future__ import annotations - import datetime import sys -from typing import TYPE_CHECKING, Literal, Optional, Union, overload +from typing import Literal, Optional, Union, overload import discord from discord import app_commands from discord.ext import commands +from core import Context, MyClient from helpers import ( CustomAutoModAction, CustomAutoModRule, @@ -20,12 +19,9 @@ convert_to_custom_channel, ) -if TYPE_CHECKING: - from main import Context, MyClient - class LogCommands(commands.Cog, name="Logging"): - def __init__(self, client: "MyClient") -> None: + def __init__(self, client: MyClient) -> None: self.client = client @commands.hybrid_group( @@ -39,9 +35,9 @@ def __init__(self, client: "MyClient") -> None: ) async def log_toggle( self, - ctx: "Context", + ctx: Context, state: Literal["on", "off"] = "on", - channel: discord.TextChannel = None, + channel: discord.TextChannel | None = None, ): is_on = state == "on" if is_on: @@ -70,7 +66,7 @@ async def log_toggle( @app_commands.rename(module="logadd_specs-args-module-name") @app_commands.describe(module="logadd_specs-args-module-description") @commands.has_permissions(manage_guild=True) - async def log_module_add(self, ctx: "Context", module: str): + async def log_module_add(self, ctx: Context, module: str): if module == "all": await self.client.db.execute("UPDATE log SET modules = DEFAULT WHERE guild_id = $1", ctx.guild.id) else: @@ -86,7 +82,7 @@ async def log_module_add(self, ctx: "Context", module: str): @app_commands.rename(module="logremove_specs-args-module-name") @app_commands.describe(module="logremove_specs-args-module-description") @commands.has_permissions(manage_guild=True) - async def log_module_remove(self, ctx: "Context", module: str): + async def log_module_remove(self, ctx: Context, module: str): if module == "all": await self.client.db.execute("UPDATE log SET modules = ARRAY[] WHERE guild_id = $1", ctx.guild.id) else: @@ -100,7 +96,7 @@ async def log_module_remove(self, ctx: "Context", module: str): class LogListeners(commands.Cog): - def __init__(self, client: "MyClient") -> None: + def __init__(self, client: MyClient) -> None: self.client = client # TODO: @@ -126,11 +122,11 @@ async def _get_actor( target_id: int, actions: discord.AuditLogAction | int | list[discord.AuditLogAction | int], changed_attribute: Optional[str] = None, - ) -> Optional[discord.User]: + ) -> Optional[Union[discord.User, discord.Member]]: """Retreives the actor from the audit logs for a specific action on a channel or role.""" try: async for entry in guild.audit_logs( - limit=15, action=actions if isinstance(actions, (discord.AuditLogAction, int)) else discord.abc.MISSING + limit=15, action=actions if isinstance(actions, discord.AuditLogAction) else discord.abc.MISSING ): target_channel_matches = False if ( @@ -139,7 +135,7 @@ async def _get_actor( and entry.target.id == target_id ): target_channel_matches = True - if hasattr(entry.extra, "channel") and entry.extra.channel and entry.extra.channel.id == target_id: + if entry.extra.channel and entry.extra.channel.id == target_id: target_channel_matches = True if not target_channel_matches: @@ -259,7 +255,7 @@ async def send_webhook(self, guild_id: int, event: str, **kwargs): return custom_response = CustomResponse(self.client) - message: dict | str = await custom_response.get_message(key, self.client.get_guild(guild_id), **kwargs) + message = await custom_response.get_message(key, self.client.get_guild(guild_id), **kwargs) if isinstance(message, dict): await webhook.send(**message) else: @@ -298,6 +294,9 @@ async def _log_check(self, guild: Union[int, discord.Guild]) -> bool: @commands.Cog.listener() async def on_message_edit(self, before: discord.Message, after: discord.Message): + if not before.guild or not after.guild: + return + if before.content != after.content: before = CustomMessage.from_message(before) before.content = before.content or " " @@ -344,7 +343,7 @@ async def on_automod_rule_delete(self, rule: discord.AutoModRule): @commands.Cog.listener() async def on_automod_action(self, execution: discord.AutoModAction): await self.send_webhook( - execution.guild.id, + execution.guild_id, "action", execution=CustomAutoModAction.from_action(execution), ) @@ -508,10 +507,10 @@ async def on_message_delete(self, message: discord.Message): message.guild.id, "delete", message=CustomMessage.from_message(message), - deleted_by=CustomUser.from_user(deleted_by), + deleted_by=CustomUser.from_user(deleted_by) if deleted_by else None, ) -async def setup(client: "MyClient") -> None: +async def setup(client: MyClient) -> None: await client.add_cog(LogCommands(client)) await client.add_cog(LogListeners(client)) diff --git a/cogs/mod.py b/cogs/mod.py index 24e86ff..7c1a4ea 100644 --- a/cogs/mod.py +++ b/cogs/mod.py @@ -1,15 +1,14 @@ -from __future__ import annotations - import datetime from copy import deepcopy from enum import Enum -from typing import TYPE_CHECKING, Any, Literal, Self +from typing import Any, Literal, Self import asyncpg import discord from discord import app_commands from discord.ext import commands +from core import Context, MyClient from helpers import ( CustomGuild, CustomMember, @@ -21,10 +20,6 @@ seconds_to_text, text_to_seconds, ) -from main import MyClient - -if TYPE_CHECKING: - from main import Context class CaseType(Enum): @@ -627,7 +622,7 @@ async def after_deletion(self) -> None: @commands.guild_only() @app_commands.guild_only() class Moderation(commands.GroupCog, name="Moderation", group_name="mod"): - def __init__(self, client: "MyClient") -> None: + def __init__(self, client: MyClient) -> None: self.client = client self.custom_response = custom_response.CustomResponse(client, "mod") @@ -676,7 +671,7 @@ async def cog_load(self): @commands.has_permissions(moderate_members=True) async def warn( self, - ctx: "Context", + ctx: Context, user: discord.Member, expires: str = None, *, @@ -737,7 +732,7 @@ async def warn( @commands.has_permissions(moderate_members=True) async def mute( self, - ctx: "Context", + ctx: Context, user: discord.Member, expires: str, *, @@ -774,7 +769,7 @@ async def mute( @app_commands.describe(user="unmute_specs-args-user-description") @app_commands.checks.has_permissions(moderate_members=True) @commands.has_permissions(moderate_members=True) - async def unmute(self, ctx: "Context", user: discord.Member): + async def unmute(self, ctx: Context, user: discord.Member): if user.timed_out_until: cases = await Mute.from_db( self.client.db, @@ -802,7 +797,7 @@ async def unmute(self, ctx: "Context", user: discord.Member): ) @app_commands.checks.has_permissions(kick_members=True) @commands.has_permissions(kick_members=True) - async def kick(self, ctx: "Context", user: discord.Member, *, reason: str = None): + async def kick(self, ctx: Context, user: discord.Member, *, reason: str = None): if user == ctx.me: await ctx.send("mod.kick.errors.bot") return @@ -835,7 +830,7 @@ async def kick(self, ctx: "Context", user: discord.Member, *, reason: str = None @commands.has_permissions(ban_members=True) async def ban( self, - ctx: "Context", + ctx: Context, user: discord.User, expires: str = None, *, @@ -870,7 +865,7 @@ async def ban( @app_commands.describe(user="unban_specs-args-user-description") @app_commands.checks.has_permissions(ban_members=True) @commands.has_permissions(ban_members=True) - async def unban(self, ctx: "Context", user: discord.User): + async def unban(self, ctx: Context, user: discord.User): cases = await Ban.from_db(self.client.db, self.client, ctx.guild, user=user) if cases: for case in cases: @@ -891,7 +886,7 @@ async def unban(self, ctx: "Context", user: discord.User): @commands.hybrid_command(name="slowmode", description="sm_specs-description", usage="sm_specs-usage") @app_commands.describe(duration="sm_specs-args-duration-description", channel="sm_specs-args-channel-description") @app_commands.rename(duration="sm_specs-args-duration-name", channel="sm_specs-args-channel-name") - async def slowmode(self, ctx: "Context", duration: str = None, channel: discord.TextChannel = None): + async def slowmode(self, ctx: Context, duration: str = None, channel: discord.TextChannel = None): if not duration: await ctx.send("mod.slowmode.current_slowmode", channel=CustomTextChannel.from_channel(ctx.channel)) return @@ -920,7 +915,7 @@ async def slowmode(self, ctx: "Context", duration: str = None, channel: discord. @commands.guild_only() @app_commands.guild_only() class Cases(commands.Cog, name="Cases"): - def __init__(self, client: "MyClient") -> None: + def __init__(self, client: MyClient) -> None: self.client = client self.custom_response = custom_response.CustomResponse(client, "mod") @@ -932,7 +927,7 @@ def __init__(self, client: "MyClient") -> None: ) @app_commands.rename(case_id="caseinfo_specs-args-case_id-name") @app_commands.describe(case_id="caseinfo_specs-args-case_id-description") - async def case(self, ctx: "Context", case_id: str): + async def case(self, ctx: Context, case_id: str): try: case_id = int(case_id) except ValueError: @@ -962,7 +957,7 @@ async def case(self, ctx: "Context", case_id: str): @app_commands.rename(case_id="casedel_specs-args-case_id-name") @app_commands.checks.has_permissions(moderate_members=True) @commands.has_permissions(moderate_members=True) - async def delete(self, ctx: "Context", case_id: str): + async def delete(self, ctx: Context, case_id: str): try: # because discord's app commands only support int up to 2^54, but discord snowflakes are 2^64, # we need to convert the case id to an int ourselves :( @@ -1016,7 +1011,7 @@ async def delete(self, ctx: "Context", case_id: str): @commands.has_permissions(moderate_members=True) async def edit( self, - ctx: "Context", + ctx: Context, case_id: str, value: Literal["expires", "reason", "message"], *, @@ -1051,7 +1046,7 @@ async def edit( ) @app_commands.describe(user="caselist_specs-args-user-description") @app_commands.rename(user="caselist_specs-args-user-name") - async def list(self, ctx: "Context", user: discord.Member = None): + async def list(self, ctx: Context, user: discord.Member = None): user = user or ctx.author cases = await Case.from_user(self.client.db, user, self.client, ctx.guild, 10) @@ -1088,6 +1083,6 @@ async def list(self, ctx: "Context", user: discord.Member = None): await ctx.send(**message) -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Moderation(client)) await client.add_cog(Cases(client)) diff --git a/cogs/say.py b/cogs/say.py index bdfbab0..bb1c2b7 100644 --- a/cogs/say.py +++ b/cogs/say.py @@ -1,7 +1,4 @@ -from __future__ import annotations - import random -from typing import TYPE_CHECKING from urllib.parse import quote_plus import discord @@ -9,16 +6,14 @@ from discord import app_commands from discord.ext import commands +from core import Context, MyClient from helpers import CustomResponse from helpers.convert import text_to_emoji from helpers.regex import DISCORD_MESSAGE_URL -if TYPE_CHECKING: - from main import Context, MyClient - class Say(commands.Cog, name="Says"): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client self.custom_response: CustomResponse = CustomResponse(client) @@ -28,7 +23,7 @@ def __init__(self, client: "MyClient"): @commands.has_permissions(manage_messages=True) @app_commands.rename(message="say_specs-args-message-name") @app_commands.describe(message="say_specs-args-message-description") - async def say(self, ctx: "Context", *, message: commands.Range[str, 1, 2000]): + async def say(self, ctx: Context, *, message: commands.Range[str, 1, 2000]): await ctx.send("say.message", message=message) @say.command(name="channel", description="chsay_specs-description", usage="chsay_specs-usage") @@ -37,7 +32,7 @@ async def say(self, ctx: "Context", *, message: commands.Range[str, 1, 2000]): @app_commands.describe( channel="chsay_specs-args-channel-description", message="chsay_specs-args-message-description" ) - async def channel_say(self, ctx: "Context", channel: discord.TextChannel, *, message: commands.Range[str, 1, 2000]): + async def channel_say(self, ctx: Context, channel: discord.TextChannel, *, message: commands.Range[str, 1, 2000]): await channel.send(message, allowed_mentions=discord.AllowedMentions.none()) @say.command(name="edit", description="editmsg_specs-description", usage="editmsg_specs-usage") @@ -46,7 +41,7 @@ async def channel_say(self, ctx: "Context", channel: discord.TextChannel, *, mes @app_commands.describe( message_link="editmsg_specs-args-link-description", content="editmsg_specs-args-content-description" ) - async def edit_message(self, ctx: "Context", message_link: str, *, content: commands.Range[str, 1, 2000]): + async def edit_message(self, ctx: Context, message_link: str, *, content: commands.Range[str, 1, 2000]): match = DISCORD_MESSAGE_URL.search(message_link) try: if match: @@ -68,21 +63,21 @@ async def edit_message(self, ctx: "Context", message_link: str, *, content: comm @commands.has_permissions(manage_messages=True) @app_commands.rename(message="asciisay_specs-args-message-name") @app_commands.describe(message="asciisay_specs-args-message-description") - async def ascii_say(self, ctx: "Context", *, message: commands.Range[str, 1, 20]): + async def ascii_say(self, ctx: Context, *, message: commands.Range[str, 1, 20]): await ctx.send("say.ascii", ascii=text2art(message)) @say.command(name="emoji", description="emojisay_specs-description", usage="emojisay_specs-usage") @commands.has_permissions(manage_messages=True) @app_commands.rename(message="emojisay_specs-args-message-name") @app_commands.describe(message="emojisay_specs-args-message-description") - async def emoji_say(self, ctx: "Context", *, message: commands.Range[str, 1, 20]): + async def emoji_say(self, ctx: Context, *, message: commands.Range[str, 1, 20]): await ctx.send("say.emoji", emoji=" ".join(text_to_emoji(message))) @say.command(name="achievement", description="mcsay_specs-description", usage="mcsay_specs-usage") @commands.has_permissions(manage_messages=True) @app_commands.rename(message="mcsay_specs-args-message-name") @app_commands.describe(message="mcsay_specs-args-message-description") - async def achievement_say(self, ctx: "Context", *, message: commands.Range[str, 1, 50]): + async def achievement_say(self, ctx: Context, *, message: commands.Range[str, 1, 50]): icon = random.randint(1, 29) localized_title = await self.custom_response("say.achievement.title", ctx) achievement_title = quote_plus(localized_title) @@ -94,7 +89,7 @@ async def achievement_say(self, ctx: "Context", *, message: commands.Range[str, @commands.has_permissions(manage_messages=True) @app_commands.rename(data="qr_specs-args-data-name") @app_commands.describe(data="qr_specs-args-data-description") - async def qr_code(self, ctx: "Context", *, data: commands.Range[str, 1, 500]): + async def qr_code(self, ctx: Context, *, data: commands.Range[str, 1, 500]): data = quote_plus(data) qr = f"https://api.qrserver.com/v1/create-qr-code/?data={data}&size=1000x1000&qzone=2" await ctx.send("say.qr", qr=qr) @@ -103,16 +98,16 @@ async def qr_code(self, ctx: "Context", *, data: commands.Range[str, 1, 500]): @commands.has_permissions(manage_messages=True) @app_commands.rename(message="reversesay_specs-args-msg-name") @app_commands.describe(message="reversesay_specs-args-msg-description") - async def reverse_say(self, ctx: "Context", *, message: commands.Range[str, 1, 2000]): + async def reverse_say(self, ctx: Context, *, message: commands.Range[str, 1, 2000]): await ctx.send("say.reverse", message=message[::-1]) @say.command(name="clap", description="clapsay_specs-description", usage="clapsay_specs-usage") @commands.has_permissions(manage_messages=True) @app_commands.rename(message="clapsay_specs-args-message-name") @app_commands.describe(message="clapsay_specs-args-message-description") - async def clap_say(self, ctx: "Context", *, message: commands.Range[str, 1, 500]): + async def clap_say(self, ctx: Context, *, message: commands.Range[str, 1, 500]): await ctx.send("say.clap", message=message.replace(" ", "👏")) -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Say(client)) diff --git a/cogs/setup.py b/cogs/setup.py index 899ffb0..663396e 100644 --- a/cogs/setup.py +++ b/cogs/setup.py @@ -1,16 +1,13 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional +from typing import Optional from discord import app_commands from discord.ext import commands -if TYPE_CHECKING: - from main import Context, MyClient +from core import Context, MyClient class Setup(commands.Cog, name="Setup"): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client @commands.hybrid_command(name="prefix", description="prefix_specs-description") @@ -20,7 +17,7 @@ def __init__(self, client: "MyClient"): prefix="prefix_specs-args-prefix-description", mention="prefix_specs-args-mention-description", ) - async def prefix(self, ctx: "Context", prefix: str, mention: Optional[bool] = True): + async def prefix(self, ctx: Context, prefix: str, mention: Optional[bool] = True): if len(prefix) > 10: return await ctx.send("setup.prefix.errors.long", prefix=prefix, limit=10) await self.client.db.execute( @@ -32,5 +29,5 @@ async def prefix(self, ctx: "Context", prefix: str, mention: Optional[bool] = Tr return await ctx.send("setup.prefix.set", prefix=prefix) -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Setup(client)) diff --git a/cogs/snapshot.py b/cogs/snapshot.py index 082a9f6..bab86e4 100644 --- a/cogs/snapshot.py +++ b/cogs/snapshot.py @@ -1,10 +1,8 @@ -from __future__ import annotations - import asyncio import datetime import json import uuid -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from uuid import UUID import asyncpg @@ -12,18 +10,17 @@ from discord import app_commands from discord.ext import commands -if TYPE_CHECKING: - from main import Context, MyClient +from core import Context, MyClient class Snapshot(commands.Cog, name="Snapshots"): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client self.connection: asyncpg.Pool = client.db self.custom_response = client.custom_response @staticmethod - async def save(ctx: "Context") -> dict: + async def save(ctx: Context) -> dict: """ Creates a snapshot of the server. @@ -96,7 +93,7 @@ async def save(ctx: "Context") -> dict: return payload - async def create_snapshot(self, ctx: "Context") -> Optional[UUID]: + async def create_snapshot(self, ctx: Context) -> Optional[UUID]: """ Creates a snapshot and inserts it into the database. @@ -150,7 +147,7 @@ async def get_snapshot(self, code: Union[str, UUID]) -> Optional[dict]: else: return None - async def delete_all_channels(self, ctx: "Context"): + async def delete_all_channels(self, ctx: Context): """ Deletes all channels in the server. @@ -166,7 +163,7 @@ async def delete_all_channels(self, ctx: "Context"): continue await asyncio.sleep(0.5) - async def delete_all_roles(self, ctx: "Context"): + async def delete_all_roles(self, ctx: Context): """ Deletes all roles in the server. @@ -182,7 +179,7 @@ async def delete_all_roles(self, ctx: "Context"): continue await asyncio.sleep(0.5) - async def load_snapshot(self, ctx: "Context", payload: dict): + async def load_snapshot(self, ctx: Context, payload: dict): for x in sorted( payload["roles"], key=lambda r: payload["roles"][r]["position"], @@ -336,7 +333,7 @@ async def load_snapshot(self, ctx: "Context", payload: dict): ) @app_commands.checks.has_permissions(administrator=True) @commands.has_permissions(administrator=True) - async def snapshot(self, ctx: "Context"): + async def snapshot(self, ctx: Context): code = await self.create_snapshot(ctx) await ctx.send("snapshot.create", code=code) @@ -346,7 +343,7 @@ async def snapshot(self, ctx: "Context"): @app_commands.rename(code="ss_load_specs-args-code-name") @app_commands.checks.has_permissions(administrator=True) @commands.has_permissions(administrator=True) - async def load(self, ctx: "Context", code: str): + async def load(self, ctx: Context, code: str): payload = await self.get_snapshot(code) if not payload: return await ctx.send("snapshot.not_found") @@ -367,5 +364,5 @@ async def load(self, ctx: "Context", code: str): await ctx.send("snapshot.load") -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Snapshot(client)) diff --git a/cogs/status.py b/cogs/status.py index d0efd84..fe2e3b5 100644 --- a/cogs/status.py +++ b/cogs/status.py @@ -1,17 +1,15 @@ import asyncio import logging import random -from typing import TYPE_CHECKING import discord from discord.ext import commands, tasks -if TYPE_CHECKING: - from main import MyClient +from core import MyClient class Status(commands.Cog, command_attrs=dict(hidden=True)): - def __init__(self, client: "MyClient"): + def __init__(self, client: MyClient): self.client = client @commands.Cog.listener() @@ -50,5 +48,5 @@ async def cog_load(self) -> None: await self.on_connect() -async def setup(client: "MyClient"): +async def setup(client: MyClient): await client.add_cog(Status(client)) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..19e9b2a --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,5 @@ +from core.argument import Argument +from core.command import Command +from core.slash_localization import SlashCommandLocalizer, update_slash_localizations, slash_command_localization +from core.context import Context +from core.bot import MyClient diff --git a/core/argument.py b/core/argument.py new file mode 100644 index 0000000..26a9ab0 --- /dev/null +++ b/core/argument.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import Any + +from discord.ext import commands + +from core.slash_localization import slash_command_localization + + +@dataclass +class Argument: + name: str + description: str + default: Any + annotation: Any + required: bool + + @classmethod + def from_param(cls, param: commands.Parameter, ctx: commands.Context): + if slash_command_localization: + localized_name = slash_command_localization(param.displayed_name or param.name, ctx) + description = slash_command_localization(param.description, ctx) if param.description else "-" + return cls( + name=localized_name if isinstance(localized_name, str) else "arg", + description=description if isinstance(description, str) and description else "-", + default=param.default, + annotation=param.annotation, + required=param.required, + ) + return None diff --git a/core/bot.py b/core/bot.py new file mode 100644 index 0000000..c4d009e --- /dev/null +++ b/core/bot.py @@ -0,0 +1,337 @@ +import asyncio +import datetime +import json +import os +import socket +import traceback +from logging import getLogger +from pathlib import Path +from time import perf_counter +from typing import Any, Optional, Union + +import aiohttp +import asyncpg +import discord +from discord import app_commands +from discord.ext import commands, localization +from helpers.emojis import LOADING + +from core import Command, Context, SlashCommandLocalizer, slash_command_localization, update_slash_localizations +from helpers import custom_response, seconds_to_text + + +class MyClient(commands.AutoShardedBot): + """Represents the bot client. Inherits from `commands.AutoShardedBot`.""" + + def __init__(self): + update_slash_localizations() + self.logger = getLogger(__name__) + self.uptime: Optional[datetime.datetime] = None + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + intents: discord.Intents = discord.Intents.all() + self.db: asyncpg.Pool | None = None + self.session: aiohttp.ClientSession | None = None + self.ready_event = asyncio.Event() + self.owner_ids = { + 648168353453572117, # pearoo + 657350415511322647, # liba + 452133888047972352, # aki26 + 1051181672508444683, # sarky + } + super().__init__( + command_prefix=self.get_prefix, + heartbeat_timeout=150.0, + intents=intents, + case_insensitive=False, + activity=discord.CustomActivity(name="Bot starting...", emoji="🟡"), + status=discord.Status.idle, + chunk_guilds_at_startup=False, + loop=self.loop, + member_cache_flags=discord.MemberCacheFlags.from_intents(intents), + max_messages=20000, + allowed_contexts=app_commands.AppCommandContext(guild=True, dm_channel=True, private_channel=True), + allowed_installs=app_commands.AppInstallationType(guild=True, user=True), + allowed_mentions=discord.AllowedMentions(everyone=False, roles=False), + ) + self.custom_response = custom_response.CustomResponse(self) + + async def request(self, url: str): + async with self.session.get(url) as response: + return await response.json() + + async def get_prefix(self, message: discord.Message) -> Union[str, list[str]]: + if __debug__: + return "?" + if not message.guild: + return "?!" + row = await self.db.fetchrow("SELECT prefix, mention FROM guilds WHERE guild_id = $1", message.guild.id) + prefix, mention = row.get("prefix", "?!"), row.get("mention", True) + if mention: + return commands.when_mentioned_or(prefix)(self, message) + else: + return prefix + + async def on_guild_join(self, guild: discord.Guild): + row = await self.db.fetchrow("SELECT * FROM guilds WHERE guild_id = $1", guild.id) + if not row: + await self.db.execute("INSERT INTO guilds (guild_id) VALUES ($1)", guild.id) + + async def get_context( + self, + origin: Union[discord.Message, discord.Interaction], + /, + *, + cls=Context, + ) -> Any: + return await super().get_context(origin, cls=cls) + + async def setup_hook(self): + self.logger.info("Running initial setup hook...") + benchmark = perf_counter() + + await self.database_initialization() + await self.first_time_database() + await self.load_cogs() + await self.tree.set_translator(SlashCommandLocalizer()) + self.session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(resolver=aiohttp.AsyncResolver(), family=socket.AF_INET) + ) + end = perf_counter() - benchmark + self.logger.info(f"Initial setup hook complete in {end:.2f}s") + + @staticmethod + async def db_connection_init(connection: asyncpg.connection.Connection): + await connection.set_type_codec("jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") + await connection.set_type_codec("json", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") + + async def database_initialization(self): + self.logger.info("Connecting to database...") + benchmark = perf_counter() + # Connects to database + self.db = await asyncpg.create_pool( + host=os.getenv("DB_HOST"), + database="lumin_beta", + # ! Replace with default database name when ran for the first time + # ! Any subsequent executions of this code must use `database="lumin"` + user="lumin", + password=os.getenv("DB_PASSWORD"), + port=os.getenv("DB_PORT"), + timeout=None, + init=self.db_connection_init, + max_inactive_connection_lifetime=120, # timeout is 2 mins + ) + end = perf_counter() - benchmark + self.logger.info(f"Connected to database in {end:.2f}s") + + async def first_time_database(self): + self.logger.info("Running first time database setup...") + benchmark = perf_counter() + database_exists = await self.db.fetchval( + "SELECT 1 FROM information_schema.schemata WHERE schema_name = 'public'" + ) + if not database_exists: + await self.db.execute("CREATE DATABASE lumin_beta OWNER lumin") + self.logger.info("Created database 'lumin'!") + + with open("first_time.sql", encoding="utf-8") as f: + # "ok ok but pearoo how do i update this if i + # feel like updating the db structure for no + # particular reason" + + # please just use pycharm its actually goated, + # if you add the db to the project and select + # lumin.public.tables then press Ctrl + Alt + G + # it will generate the SQL for you which is crazy + # tbh like wtf + await self.db.execute(f.read()) + + end = perf_counter() - benchmark + self.logger.info( + f"First time database setup complete in {end:.2f}s, you may now comment out the execution of this method in setup_hook" + ) + + async def load_cogs(self): + self.logger.info("Loading cogs...") + benchmark = perf_counter() + + # Load all cogs within the cogs folder + allowed: list[str] = [ + "afk", + "basic", + "economy", + "giveaway", + "help", + "imagesinfo", + "log", + "mod", + "say", + "setup", + "snapshot", + "status", + ] + + cogs = Path("cogs").glob("*.py") + for cog in cogs: + if cog.stem in allowed: # if you're having issues with cogs not loading, check this list + await self.load_extension(f"cogs.{cog.stem}") + self.logger.info(f"Loaded extension {cog.name}") + end = perf_counter() - benchmark + self.logger.info(f"Loaded cogs in {end:.2f}s") + + async def on_ready(self): + if not hasattr(self, "uptime"): + self.uptime = discord.utils.utcnow() + self.logger.info("Bot is ready!") + self.logger.info(f"Servers: {len(self.guilds)}, Commands: {len(self.commands)}, Shards: {self.shard_count}") + self.logger.info(f"Loaded cogs: {', '.join([cog for cog in self.cogs])}") + self.logger.info(f"discord-localization v{localization.__version__}") + + async def handle_error( + self, + ctx: Context, + error: Union[discord.errors.DiscordException, app_commands.AppCommandError], + ): + command = None + if isinstance(ctx, (Context, commands.Context)): + command = Command.from_ctx(ctx) + elif hasattr(ctx, "command") and ctx.command: + command = Command.from_ctx(ctx) + + if isinstance(error, commands.HybridCommandError): + error = error.original # type: ignore + + match error: + case commands.MissingRequiredArgument(): + error: commands.MissingRequiredArgument + name = slash_command_localization(error.param.name, ctx) + parameter = f"[{name if error.param.required else f'({name})'}]" + + await ctx.send( + "errors.missing_required_argument", + command=command, + parameter=parameter, + ) + case commands.BotMissingPermissions() | app_commands.BotMissingPermissions(): + error: commands.BotMissingPermissions + permissions = [ + (await self.custom_response(f"permissions.{permission}", ctx)) + for permission in error.missing_permissions + ] + + await ctx.send( + "errors.bot_missing_permissions", + command=command, + permissions=", ".join(permissions), + ) + case commands.BadArgument(): + await ctx.send("errors.bad_argument", command=command) + raise error + case commands.MissingPermissions() | app_commands.MissingPermissions(): + error: commands.MissingPermissions + permissions = [ + (await self.custom_response(f"permissions.{permission}", ctx)) + for permission in error.missing_permissions + ] + + await ctx.send( + "errors.missing_permissions", + command=command, + permissions=", ".join(permissions), + ) + case commands.CommandOnCooldown(): + error: commands.CommandOnCooldown + retry_after = seconds_to_text(int(error.retry_after)) + await ctx.send( + "errors.command_on_cooldown", + command=command, + retry_after=retry_after, + ) + case commands.ChannelNotFound(): + await ctx.send("errors.channel_not_found", command=command) + case commands.EmojiNotFound(): + await ctx.send("errors.emoji_not_found", command=command) + case commands.MemberNotFound(): + await ctx.send("errors.member_not_found", command=command) + case commands.UserNotFound(): + await ctx.send("errors.user_not_found", command=command) + case commands.RoleNotFound(): + await ctx.send("errors.role_not_found", command=command) + case discord.Forbidden(): + await ctx.send("errors.forbidden", command=command) + case commands.NotOwner(): + await ctx.send("errors.not_owner", command=command) + case commands.CommandNotFound() | app_commands.CommandNotFound(): + return + case discord.RateLimited(): + channel: discord.TextChannel = await self.fetch_channel(1268260404677574697) + webhook: discord.Webhook | None = discord.utils.get( + await channel.webhooks(), + name=f"{self.user.display_name} Rate Limit", + ) + if not webhook: + webhook = await channel.create_webhook(name=f"{self.user.display_name} Rate Limit") + await webhook.send( + content=f"# ⚠️ RATE LIMIT\n**Guild:** {ctx.guild.name} / {ctx.guild.id}\n**User:** {ctx.author} / {ctx.author.id}\n**Command:** {ctx.command} {'- failed' if ctx.command_failed else ''}\n**Error:** {error}" + ) + raise error + case _: + # if the error is unknown, log it + channel: discord.TextChannel = ( + ctx.channel if __debug__ and ctx and ctx.channel else await self.fetch_channel(1268260404677574697) + ) + stack = "".join(traceback.format_exception(type(error), error, error.__traceback__)) + # if stack is more than 1700 characters, turn it into a .txt file and store it as an attachment + too_long = len(stack) > 1700 + file: discord.File | None = None + if too_long: + with open("auto-report_stack-trace.txt", "w") as f: + f.write(stack) + file = discord.File(fp="auto-report_stack-trace.txt", filename="error.txt") + stack = "The stack trace was too long to send in a message, so it was saved as a file." + webhook: discord.Webhook = discord.utils.get( + await channel.webhooks(), name=f"{self.user.display_name} Errors" + ) + if not webhook: + webhook = await channel.create_webhook( + name=f"{self.user.display_name} Errors", + avatar=await ctx.me.avatar.read(), + ) + await webhook.send( + content=f"**ID:** {ctx.message.id}\n" + f"**Guild:** {ctx.guild.name if ctx.guild else 'DMs'} / {ctx.guild.id if ctx.guild else 0}\n" + f"**User:** {ctx.author} / {ctx.author.id}\n" + f"**Command:** {ctx.command}\n" + f"```{stack}```", + file=file if too_long and file else discord.abc.MISSING, + ) # type: ignore + await ctx.reply( + f"An error has occured and has been reported to the developers. Report ID: `{ctx.message.id}`", + mention_author=False, + ) + raise error + + async def on_command_error(self, ctx: Context, error: discord.errors.DiscordException): + await self.handle_error(ctx, error) + + async def on_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError): + await self.handle_error(await Context.from_interaction(interaction), error) + + async def before_invoke(self, ctx: Context): + if ctx.guild: + is_set_up = await self.db.fetchrow("SELECT * FROM guilds WHERE guild_id = $1", ctx.guild.id) + if not is_set_up: + await self.db.execute("INSERT INTO guilds (guild_id) VALUES ($1)", ctx.guild.id) + try: + # Signals that the bot is still thinking / performing a task + if ctx.interaction and ctx.interaction.type == discord.InteractionType.application_command: + await ctx.interaction.response.defer(thinking=True) # type: ignore + else: + await ctx.message.add_reaction(LOADING) + except discord.HTTPException: + pass + + async def after_invoke(self, ctx: Context): + try: + await ctx.message.remove_reaction(LOADING, ctx.me) + except discord.HTTPException: + pass diff --git a/core/command.py b/core/command.py new file mode 100644 index 0000000..7565686 --- /dev/null +++ b/core/command.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import Optional + +from discord.ext import commands + +from core.slash_localization import slash_command_localization + + +@dataclass +class Command: + name: str + description: str + usage: str + prefix: str + aliases: Optional[str] + + @classmethod + def from_ctx(cls, ctx: commands.Context): + if ctx.command and slash_command_localization: + usage = ( + slash_command_localization(ctx.command.usage, ctx) if ctx.command.usage else ctx.command.qualified_name + ) + description = slash_command_localization(ctx.command.description, ctx) + return cls( + name=ctx.command.qualified_name, + description=description if isinstance(description, str) and description else "-", + usage=f"{ctx.clean_prefix}{usage}", + prefix=ctx.clean_prefix, + aliases=", ".join(ctx.command.aliases) if len(ctx.command.aliases) > 0 else None, + ) + return None + + @classmethod + def from_command(cls, command: commands.Command, ctx: commands.Context): + if slash_command_localization: + usage = slash_command_localization(command.usage, ctx) if command.usage else command.qualified_name + description = slash_command_localization(command.description, ctx) + return cls( + name=command.qualified_name, + description=description if isinstance(description, str) and description else "-", + usage=f"{ctx.clean_prefix}{usage}", + prefix=ctx.clean_prefix, + aliases=", ".join(command.aliases) if len(command.aliases) > 0 else None, + ) + return None diff --git a/core/context.py b/core/context.py new file mode 100644 index 0000000..b0063ba --- /dev/null +++ b/core/context.py @@ -0,0 +1,85 @@ +from typing import Optional, Sequence, Union + +import discord +from discord.ext import commands + + +class Context(commands.Context): + async def send( # type: ignore + self, + key: Optional[str] = None, + *, + content: Optional[str] = None, + tts: bool = False, + embed: Optional[discord.Embed] = None, + embeds: Optional[Sequence[discord.Embed]] = None, + file: Optional[discord.File] = None, + files: Optional[Sequence[discord.File]] = None, + stickers: Optional[Sequence[Union[discord.GuildSticker, discord.StickerItem]]] = None, + delete_after: Optional[float] = None, + nonce: Optional[Union[str, int]] = None, + allowed_mentions: Optional[discord.AllowedMentions] = None, + reference: Optional[Union[discord.Message, discord.MessageReference, discord.PartialMessage]] = None, + mention_author: Optional[bool] = None, + view: Optional[discord.ui.View] = None, + suppress_embeds: bool = False, + ephemeral: bool = False, + silent: bool = False, + poll: Optional[discord.Poll] = None, + **format_kwargs: object, + ) -> discord.Message: + """ + Sends a localized or raw message by merging the arguments passed to send with a + localized payload (if a localization key is provided) and then delegating to + super().send. + + Exactly one of the following must be provided: + - A localization key as the first positional argument (key) + - A raw message string via the keyword-only argument `content` + + No errors will be raised if both or neither are provided. + """ + base_args = { + "content": content, + "tts": tts, + "embed": embed, + "embeds": embeds, + "file": file, + "files": files, + "stickers": stickers, + "nonce": nonce, + "allowed_mentions": allowed_mentions, + "reference": reference, + "mention_author": mention_author, + "view": view, + "suppress_embeds": suppress_embeds, + "ephemeral": ephemeral, + "silent": silent, + "poll": poll, + } + + locale_str = self.guild.preferred_locale if self.guild and self.guild.preferred_locale else "en" + + if key is not None: + localized_payload = await self.bot.custom_response.get_message(key, locale_str, **format_kwargs) + else: + localized_payload = content + + if isinstance(localized_payload, dict): + base_args.update(localized_payload) + else: + base_args["content"] = localized_payload + + merged_args = {k: v for k, v in base_args.items() if v is not None} + + msg = await super().send(**merged_args) + if delete_after is not None: + await msg.delete(delay=delete_after) + return msg + + async def reply(self, *args, **kwargs) -> discord.Message: + """ + Behaves like send, but automatically sets reference to self.message. Don't use this unless it's necessary. + """ + kwargs.setdefault("reference", self.message) + return await self.send(*args, **kwargs) diff --git a/core/slash_localization.py b/core/slash_localization.py new file mode 100644 index 0000000..b07df5e --- /dev/null +++ b/core/slash_localization.py @@ -0,0 +1,64 @@ +import json +from logging import getLogger +from pathlib import Path +from time import perf_counter +from typing import Optional + +import discord +from discord import app_commands +from discord.ext import localization + +logger = getLogger(__name__) + +slash_command_localization: Optional[localization.Localization] = None + + +def update_slash_localizations(): + slash_localizations = {} + + # load the slash localization files and combine them into one dictionary + for file_path in Path("./slash_localization").glob("*.l10n.json"): + lang = file_path.stem.removesuffix(".l10n") + try: + data = json.loads(Path(file_path).read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Expected dict in {file_path}, got {type(data).__name__}") + if lang not in slash_localizations: + slash_localizations[lang] = {} + slash_localizations[lang].update(data) + except Exception as e: + logger.warning(f"Failed to load {file_path}: {e}") + global slash_command_localization + slash_command_localization = localization.Localization(slash_localizations, default_locale="en", separator="-") + + +class SlashCommandLocalizer(app_commands.Translator): + """Localizes slash commands and their arguments using discord-localization. + This uses the localization set by the user, not the guild's locale.""" + + async def translate( + self, + string: app_commands.locale_str, + locale: discord.Locale, + context: app_commands.TranslationContext, + ) -> str | None: + if slash_command_localization: + localized = slash_command_localization.translate(string.message, str(locale)) + if not isinstance(localized, str): + return None + return localized + return None + + async def unload(self) -> None: + benchmark = perf_counter() + logger.info("Unloading Slash Localizer...") + await super().unload() + end = perf_counter() - benchmark + logger.info(f"Unloaded Slash Localizer in {end:.2f}s") + + async def load(self) -> None: + benchmark = perf_counter() + logger.info("Loading Slash Localizer...") + await super().load() + end = perf_counter() - benchmark + logger.info(f"Loaded Slash Localizer in {end:.2f}s") diff --git a/helpers/custom_args.py b/helpers/custom_args.py index b1e6b14..cf227b7 100644 --- a/helpers/custom_args.py +++ b/helpers/custom_args.py @@ -2,11 +2,13 @@ import datetime from dataclasses import dataclass, field -from typing import Union, Optional, Literal, Sequence -from cpuinfo import get_cpu_info -from emoji import demojize +from typing import Literal, Optional, Sequence, Union + import discord import psutil +from cpuinfo import get_cpu_info +from emoji import demojize + from .convert import seconds_to_text @@ -206,8 +208,8 @@ class CustomUser: """Returns a string that mentions the user.""" @classmethod - def from_user(cls, user: discord.User): - """Creates a ``CustomUser`` from a ``discord.User`` object.""" + def from_user(cls, user: Union[discord.User, discord.Member]): + """Creates a ``CustomUser`` from a ``discord.User`` or a ``discord.Member`` object.""" return cls( _name=f"{user.name}#{user.discriminator}" if user.discriminator != "0" else user.name, id=user.id, diff --git a/main.py b/main.py index 42d28be..634eb7d 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,12 @@ import asyncio -import datetime -import json import logging import os -import pathlib import platform -import socket -import time -import traceback -from dataclasses import dataclass -from pathlib import Path -from time import perf_counter -from typing import Any, Literal, Optional, Sequence, Union -import aiohttp -import asyncpg import discord -from discord import app_commands -from discord.ext import commands, localization from dotenv import load_dotenv -import helpers -from helpers import custom_response, emojis +from core.bot import MyClient for handler in logging.root.handlers[:]: # prevent double logging @@ -36,28 +21,6 @@ if __debug__: TOKEN = os.getenv("DEBUG_TOKEN") -slash_command_localization: Optional[localization.Localization] = None - - -def update_slash_localizations(): - slash_localizations = {} - - # load the slash localization files and combine them into one dictionary - for file_path in pathlib.Path("./slash_localization").glob("*.l10n.json"): - lang = file_path.stem.removesuffix(".l10n") - try: - data = json.loads(Path(file_path).read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise ValueError(f"Expected dict in {file_path}, got {type(data).__name__}") - if lang not in slash_localizations: - slash_localizations[lang] = {} - slash_localizations[lang].update(data) - except Exception as e: - logger.warning(f"Failed to load {file_path}: {e}") - global slash_command_localization - slash_command_localization = localization.Localization(slash_localizations, default_locale="en", separator="-") - - if __name__ == "__main__": if platform.system() != "Windows": import uvloop # type: ignore @@ -69,622 +32,10 @@ def update_slash_localizations(): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) logger.info("Using default event loop policy") - -@dataclass -class Command: - name: str - description: str - usage: str - prefix: str - aliases: Optional[str] - - @classmethod - def from_ctx(cls, ctx: commands.Context): - if ctx.command and slash_command_localization: - usage = ( - slash_command_localization(ctx.command.usage, ctx) if ctx.command.usage else ctx.command.qualified_name - ) - description = slash_command_localization(ctx.command.description, ctx) - return cls( - name=ctx.command.qualified_name, - description=description if isinstance(description, str) and description else "-", - usage=f"{ctx.clean_prefix}{usage}", - prefix=ctx.clean_prefix, - aliases=", ".join(ctx.command.aliases) if len(ctx.command.aliases) > 0 else None, - ) - return None - - @classmethod - def from_command(cls, command: commands.Command, ctx: commands.Context): - if slash_command_localization: - usage = slash_command_localization(command.usage, ctx) if command.usage else command.qualified_name - description = slash_command_localization(command.description, ctx) - return cls( - name=command.qualified_name, - description=description if isinstance(description, str) and description else "-", - usage=f"{ctx.clean_prefix}{usage}", - prefix=ctx.clean_prefix, - aliases=", ".join(command.aliases) if len(command.aliases) > 0 else None, - ) - return None - - -@dataclass -class Argument: - name: str - description: str - default: Any - annotation: Any - required: bool - - @classmethod - def from_param(cls, param: commands.Parameter, ctx: commands.Context): - if slash_command_localization: - localized_name = slash_command_localization(param.displayed_name or param.name, ctx) - description = slash_command_localization(param.description, ctx) if param.description else "-" - return cls( - name=localized_name if isinstance(localized_name, str) else "arg", - description=description if isinstance(description, str) and description else "-", - default=param.default, - annotation=param.annotation, - required=param.required, - ) - - -class Context(commands.Context): - async def send( # type: ignore - self, - key: Optional[str] = None, - *, - content: Optional[str] = None, - tts: bool = False, - embed: Optional[discord.Embed] = None, - embeds: Optional[Sequence[discord.Embed]] = None, - file: Optional[discord.File] = None, - files: Optional[Sequence[discord.File]] = None, - stickers: Optional[Sequence[Union[discord.GuildSticker, discord.StickerItem]]] = None, - delete_after: Optional[float] = None, - nonce: Optional[Union[str, int]] = None, - allowed_mentions: Optional[discord.AllowedMentions] = None, - reference: Optional[Union[discord.Message, discord.MessageReference, discord.PartialMessage]] = None, - mention_author: Optional[bool] = None, - view: Optional[discord.ui.View] = None, - suppress_embeds: bool = False, - ephemeral: bool = False, - silent: bool = False, - poll: Optional[discord.Poll] = None, - **format_kwargs: object, - ) -> discord.Message: - """ - Sends a localized or raw message by merging the arguments passed to send with a - localized payload (if a localization key is provided) and then delegating to - super().send. - - Exactly one of the following must be provided: - - A localization key as the first positional argument (key) - - A raw message string via the keyword-only argument `content` - - No errors will be raised if both or neither are provided. - """ - base_args = { - "content": content, - "tts": tts, - "embed": embed, - "embeds": embeds, - "file": file, - "files": files, - "stickers": stickers, - "nonce": nonce, - "allowed_mentions": allowed_mentions, - "reference": reference, - "mention_author": mention_author, - "view": view, - "suppress_embeds": suppress_embeds, - "ephemeral": ephemeral, - "silent": silent, - "poll": poll, - } - - locale_str = self.guild.preferred_locale if self.guild and self.guild.preferred_locale else "en" - - if key is not None: - localized_payload = await self.bot.custom_response.get_message(key, locale_str, **format_kwargs) - else: - localized_payload = content - - if isinstance(localized_payload, dict): - base_args.update(localized_payload) - else: - base_args["content"] = localized_payload - - merged_args = {k: v for k, v in base_args.items() if v is not None} - - msg = await super().send(**merged_args) - if delete_after is not None: - await msg.delete(delay=delete_after) - return msg - - async def reply(self, *args, **kwargs) -> discord.Message: - """ - Behaves like send, but automatically sets reference to self.message. Don't use this unless it's necessary. - """ - kwargs.setdefault("reference", self.message) - return await self.send(*args, **kwargs) - - -class SlashCommandLocalizer(app_commands.Translator): - """Localizes slash commands and their arguments using discord-localization. - This uses the localization set by the user, not the guild's locale.""" - - async def translate( - self, - string: app_commands.locale_str, - locale: discord.Locale, - context: app_commands.TranslationContext, - ) -> str | None: - if slash_command_localization: - localized = slash_command_localization.translate(string.message, str(locale)) - if not isinstance(localized, str): - return None - return localized - return None - - async def unload(self) -> None: - benchmark = perf_counter() - logger.info("Unloading Slash Localizer...") - await super().unload() - end = perf_counter() - benchmark - logger.info(f"Unloaded Slash Localizer in {end:.2f}s") - - async def load(self) -> None: - benchmark = perf_counter() - logger.info("Loading Slash Localizer...") - await super().load() - end = perf_counter() - benchmark - logger.info(f"Loaded Slash Localizer in {end:.2f}s") - - -class MyClient(commands.AutoShardedBot): - """Represents the bot client. Inherits from `commands.AutoShardedBot`.""" - - def __init__(self): - update_slash_localizations() - self.uptime: Optional[datetime.datetime] = None - self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() - intents: discord.Intents = discord.Intents.all() - self.db: asyncpg.Pool | None = None - self.session: aiohttp.ClientSession | None = None - self.ready_event = asyncio.Event() - self.owner_ids = { - 648168353453572117, # pearoo - 657350415511322647, # liba - 452133888047972352, # aki26 - 1051181672508444683, # sarky - } - super().__init__( - command_prefix=self.get_prefix, - heartbeat_timeout=150.0, - intents=intents, - case_insensitive=False, - activity=discord.CustomActivity(name="Bot starting...", emoji="🟡"), - status=discord.Status.idle, - chunk_guilds_at_startup=False, - loop=self.loop, - member_cache_flags=discord.MemberCacheFlags.from_intents(intents), - max_messages=20000, - allowed_contexts=app_commands.AppCommandContext(guild=True, dm_channel=True, private_channel=True), - allowed_installs=app_commands.AppInstallationType(guild=True, user=True), - allowed_mentions=discord.AllowedMentions(everyone=False, roles=False), - ) - self.custom_response = custom_response.CustomResponse(self) - - async def request(self, url: str): - async with self.session.get(url) as response: - return await response.json() - - async def get_prefix(self, message: discord.Message) -> Union[str, list[str]]: - if __debug__: - return "?" - if not message.guild: - return "?!" - row = await self.db.fetchrow("SELECT prefix, mention FROM guilds WHERE guild_id = $1", message.guild.id) - prefix, mention = row.get("prefix", "?!"), row.get("mention", True) - if mention: - return commands.when_mentioned_or(prefix)(self, message) - else: - return prefix - - async def on_guild_join(self, guild: discord.Guild): - row = await self.db.fetchrow("SELECT * FROM guilds WHERE guild_id = $1", guild.id) - if not row: - await self.db.execute("INSERT INTO guilds (guild_id) VALUES ($1)", guild.id) - - async def get_context( - self, - origin: Union[discord.Message, discord.Interaction], - /, - *, - cls=Context, - ) -> Any: - return await super().get_context(origin, cls=cls) - - async def setup_hook(self): - logger.info("Running initial setup hook...") - benchmark = perf_counter() - - await self.database_initialization() - await self.first_time_database() - await self.load_cogs() - await self.tree.set_translator(SlashCommandLocalizer()) - self.session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(resolver=aiohttp.AsyncResolver(), family=socket.AF_INET) - ) - end = perf_counter() - benchmark - logger.info(f"Initial setup hook complete in {end:.2f}s") - - @staticmethod - async def db_connection_init(connection: asyncpg.connection.Connection): - await connection.set_type_codec("jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") - await connection.set_type_codec("json", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") - - async def database_initialization(self): - logger.info("Connecting to database...") - benchmark = perf_counter() - # Connects to database - self.db = await asyncpg.create_pool( - host=os.getenv("DB_HOST"), - database="lumin_beta", - # ! Replace with default database name when ran for the first time - # ! Any subsequent executions of this code must use `database="lumin"` - user="lumin", - password=os.getenv("DB_PASSWORD"), - port=os.getenv("DB_PORT"), - timeout=None, - init=self.db_connection_init, - max_inactive_connection_lifetime=120, # timeout is 2 mins - ) - end = perf_counter() - benchmark - logger.info(f"Connected to database in {end:.2f}s") - - async def first_time_database(self): - logger.info("Running first time database setup...") - benchmark = perf_counter() - database_exists = await self.db.fetchval( - "SELECT 1 FROM information_schema.schemata WHERE schema_name = 'public'" - ) - if not database_exists: - await self.db.execute("CREATE DATABASE lumin_beta OWNER lumin") - logger.info("Created database 'lumin'!") - - with open("first_time.sql", encoding="utf-8") as f: - # "ok ok but pearoo how do i update this if i - # feel like updating the db structure for no - # particular reason" - - # please just use pycharm its actually goated, - # if you add the db to the project and select - # lumin.public.tables then press Ctrl + Alt + G - # it will generate the SQL for you which is crazy - # tbh like wtf - await self.db.execute(f.read()) - - end = perf_counter() - benchmark - logger.info( - f"First time database setup complete in {end:.2f}s, you may now comment out the execution of this method in setup_hook" - ) - - async def load_cogs(self): - logger.info("Loading cogs...") - benchmark = perf_counter() - - # Load all cogs within the cogs folder - allowed: list[str] = [ - "afk", - "basic", - "economy", - "giveaway", - "help", - "info", - "log", - "mod", - "say", - "setup", - "snapshot", - "status", - ] - - cogs = Path("./cogs").glob("*.py") - for cog in cogs: - if cog.stem in allowed: # if you're having issues with cogs not loading, check this list - await self.load_extension(f"cogs.{cog.stem}") - logger.info(f"Loaded extension {cog.name}") - end = perf_counter() - benchmark - logger.info(f"Loaded cogs in {end:.2f}s") - - async def on_ready(self): - if not hasattr(self, "uptime"): - self.uptime = discord.utils.utcnow() - logger.info("Bot is ready!") - logger.info(f"Servers: {len(self.guilds)}, Commands: {len(self.commands)}, Shards: {self.shard_count}") - logger.info(f"Loaded cogs: {', '.join([cog for cog in self.cogs])}") - logger.info(f"discord-localization v{localization.__version__}") - - async def handle_error( - self, - ctx: Context, - error: Union[discord.errors.DiscordException, app_commands.AppCommandError], - ): - command = None - if isinstance(ctx, (Context, commands.Context)): - command = Command.from_ctx(ctx) - elif hasattr(ctx, "command") and ctx.command: - command = Command.from_ctx(ctx) - - if isinstance(error, commands.HybridCommandError): - error = error.original # type: ignore - - match error: - case commands.MissingRequiredArgument(): - error: commands.MissingRequiredArgument - name = slash_command_localization(error.param.name, ctx) - parameter = f"[{name if error.param.required else f'({name})'}]" - - await ctx.send( - "errors.missing_required_argument", - command=command, - parameter=parameter, - ) - case commands.BotMissingPermissions() | app_commands.BotMissingPermissions(): - error: commands.BotMissingPermissions - permissions = [ - (await self.custom_response(f"permissions.{permission}", ctx)) - for permission in error.missing_permissions - ] - - await ctx.send( - "errors.bot_missing_permissions", - command=command, - permissions=", ".join(permissions), - ) - case commands.BadArgument(): - await ctx.send("errors.bad_argument", command=command) - raise error - case commands.MissingPermissions() | app_commands.MissingPermissions(): - error: commands.MissingPermissions - permissions = [ - (await self.custom_response(f"permissions.{permission}", ctx)) - for permission in error.missing_permissions - ] - - await ctx.send( - "errors.missing_permissions", - command=command, - permissions=", ".join(permissions), - ) - case commands.CommandOnCooldown(): - error: commands.CommandOnCooldown - retry_after = helpers.seconds_to_text(int(error.retry_after)) - await ctx.send( - "errors.command_on_cooldown", - command=command, - retry_after=retry_after, - ) - case commands.ChannelNotFound(): - await ctx.send("errors.channel_not_found", command=command) - case commands.EmojiNotFound(): - await ctx.send("errors.emoji_not_found", command=command) - case commands.MemberNotFound(): - await ctx.send("errors.member_not_found", command=command) - case commands.UserNotFound(): - await ctx.send("errors.user_not_found", command=command) - case commands.RoleNotFound(): - await ctx.send("errors.role_not_found", command=command) - case discord.Forbidden(): - await ctx.send("errors.forbidden", command=command) - case commands.NotOwner(): - await ctx.send("errors.not_owner", command=command) - case commands.CommandNotFound() | app_commands.CommandNotFound(): - return - case discord.RateLimited(): - channel: discord.TextChannel = await self.fetch_channel(1268260404677574697) - webhook: discord.Webhook | None = discord.utils.get( - await channel.webhooks(), - name=f"{self.user.display_name} Rate Limit", - ) - if not webhook: - webhook = await channel.create_webhook(name=f"{self.user.display_name} Rate Limit") - await webhook.send( - content=f"# ⚠️ RATE LIMIT\n**Guild:** {ctx.guild.name} / {ctx.guild.id}\n**User:** {ctx.author} / {ctx.author.id}\n**Command:** {ctx.command} {'- failed' if ctx.command_failed else ''}\n**Error:** {error}" - ) - raise error - case _: - # if the error is unknown, log it - channel: discord.TextChannel = ( - ctx.channel if __debug__ and ctx and ctx.channel else await self.fetch_channel(1268260404677574697) - ) - stack = "".join(traceback.format_exception(type(error), error, error.__traceback__)) - # if stack is more than 1700 characters, turn it into a .txt file and store it as an attachment - too_long = len(stack) > 1700 - file: discord.File | None = None - if too_long: - with open("auto-report_stack-trace.txt", "w") as f: - f.write(stack) - file = discord.File(fp="auto-report_stack-trace.txt", filename="error.txt") - stack = "The stack trace was too long to send in a message, so it was saved as a file." - webhook: discord.Webhook = discord.utils.get( - await channel.webhooks(), name=f"{self.user.display_name} Errors" - ) - if not webhook: - webhook = await channel.create_webhook( - name=f"{self.user.display_name} Errors", - avatar=await ctx.me.avatar.read(), - ) - await webhook.send( - content=f"**ID:** {ctx.message.id}\n" - f"**Guild:** {ctx.guild.name if ctx.guild else 'DMs'} / {ctx.guild.id if ctx.guild else 0}\n" - f"**User:** {ctx.author} / {ctx.author.id}\n" - f"**Command:** {ctx.command}\n" - f"```{stack}```", - file=file if too_long and file else discord.abc.MISSING, - ) # type: ignore - await ctx.reply( - f"An error has occured and has been reported to the developers. Report ID: `{ctx.message.id}`", - mention_author=False, - ) - raise error - - async def on_command_error(self, ctx: Context, error: discord.errors.DiscordException): - await self.handle_error(ctx, error) - - async def on_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError): - await self.handle_error(await Context.from_interaction(interaction), error) - - logger.info("Starting the bot...") client = MyClient() -@client.before_invoke -async def before_invoke(ctx: commands.Context): - if ctx.guild: - is_set_up = await client.db.fetchrow("SELECT * FROM guilds WHERE guild_id = $1", ctx.guild.id) - if not is_set_up: - await client.db.execute("INSERT INTO guilds (guild_id) VALUES ($1)", ctx.guild.id) - try: - # Signals that the bot is still thinking / performing a task - if ctx.interaction and ctx.interaction.type == discord.InteractionType.application_command: - await ctx.interaction.response.defer(thinking=True) # type: ignore - else: - await ctx.message.add_reaction(emojis.LOADING) - except discord.HTTPException: - pass - - -@client.after_invoke -async def after_invoke(ctx: commands.Context): - try: - await ctx.message.remove_reaction(emojis.LOADING, ctx.me) - except discord.HTTPException: - pass - - -@client.hybrid_command(hidden=True, name="reload", description="reload_specs-description", usage="reload_specs-usage") -@commands.is_owner() -@app_commands.describe(cog="reload_specs-args-cog-description") -@app_commands.rename(cog="reload_specs-args-cog-name") -async def reload(ctx: commands.Context, cog: str): - try: - benchmark = perf_counter() - await client.reload_extension(f"cogs.{cog}") - end = perf_counter() - benchmark - await ctx.reply(content=f"Reloaded extension `{cog}` in **{end:.2f}s**") - logger.info(f"{ctx.author.name} reloaded {cog}.py") - except Exception as e: - await ctx.reply(content=f"Failed to reload extension `{cog}`: {e}") - - -@client.hybrid_command(hidden=True, name="load", description="load_specs-description", usage="load_specs-usage") -@commands.is_owner() -@app_commands.describe(cog="load_specs-args-cog-description") -@app_commands.rename(cog="load_specs-args-cog-name") -async def load(ctx: commands.Context, cog: str): - try: - benchmark = perf_counter() - await client.load_extension(f"cogs.{cog}") - end = perf_counter() - benchmark - await ctx.reply(content=f"Loaded extension `{cog}` in **{end:.2f}s**") - logger.info(f"{ctx.author.name} loaded {cog}.py") - except Exception as e: - await ctx.reply(content=f"Failed to load extension `{cog}`: {e}") - - -@client.hybrid_command(hidden=True, name="unload", description="unload_specs-description", usage="unload_specs-usage") -@commands.is_owner() -@app_commands.describe(cog="unload_specs-args-cog-description") -@app_commands.rename(cog="unload_specs-args-cog-name") -async def unload(ctx: commands.Context, cog: str): - try: - benchmark = perf_counter() - await client.unload_extension(f"cogs.{cog}") - end = perf_counter() - benchmark - await ctx.reply(content=f"Unloaded extension `{cog}` in **{end:.2f}s**") - logger.info(f"{ctx.author.name} unloaded {cog}.py") - except Exception as e: - await ctx.reply(content=f"Failed to unload extension `{cog}`: {e}") - - -@client.hybrid_command( - hidden=True, name="l10nreload", description="l10nreload_specs-description", usage="l10nreload_specs-usage" -) -@commands.is_owner() -@app_commands.describe(path="l10nreload_specs-args-path-description") -@app_commands.rename(path="l10nreload_specs-args-path-name") -async def l10nreload(ctx: commands.Context, path: str = "./localization"): - ctx.bot.custom_response.load_localizations(path) - await ctx.reply(content="Reloaded localization files.") - logger.info(f"{ctx.author.name} reloaded localization files.") - - -@client.hybrid_command(hidden=True, name="sync", description="sync_specs-description", usage="sync_specs-usage") -@commands.is_owner() -@app_commands.describe( - guilds="sync_specs-args-guilds-description", - scope="sync_specs-args-scope-description", -) -@app_commands.rename(guilds="sync_specs-args-guilds-name", scope="sync_specs-args-scope-name") -@app_commands.choices( - scope=[ - app_commands.Choice(name="sync_specs-args-scope-local", value="~"), - app_commands.Choice(name="sync_specs-args-scope-global", value="*"), - app_commands.Choice(name="sync_specs-args-scope-resync", value="^"), - app_commands.Choice(name="sync_specs-args-scope-slash", value="/"), - ] -) -async def sync( - ctx: commands.Context, - guilds: commands.Greedy[discord.Object] = None, - scope: Optional[Literal["~", "*", "^", "/"]] = None, -) -> None: - tree: discord.app_commands.CommandTree[ctx.bot] = ctx.bot.tree # type: ignore - benchmark = time.perf_counter() - - if not guilds: - if scope == "~": - synced = await tree.sync(guild=ctx.guild) - elif scope == "*": - tree.copy_global_to(guild=ctx.guild) - synced = await tree.sync(guild=ctx.guild) - elif scope == "^": - tree.clear_commands(guild=ctx.guild) - await tree.sync(guild=ctx.guild) - synced = [] - elif scope == "/": - update_slash_localizations() - await ctx.reply(content="Reloaded slash localizations") - return - else: - update_slash_localizations() - synced = await tree.sync() - - end = time.perf_counter() - benchmark - await ctx.reply( - content=f"Synced **{len(synced)}** {'commands' if len(synced) != 1 else 'command'} {'globally' if scope is None else 'to the current guild'}, took **{end:.2f}s**" - ) - else: - update_slash_localizations() - guilds_synced = 0 - for guild in guilds: - try: - await tree.sync(guild=guild) - except discord.HTTPException: - pass - else: - guilds_synced += 1 - - end = time.perf_counter() - benchmark - await ctx.reply(content=f"Synced the tree to **{guilds_synced}/{len(guilds)}** guilds, took **{end:.2f}s**") - - async def main(): try: await client.start(TOKEN) @@ -702,3 +53,5 @@ async def main(): asyncio.run(main()) except KeyboardInterrupt: logger.error("KeyboardInterrupt: Bot shut down by console") + else: + logger.info("Bot shut down") diff --git a/pyproject.toml b/pyproject.toml index 42c48c5..f1ee8ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "pypokedex>=1.6.0", "art>=6.5", "ruff>=0.13.1", + "pillow>=11.3.0", + "asyncpraw>=7.8.1", + "pytemperature>=1.1", ] [tool.setuptools] diff --git a/uv.lock b/uv.lock index bce865c..aa90b96 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/2c/711076e5f5d0707b8ec55a233c8bfb193e0981a800cd1b3b123e8ff61ca1/aiodns-3.5.0-py3-none-any.whl", hash = "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5", size = 8068, upload-time = "2025-06-13T16:21:52.45Z" }, ] +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -88,6 +97,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/e0/ad1edd74311831ca71b32a5b83352b490d78d11a90a1cde04e1b6830e018/aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51", size = 25941, upload-time = "2021-02-22T01:01:10.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/48/77c0092f716c4bf9460dca44f5120f70b8f71f14a12f40d22551a7152719/aiosqlite-0.17.0-py3-none-any.whl", hash = "sha256:6c49dc6d3405929b1d08eeccc72306d3677503cc5e5e43771efc1e00232e8231", size = 15433, upload-time = "2021-02-22T01:01:07.698Z" }, +] + [[package]] name = "art" version = "6.5" @@ -121,6 +142,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, ] +[[package]] +name = "asyncpraw" +version = "7.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "aiosqlite" }, + { name = "asyncprawcore" }, + { name = "update-checker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/df/f5dad426f02a6f0b3ad3c0b028ae2ecda80a05a90237c99b33583d1a1370/asyncpraw-7.8.1.tar.gz", hash = "sha256:6fc50e3976ae106ef6190dbcca3b1b4050de7da5644adc50851ae6487679206f", size = 158972, upload-time = "2024-12-21T19:48:20.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/45/65671411cfaf816e4ca60b3417e79b03c98edebbe6b157dc79063f8a469b/asyncpraw-7.8.1-py3-none-any.whl", hash = "sha256:8b960dd0ab404e876b8a4eb3752a3706048a9da8fb0503018b82437d973651ba", size = 196437, upload-time = "2024-12-21T19:48:17.682Z" }, +] + +[[package]] +name = "asyncprawcore" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/0a/45dfc8c3b91d74f646817db3e89baecbd963a8374e2a8a33f2eb83c7e398/asyncprawcore-2.4.0.tar.gz", hash = "sha256:3a3359e5cd1ebe61d544a09d4b5eca7b16edfd17de07081a8415fc794f7bb62e", size = 18072, upload-time = "2023-11-27T03:39:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/a0/a1f216c7ad7fd94482242eb92d6903c0ed5f3340436b952db2a42d2eddeb/asyncprawcore-2.4.0-py3-none-any.whl", hash = "sha256:3fe0f85d783ad0fd2a5d68d219b576bdbe04260ad7195c8a035d8724f57cfa45", size = 19741, upload-time = "2023-11-27T03:39:51.793Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -385,19 +435,22 @@ wheels = [ [[package]] name = "lumin" -version = "0.1.0" +version = "0.6.1" source = { virtual = "." } dependencies = [ { name = "aiodns" }, { name = "art" }, { name = "asyncpg" }, + { name = "asyncpraw" }, { name = "discord" }, { name = "discord-localization" }, { name = "docutils-stubs" }, { name = "emoji" }, + { name = "pillow" }, { name = "psutil" }, { name = "py-cpuinfo" }, { name = "pypokedex" }, + { name = "pytemperature" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "ruff" }, @@ -409,13 +462,16 @@ requires-dist = [ { name = "aiodns", specifier = ">=3.5.0" }, { name = "art", specifier = ">=6.5" }, { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "asyncpraw", specifier = ">=7.8.1" }, { name = "discord", specifier = ">=2.3.2" }, { name = "discord-localization", specifier = ">=1.1.4" }, { name = "docutils-stubs", specifier = ">=0.0.22" }, { name = "emoji", specifier = ">=2.14.1" }, + { name = "pillow", specifier = ">=11.3.0" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "py-cpuinfo", specifier = ">=9.0.0" }, { name = "pypokedex", specifier = ">=1.6.0" }, + { name = "pytemperature", specifier = ">=1.1" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "ruff", specifier = ">=0.13.1" }, @@ -482,6 +538,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload-time = "2025-04-10T22:20:16.445Z" }, ] +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + [[package]] name = "propcache" version = "0.3.1" @@ -625,6 +747,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/98/ca7a9e1bd81a8e170c62a27c425cfcd5626920d5e9b91b5f7d6117755842/pypokedex-1.6.0-py36-none-any.whl", hash = "sha256:bfc896e452008ae8d6ae9ec90d61e854b650a9253cd9ba24389270c6cee11175", size = 11478, upload-time = "2021-05-14T20:46:29.849Z" }, ] +[[package]] +name = "pytemperature" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/74/d83f02be7290b90657fc34b5835feaccf993d9dd9831ca0471fe63096264/pytemperature-1.1.tar.gz", hash = "sha256:72f96281e8576c8b72b37a603a81b6818c6e23ed61350fff5eec25f3b59ae4fa", size = 2566, upload-time = "2022-01-14T05:25:57.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/31/20de6e4c7d23d368276be8e43a23a81922ba847d31c73d743054cf14c45b/pytemperature-1.1-py3-none-any.whl", hash = "sha256:47eb70675685f56cab2d5401510dc3d98584c8d955b575ae16914b21b20d5e03", size = 2769, upload-time = "2022-01-14T05:25:56.525Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -701,6 +832,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "update-checker" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/0b/1bec4a6cc60d33ce93d11a7bcf1aeffc7ad0aa114986073411be31395c6f/update_checker-0.18.0.tar.gz", hash = "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13", size = 6699, upload-time = "2020-08-04T07:08:50.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ba/8dd7fa5f0b1c6a8ac62f8f57f7e794160c1f86f31c6d0fb00f582372a3e4/update_checker-0.18.0-py3-none-any.whl", hash = "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd", size = 7008, upload-time = "2020-08-04T07:08:49.51Z" }, +] + [[package]] name = "urllib3" version = "2.4.0"