diff --git a/bot/bases/context.py b/bot/bases/context.py index 2800491f..6ea6e2a4 100644 --- a/bot/bases/context.py +++ b/bot/bases/context.py @@ -6,7 +6,7 @@ import discord from discord.ext import commands -from utils import formats +from utils import fmt if TYPE_CHECKING: import datetime @@ -42,7 +42,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: @override def __repr__(self) -> str: - return f"" + return f"" # The following attributes are here just to match discord.Interaction properties # Just so we don't need to do `if isinstance(discord.Interaction):` checks every time @@ -83,7 +83,7 @@ def session(self) -> ClientSession: async def tick_reaction(self, semi_bool: bool | None) -> None: """Add tick reaction to `ctx.message`.""" with contextlib.suppress(discord.HTTPException): - await self.message.add_reaction(formats.tick(semi_bool)) + await self.message.add_reaction(fmt.tick(semi_bool)) # the next two functions mean the following in a context of discord chat: # /--> replying to @Bob: wow, 2+2=5 diff --git a/bot/bases/tasks.py b/bot/bases/tasks.py index 7a8e8704..d1299eb3 100644 --- a/bot/bases/tasks.py +++ b/bot/bases/tasks.py @@ -8,7 +8,7 @@ from discord.ext import tasks from discord.utils import MISSING -from utils import formats +from utils import fmt if TYPE_CHECKING: import datetime @@ -78,7 +78,7 @@ async def _error(self, cog: HasBotAttribute, exception: Exception) -> None: meta = f"module = {self.coro.__module__}\nqualname = {self.coro.__qualname__}" embed = ( discord.Embed(title=f"Task Error: `{self.coro.__name__}`", colour=0xEF7A85) - .add_field(name="Meta", value=formats.code(meta, "ebnf"), inline=False) + .add_field(name="Meta", value=fmt.code(meta, "ebnf"), inline=False) .set_footer(text=f"{self.__class__.__name__}._error: {self.coro.__name__}") ) await cog.bot.exc_manager.register_error(exception, embed) diff --git a/bot/bases/views.py b/bot/bases/views.py index 1f77626c..cc6412d5 100644 --- a/bot/bases/views.py +++ b/bot/bases/views.py @@ -11,7 +11,7 @@ import discord -from utils import const, errors, formats, helpers +from utils import const, errors, fmt, helpers if TYPE_CHECKING: from .context import AluInteraction @@ -59,8 +59,8 @@ async def on_views_modals_error( ), icon_url=interaction.user.display_avatar, ) - .add_field(name="View Objects", value=formats.code(args_join, "ps"), inline=False) - .add_field(name="Snowflake IDs", value=formats.code(snowflake_ids, "ebnf"), inline=False) + .add_field(name="View Objects", value=fmt.code(args_join, "ps"), inline=False) + .add_field(name="Snowflake IDs", value=fmt.code(snowflake_ids, "ebnf"), inline=False) .set_footer( text=f"{view.__class__.__name__}.on_error", icon_url=interaction.guild.icon if interaction.guild else interaction.user.display_avatar, diff --git a/bot/bot.py b/bot/bot.py index 9e503988..fb711ff5 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -14,7 +14,7 @@ from bot import EXT_CATEGORY_NONE, AluContext, ExtCategory from config import config from ext import get_extensions -from utils import cache, const, disambiguator, errors, formats, helpers, transposer +from utils import cache, const, disambiguator, errors, fmt, helpers, transposer from .exc_manager import ExceptionManager from .intents_perms import INTENTS, PERMISSIONS @@ -37,14 +37,7 @@ log = logging.getLogger(__name__) -class AluBotHelper(TimerManager): - """Extra class to help with MRO.""" - - def __init__(self, *, bot: AluBot) -> None: - super().__init__(bot=bot) - - -class AluBot(commands.Bot, AluBotHelper): +class AluBot(commands.Bot): """Main class for AluBot. Essentially extended subclass over discord.py's `commands.Bot` @@ -71,15 +64,13 @@ def __init__( Parameters ---------- - session : ClientSession - aiohttp.ClientSession to use within the bot. - pool : asyncpg.Pool[DotRecord] - A connection pool to the database. - test : bool, default=False + test: bool = False whether the bot is a testing version (YenBot) or main production bot (AluBot). I just like to use different discord bot token to debug issues, test new code, etc. - kwargs : Any - kwargs for the purpose of MRO and what not. + session: ClientSession + aiohttp.ClientSession to use within the bot. + pool: asyncpg.Pool[asyncpg.Record] + A connection pool to the database. """ self.test: bool = test @@ -134,7 +125,7 @@ async def setup_hook(self) -> None: # we could go with attribute option like exceptions manager # but let's keep its methods nearby in AluBot namespace # needs to be done after cogs are loaded so all cog event listeners are ready - super(AluBotHelper, self).__init__(bot=self) + self.timers = TimerManager(bot=self) if self.test: if failed_to_load_some_ext: @@ -393,7 +384,7 @@ async def on_error(self: AluBot, event: str, *args: Any, **kwargs: Any) -> None: args_join = "\n".join(f"[{index}]: {arg!r}" for index, arg in enumerate(args)) if args else "No Args" embed = ( discord.Embed(colour=0xA32952, title=f"Event Error: `{event}`") - .add_field(name="Args", value=formats.code(args_join, "ps"), inline=False) + .add_field(name="Args", value=fmt.code(args_join, "ps"), inline=False) .set_footer(text=f"{self.__class__.__name__}.on_error: {event}") ) await self.exc_manager.register_error(exception, embed) @@ -430,7 +421,7 @@ async def on_command_error(self, ctx: AluContext, error: commands.CommandError | desc = ( f"Sorry! Incorrect argument value: {error.argument!r}.\n Only these options are valid " f"for a parameter `{error.param.displayed_name or error.param.name}`:\n" - f"{formats.human_join([repr(literal) for literal in error.literals])}." + f"{fmt.human_join([repr(literal) for literal in error.literals])}." ) case commands.EmojiNotFound(): @@ -450,7 +441,7 @@ async def on_command_error(self, ctx: AluContext, error: commands.CommandError | # TODO: make a fuzzy search in here to recommend the command that user wants desc = f"Please, double-check, did you make a typo? Or use `{ctx.prefix}help`" case commands.CommandOnCooldown(): - desc = f"Please retry in `{formats.human_timedelta(error.retry_after, mode='brief')}`" + desc = f"Please retry in `{fmt.human_timedelta(error.retry_after, mode='brief')}`" case commands.NotOwner(): desc = f"Sorry, only {ctx.bot.owner} as the bot developer is allowed to use this command." case commands.MissingRole(): @@ -483,8 +474,8 @@ async def on_command_error(self, ctx: AluContext, error: commands.CommandError | name=f"@{ctx.author} in #{ctx.channel} ({ctx.guild.name if ctx.guild else 'DM Channel'})", icon_url=ctx.author.display_avatar, ) - .add_field(name="Command Args", value=formats.code(kwargs_join, "ps"), inline=False) - .add_field(name="Snowflake IDs", value=formats.code(snowflake_ids, "ebnf"), inline=False) + .add_field(name="Command Args", value=fmt.code(kwargs_join, "ps"), inline=False) + .add_field(name="Snowflake IDs", value=fmt.code(snowflake_ids, "ebnf"), inline=False) .set_footer( text=f"on_command_error: {cmd_name}", icon_url=ctx.guild.icon if ctx.guild else ctx.author.display_avatar, diff --git a/bot/logs.py b/bot/logs.py index 3bd996e3..b8691574 100644 --- a/bot/logs.py +++ b/bot/logs.py @@ -11,7 +11,7 @@ import discord from config import config -from utils import const, formats +from utils import const, fmt if TYPE_CHECKING: from collections.abc import Generator @@ -60,7 +60,7 @@ def setup_logging(*, test: bool) -> Generator[Any, Any, Any]: log.info(ASCII_STARTING_UP_ART) # send a webhook message as well webhook = discord.SyncWebhook.from_url(config["WEBHOOKS"]["LOGGER"]) - now_str = formats.format_dt(datetime.datetime.now(datetime.UTC), style="T") + now_str = fmt.format_dt(datetime.datetime.now(datetime.UTC), style="T") embed = discord.Embed( colour=discord.Colour.og_blurple(), description=f"{now_str} The bot is restarting", diff --git a/bot/timer_manager.py b/bot/timer_manager.py index 37af2785..693b030a 100644 --- a/bot/timer_manager.py +++ b/bot/timer_manager.py @@ -147,15 +147,24 @@ class TimerManager: https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/reminder.py * DuckBot-Discord/DuckBot, rewrite branch (license MPL v2), TimerManager: https://github.com/DuckBot-Discord/DuckBot/blob/rewrite/utils/bases/timer.py + + Workflow + ------- + * __init__ ➡️ dispatch ➡️ wait_for_active (get_active) ➡️ call ➡️ looped + * create ➡️ maybe short_optimisation ➡️ reschedule + + Warning + ------- + Remember to use `self.bot.timers.cleanup(timer.id)` in the end of listener's coro. """ __slots__: tuple[str, ...] = ( - "_current_timer", "_have_data", "_scheduling_task", "_skipped_timer_ids", "_temporary_timer_id_count", "bot", + "current_timer", "name", ) @@ -165,12 +174,11 @@ def __init__(self, *, bot: AluBot) -> None: self._temporary_timer_id_count: int = -1 """Not really useful attribute, but just so `__eq__` can properly work on temporary timers.""" self._skipped_timer_ids: set[int] = set() - self._have_data = asyncio.Event() - self._current_timer: Timer[TimerData] | None = None - self._scheduling_task = self.bot.loop.create_task(self.dispatch_timers()) + self.current_timer: Timer[TimerData] | None = None + self._scheduling_task = self.bot.loop.create_task(self.dispatch()) - async def dispatch_timers(self) -> None: + async def dispatch(self) -> None: """The main dispatch timers loop. This will wait for the next timer to expire and dispatch the bot's event with its data. @@ -182,7 +190,7 @@ async def dispatch_timers(self) -> None: while not self.bot.is_closed(): # can `asyncio.sleep` only for up to ~48 days reliably, # so we cap it at 40 days, see: http://bugs.python.org/issue20493 - timer = self._current_timer = await self.wait_for_active_timers(days=40) + timer = self.current_timer = await self.wait_for_active(days=40) log.debug("Current_timer = %s", timer) # Double check if there exist a listener for the current timer @@ -206,11 +214,11 @@ async def dispatch_timers(self) -> None: to_sleep = (timer.expires_at - now).total_seconds() await asyncio.sleep(to_sleep) - await self.call_timer(timer) + await self.call(timer) except asyncio.CancelledError: raise except (OSError, discord.ConnectionClosed, asyncpg.PostgresConnectionError): - self.reschedule_timers() + self.reschedule() except Exception as exc: # noqa: BLE001 embed = discord.Embed( colour=0xFF8243, @@ -218,7 +226,7 @@ async def dispatch_timers(self) -> None: ).set_footer(text=f"{self.__class__.__name__}.dispatch_timers") await self.bot.exc_manager.register_error(exc, embed) - async def wait_for_active_timers(self, *, days: int = 7) -> Timer[TimerData]: + async def wait_for_active(self, *, days: int = 7) -> Timer[TimerData]: """Wait for a timer that has expired. This will wait until a timer is expired and should be dispatched. @@ -234,20 +242,20 @@ async def wait_for_active_timers(self, *, days: int = 7) -> Timer[TimerData]: The timer that is expired and should be dispatched. """ - timer = await self.get_active_timer(days=days) + timer = await self.get_active(days=days) if timer is not None: self._have_data.set() return timer self._have_data.clear() - self._current_timer = None + self.current_timer = None await self._have_data.wait() - return await self.get_active_timer(days=days) # type: ignore[reportReturnType] # at this point we always have data + return await self.get_active(days=days) # type: ignore[reportReturnType] # at this point we always have data - async def call_timer(self, timer: Timer[TimerData]) -> None: - """Call an expired timer to dispatch it. + async def call(self, timer: Timer[TimerData]) -> None: + """Call an expired timer to dispatch its event. Parameters ---------- @@ -260,7 +268,7 @@ async def call_timer(self, timer: Timer[TimerData]) -> None: self._skipped_timer_ids.add(timer.id) self.bot.dispatch(timer.event_name, timer) - async def get_active_timer(self, *, days: int = 7) -> Timer[TimerData] | None: + async def get_active(self, *, days: int = 7) -> Timer[TimerData] | None: """Get the most current active timer in the database. This timer is expired and should be dispatched. @@ -290,7 +298,7 @@ async def get_active_timer(self, *, days: int = 7) -> Timer[TimerData] | None: return Timer(row=record) return None - async def create_timer( + async def create( self, *, event: str, @@ -356,7 +364,7 @@ async def create_timer( delta = (expires_at - created_at).total_seconds() if delta <= 60: # a shortcut for small timers - self.bot.loop.create_task(self.short_timer_optimisation(delta, timer)) + self.bot.loop.create_task(self.short_optimisation(delta, timer)) return timer query = """ @@ -380,17 +388,17 @@ async def create_timer( self._have_data.set() # check if this timer is earlier than our currently run timer - if self._current_timer and expires_at < self._current_timer.expires_at: - self.reschedule_timers() + if self.current_timer and expires_at < self.current_timer.expires_at: + self.reschedule() return timer - async def short_timer_optimisation(self, seconds: float, timer: Timer[TimerData]) -> None: + async def short_optimisation(self, seconds: float, timer: Timer[TimerData]) -> None: """Optimisation for small timers, skipping the whole database insert/delete procedure.""" await asyncio.sleep(seconds) - await self.call_timer(timer) + await self.call(timer) - async def get_timer_by_id(self, id: int) -> Timer[TimerData] | None: # noqa: A002 + async def get_by_id(self, id: int) -> Timer[TimerData] | None: # noqa: A002 """Get a timer from its ID. Parameters @@ -408,20 +416,7 @@ async def get_timer_by_id(self, id: int) -> Timer[TimerData] | None: # noqa: A0 record: TimerRow[TimerData] | None = await self.bot.pool.fetchrow(query, id) return Timer(row=record) if record else None - async def cleanup_timer(self, id: int) -> None: # noqa: A002 - """Delete a timer by its ID. - - Parameters - ---------- - id: int - The ID of the timer to delete. - - """ - self._skipped_timer_ids.remove(id) - query = "DELETE * FROM timers WHERE id = $1" - await self.bot.pool.execute(query, id) - - async def get_timer_by_kwargs(self, event: str, /, **kwargs: Any) -> Timer[TimerData] | None: + async def get_by_kwargs(self, event: str, /, **kwargs: Any) -> Timer[TimerData] | None: """Gets a timer from the database. Note you cannot find a database by its expiry or creation time. @@ -444,7 +439,34 @@ async def get_timer_by_kwargs(self, event: str, /, **kwargs: Any) -> Timer[Timer record: TimerRow[TimerData] | None = await self.bot.pool.fetchrow(query, event, *kwargs.values()) return Timer(row=record) if record else None - async def delete_timer_by_kwargs(self, event: str, /, **kwargs: Any) -> None: + async def fetch(self) -> list[Timer[TimerData]]: + """Fetch all timers from the database. + + Returns + ------- + list[Timer[TimerData]] + A list of `Timer` objects. + + """ + rows = await self.bot.pool.fetch("SELECT * FROM timers") + return [Timer(row=row) for row in rows] + + async def cleanup(self, id: int) -> None: # noqa: A002 + """Cleanup a timer by its ID. + + Important! This is supposed to be called in the end of listeners coro. + + Parameters + ---------- + id: int + The ID of the timer to delete. + + """ + self._skipped_timer_ids.remove(id) + query = "DELETE * FROM timers WHERE id = $1" + await self.bot.pool.execute(query, id) + + async def delete_by_kwargs(self, event: str, /, **kwargs: Any) -> None: """Delete a timer from the database. Note you cannot find a database by its expiry or creation time. @@ -461,24 +483,20 @@ async def delete_timer_by_kwargs(self, event: str, /, **kwargs: Any) -> None: query = f"DELETE FROM timers WHERE event = $1 AND {' AND '.join(filtered_clause)} RETURNING id" record: Any = await self.bot.pool.fetchrow(query, event, *kwargs.values()) - # if the current timer is being deleted - if record is not None and self._current_timer and self._current_timer.id == record["id"]: - # cancel the task and re-run it - self.reschedule_timers() + if record is not None: + self.check_reschedule(record["id"]) - async def fetch_timers(self) -> list[Timer[TimerData]]: - """Fetch all timers from the database. + def reschedule(self) -> None: + """A shortcut to cancel the scheduling task which dispatches the timers and rerun it.""" + self._scheduling_task.cancel() + self._scheduling_task = self.bot.loop.create_task(self.dispatch()) - Returns - ------- - list[Timer[TimerData]] - A list of `Timer` objects. + def check_reschedule(self, id_: int) -> None: + """A common cleanup function for deleting timers by id. + Should be called in the end of most "delete" methods, i.e. /remind delete slash command """ - rows = await self.bot.pool.fetch("SELECT * FROM timers") - return [Timer(row=row) for row in rows] - - def reschedule_timers(self) -> None: - """A shortcut to cancel the scheduling task which dispatches the timers and rerun it.""" - self._scheduling_task.cancel() - self._scheduling_task = self.bot.loop.create_task(self.dispatch_timers()) + # if the current timer is being deleted + if self.current_timer and self.current_timer.id == id_: + # cancel the task and re-run it + self.reschedule() diff --git a/bot/tree.py b/bot/tree.py index f0362faf..e9623575 100644 --- a/bot/tree.py +++ b/bot/tree.py @@ -7,7 +7,7 @@ from discord import app_commands from discord.ext import commands -from utils import const, errors, formats, helpers +from utils import const, errors, fmt, helpers if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator @@ -190,7 +190,7 @@ async def on_error(self, interaction: AluInteraction, error: app_commands.AppCom # These errors are raised in code of this project by myself or with an explanation text as `error` desc = f"{error}" elif isinstance(error, app_commands.CommandOnCooldown): - desc = f"Please retry in `{formats.human_timedelta(error.retry_after, mode='full')}`" + desc = f"Please retry in `{fmt.human_timedelta(error.retry_after, mode='full')}`" elif isinstance(error, app_commands.CommandSignatureMismatch): desc = ( "\N{WARNING SIGN} This command's signature is out of date!\n" @@ -254,8 +254,8 @@ async def on_error(self, interaction: AluInteraction, error: app_commands.AppCom ), icon_url=interaction.user.display_avatar, ) - .add_field(name="Command Arguments", value=formats.code(args_join, "ps"), inline=False) - .add_field(name="Snowflake IDs", value=formats.code(snowflake_ids, "ebnf"), inline=False) + .add_field(name="Command Arguments", value=fmt.code(args_join, "ps"), inline=False) + .add_field(name="Snowflake IDs", value=fmt.code(snowflake_ids, "ebnf"), inline=False) .set_footer( text=f"on_app_command_error: {cmd_name}", icon_url=interaction.guild.icon if interaction.guild else interaction.user.display_avatar, diff --git a/core/logger_via_webhook.py b/core/logger_via_webhook.py index b1340193..c8b9022f 100644 --- a/core/logger_via_webhook.py +++ b/core/logger_via_webhook.py @@ -11,7 +11,7 @@ from bot import AluCog from config import config -from utils import const, formats +from utils import const, fmt if TYPE_CHECKING: from collections.abc import Mapping @@ -141,7 +141,7 @@ async def send_log_record(self, record: logging.LogRecord) -> None: # the time is there so the MM:SS is more clear. Discord stacks messages from the same webhook user # so if logger sends at 23:01 and 23:02 it will be hard to understand the time difference dt = datetime.datetime.fromtimestamp(record.created, datetime.UTC) - msg = textwrap.shorten(f"{emoji} {formats.format_dt(dt, style='T')} {record.message}", width=1995) + msg = textwrap.shorten(f"{emoji} {fmt.format_dt(dt, style='T')} {record.message}", width=1995) avatar_url = self.get_avatar(record.name) # Discord doesn't allow Webhooks names to contain "discord"; diff --git a/docs/Practices.md b/docs/Practices.md index 1962f8fa..ad19f183 100644 --- a/docs/Practices.md +++ b/docs/Practices.md @@ -130,3 +130,12 @@ As we can see `url` and `description` are undecided. * Write `# type: ignore[reportReturnType]` and not `# pyright: ignore[reportReturnType]` * add two empty lines before `__all__` which should be there right after `if TYPE_CHECKING` + +## How to use Timers + +```py + @commands.Cog.listener("on_birthday_timer_complete") + async def birthday_congratulations(self, timer: Timer[BirthdayTimerData]) -> None: + # something something + await self.bot.timers.cleanup(timer.id) # ! This is a must +``` diff --git a/examples/beta/base.py b/examples/beta/base.py index 05bcedef..de58af20 100644 --- a/examples/beta/base.py +++ b/examples/beta/base.py @@ -38,7 +38,7 @@ from bot import AluBot, AluCog, ExtCategory, aluloop from config import config -from utils import cache, const, errors, formats, fuzzy, timezones +from utils import cache, const, errors, fmt, fuzzy, timezones from utils.helpers import measure_time if TYPE_CHECKING: diff --git a/ext/community/birthday.py b/ext/community/birthday.py index 7826c48e..11d8a135 100644 --- a/ext/community/birthday.py +++ b/ext/community/birthday.py @@ -9,17 +9,16 @@ from discord import app_commands from discord.ext import commands -from utils import const, converters, errors, formats, pages, timezones +from utils import const, converters, errors, fmt, pages, timezones from ._base import CommunityCog if TYPE_CHECKING: - from bot import AluBot, Timer, TimerRow + from bot import AluBot, AluInteraction, Timer, TimerRow - -class BirthdayTimerData(TypedDict): - user_id: int - year: int + class BirthdayTimerData(TypedDict): + user_id: int + year: int CONGRATULATION_TEXT_BANK = ( @@ -123,7 +122,7 @@ def birthday_channel(self) -> discord.TextChannel: @birthday_group.command(name="set") async def birthday_set( self, - interaction: discord.Interaction[AluBot], + interaction: AluInteraction, day: app_commands.Range[int, 1, 31], month: app_commands.Transform[int, converters.MonthPicker], year: app_commands.Range[int, 1970], @@ -152,7 +151,7 @@ async def birthday_set( ) except ValueError: msg = "Invalid date given, please recheck the date." - raise errors.BadArgument(msg) + raise errors.BadArgument(msg) from None confirm_embed = ( discord.Embed( @@ -202,7 +201,7 @@ async def birthday_set( # clear the previous birthday data before adding a new one await self.remove_birthday_worker(interaction.user.id) - await self.bot.create_timer( + await self.bot.timers.create( event="birthday", expires_at=expires_at, timezone=timezone.key, @@ -213,7 +212,7 @@ async def birthday_set( discord.Embed(colour=interaction.user.colour, title="Your birthday is successfully set") .add_field(name="Data", value=birthday_fmt(birthday)) .add_field(name="Timezone", value=timezone.label) - .add_field(name="Next congratulations incoming", value=formats.format_dt(expires_at, "R")) + .add_field(name="Next congratulations incoming", value=fmt.format_dt(expires_at, "R")) .set_footer(text="Important! By submitting this information you agree it can be shown to anyone.") ) await interaction.followup.send(embed=embed) @@ -226,16 +225,16 @@ async def remove_birthday_worker(self, user_id: int) -> str: """ # noqa: RUF027 status = await self.bot.pool.execute(query, str(user_id)) - current_timer = self.bot._current_timer + current_timer = self.bot.timers.current_timer if current_timer and current_timer.event == "timer" and current_timer.data: author_id = current_timer.data.get("user_id") if author_id == user_id: - self.bot.reschedule_timers() + self.bot.timers.reschedule() return status @birthday_group.command() - async def remove(self, interaction: discord.Interaction[AluBot]) -> None: + async def remove(self, interaction: AluInteraction) -> None: """Remove your birthday data and stop getting congratulations.""" # TODO: make confirm message YES NO; status = await self.remove_birthday_worker(interaction.user.id) @@ -254,7 +253,7 @@ async def remove(self, interaction: discord.Interaction[AluBot]) -> None: await interaction.response.send_message(embed=embed, ephemeral=True) @birthday_group.command() - async def check(self, interaction: discord.Interaction[AluBot], member: discord.Member | None) -> None: + async def check(self, interaction: AluInteraction, member: discord.Member | None) -> None: """Check your or somebody's birthday in database. Parameters @@ -312,10 +311,6 @@ async def birthday_congratulations(self, timer: Timer[BirthdayTimerData]) -> Non return birthday_role = self.community.birthday_role - # if birthday_role in member.roles: - # # I guess the notification already happened - # return - await member.add_roles(birthday_role) content = f"Chat, today is {member.mention}'s birthday ! {const.Role.birthday_lover}" @@ -339,7 +334,7 @@ async def birthday_congratulations(self, timer: Timer[BirthdayTimerData]) -> Non await self.birthday_channel.send(content=content, embed=embed) # create remove roles timer - await self.bot.create_timer( + await self.bot.timers.create( event="remove_birthday_role", expires_at=timer.expires_at + datetime.timedelta(days=1), created_at=timer.created_at, @@ -348,13 +343,14 @@ async def birthday_congratulations(self, timer: Timer[BirthdayTimerData]) -> Non ) # create next year timer - await self.bot.create_timer( + await self.bot.timers.create( event="birthday", expires_at=timer.expires_at.replace(year=timer.expires_at.year + 1), created_at=timer.created_at, timezone=timer.timezone, data=timer.data, ) + await self.bot.timers.cleanup(timer.id) @commands.Cog.listener("on_remove_birthday_role_timer_complete") async def birthday_cleanup(self, timer: Timer[BirthdayTimerData]) -> None: @@ -366,9 +362,10 @@ async def birthday_cleanup(self, timer: Timer[BirthdayTimerData]) -> None: member = guild.get_member(user_id) if member is not None: await member.remove_roles(birthday_role) + await self.bot.timers.cleanup(timer.id) @birthday_group.command(name="list") - async def birthday_list(self, interaction: discord.Interaction[AluBot]) -> None: + async def birthday_list(self, interaction: AluInteraction) -> None: """Show list of birthdays in this server.""" guild = self.community.guild diff --git a/ext/community/confessions.py b/ext/community/confessions.py index 4a2bd787..32e7aebf 100644 --- a/ext/community/confessions.py +++ b/ext/community/confessions.py @@ -7,7 +7,7 @@ from bot import AluModal, AluView from utils.const import Colour, Emote -from utils.formats import human_timedelta +from utils.fmt import human_timedelta from ._base import CommunityCog diff --git a/ext/community/levels.py b/ext/community/levels.py index 13c7b4a3..c7a097d7 100644 --- a/ext/community/levels.py +++ b/ext/community/levels.py @@ -11,7 +11,7 @@ from tabulate import tabulate from bot import aluloop -from utils import const, errors, formats, pages +from utils import const, errors, fmt, pages from ._base import CommunityCog @@ -186,7 +186,7 @@ async def rank_work( row.rep, next_lvl_exp, prev_lvl_exp, - formats.ordinal(place), + fmt.ordinal(place), member, ) return ctx.client.transposer.image_to_file(image, filename="rank.png") @@ -237,7 +237,7 @@ async def leaderboard( # we put mentions on one line and the data onto the second line and properly align those; # we put invisible symbol to trick the tabulate to make two lines for those ( - f"{(label := '`' + formats.label_indent(counter, counter - 1, split_size) + '`')}" + f"{(label := '`' + fmt.label_indent(counter, counter - 1, split_size) + '`')}" f"\n`{' ' * len(label)}" ), f"{member.mention}\n{' ' * len(member.mention)}", @@ -248,13 +248,13 @@ async def leaderboard( for counter, (member, row) in enumerate(batch, start=offset + 1) ], headers=[ - "`" + formats.label_indent("N", offset + 1, split_size), + "`" + fmt.label_indent("N", offset + 1, split_size), "Name", "Level", "Exp", "Rep`", ], - tablefmt=formats.no_pad_fmt, + tablefmt=fmt.no_pad_fmt, ) offset += split_size tables.append(table) diff --git a/ext/community/logger.py b/ext/community/logger.py index 1d1cdf4a..98353e6f 100644 --- a/ext/community/logger.py +++ b/ext/community/logger.py @@ -9,7 +9,7 @@ from discord.ext import commands from bot import aluloop -from utils import const, formats +from utils import const, fmt from ._base import CommunityCog @@ -262,7 +262,7 @@ async def on_message_edit(self, before: discord.Message, after: discord.Message) # for example if before is peepoComfy and after is dankComfy then it wont be obvious in the embed result # since discord formats emotes first. e.description = ( - f"[**Jump link**]({after.jump_url}) {formats.inline_word_by_word_diff(before.content, after.content)}" + f"[**Jump link**]({after.jump_url}) {fmt.inline_word_by_word_diff(before.content, after.content)}" ) await self.community.logs.send(embed=e) diff --git a/ext/dev/management.py b/ext/dev/management.py index 5bcc9d25..91b8fd56 100644 --- a/ext/dev/management.py +++ b/ext/dev/management.py @@ -7,7 +7,7 @@ from discord.ext import commands from tabulate import tabulate -from utils import const, errors, formats, fuzzy +from utils import const, errors, fmt, fuzzy from ._base import DevBaseCog @@ -62,7 +62,7 @@ def get_guild_stats(self, embed: discord.Embed, guild: discord.Guild) -> discord tabular_data=[("Name", guild.name), ("ID", guild.id), ("Shard ID", guild.shard_id or "N/A")], tablefmt="plain", ) - embed.add_field(name="Guild Info", value=formats.code(guild_info)) + embed.add_field(name="Guild Info", value=fmt.code(guild_info)) if guild.owner: embed.set_author( name=f"Owner: {guild.owner} (ID: {guild.owner_id})", @@ -76,7 +76,7 @@ def get_guild_stats(self, embed: discord.Embed, guild: discord.Guild) -> discord guild_stats = tabulate( tabular_data=[("Members", total), ("Bots", f"{bots} ({bots / total:.2%})")], tablefmt="plain" ) - embed.add_field(name="Guild Stats", value=formats.code(guild_stats)) + embed.add_field(name="Guild Stats", value=fmt.code(guild_stats)) if guild.me: embed.timestamp = guild.me.joined_at return embed diff --git a/ext/dev/reload.py b/ext/dev/reload.py index c2464b02..ae72346a 100644 --- a/ext/dev/reload.py +++ b/ext/dev/reload.py @@ -13,7 +13,7 @@ from discord.ext import commands from ext import get_extensions -from utils import const, formats +from utils import const, fmt from ._base import DevBaseCog @@ -70,6 +70,8 @@ async def load_unload_reload_job( tick = False await ctx.tick_reaction(tick) + # need to restart the timers in case new/old extensions add/remove timer listeners. + self.bot.timers.reschedule() @commands.command(name="load", hidden=True) async def load(self, ctx: AluContext, extension: Annotated[str, ExtensionConverter]) -> None: @@ -114,7 +116,7 @@ async def do_the_job(ext: str, emote: str, method: Callable[[str], Awaitable[Non ).set_footer(text=f"reload_all_worker.do_the_job: {ext}") await self.bot.exc_manager.register_error(exc, embed) # name, value - errors.append((f"{formats.tick(False)} `{exc.__class__.__name__}`", f"{exc}")) + errors.append((f"{const.Tick.No} `{exc.__class__.__name__}`", f"{exc}")) for ext in extensions_to_reload: emoji = "\N{ANTICLOCKWISE DOWNWARDS AND UPWARDS OPEN CIRCLE ARROWS}" @@ -125,7 +127,7 @@ async def do_the_job(ext: str, emote: str, method: Callable[[str], Awaitable[Non if errors: content = "\n".join( - f"{formats.tick(status)} - {emoji} `{ext if not ext.startswith('ext.') else ext[5:]}`" + f"{fmt.tick(status)} - {emoji} `{ext if not ext.startswith('ext.') else ext[5:]}`" for status, emoji, ext in statuses ) @@ -138,10 +140,10 @@ async def do_the_job(ext: str, emote: str, method: Callable[[str], Awaitable[Non else: # no errors thus let's not clutter my spam channel with output^ try: - await ctx.message.add_reaction(formats.tick(True)) + await ctx.message.add_reaction(const.Tick.Yes) except discord.HTTPException: with contextlib.suppress(discord.HTTPException): - await ctx.send(formats.tick(True)) + await ctx.send(const.Tick.No) @reload.command(name="all", hidden=True) async def reload_all(self, ctx: AluContext) -> None: @@ -234,7 +236,7 @@ async def reload_pull_worker(self, ctx: AluContext) -> None: else: statuses.append((True, module)) - await ctx.send("\n".join(f"{formats.tick(status)}: `{module}`" for status, module in statuses)) + await ctx.send("\n".join(f"{fmt.tick(status)}: `{module}`" for status, module in statuses)) @reload.command(name="pull", hidden=True) async def reload_pull(self, ctx: AluContext) -> None: diff --git a/ext/dev/sync.py b/ext/dev/sync.py index 7ee1d693..808a896a 100644 --- a/ext/dev/sync.py +++ b/ext/dev/sync.py @@ -14,7 +14,7 @@ from ._base import DevBaseCog if TYPE_CHECKING: - from bot import AluBot, AluContext + from bot import AluBot, AluContext, AluInteraction log = logging.getLogger(__name__) log.setLevel(logging.INFO) @@ -75,10 +75,7 @@ async def sync_to_guild_list(self, guilds: list[discord.Object]) -> str: return f"Synced {len(cmds)} guild-bound commands to `{successful_guild_syncs}/{len(guilds)}` guilds." async def sync_command_worker( - self, - spec: str | None, - current_guild: discord.Guild | None, - guilds: list[discord.Object], + self, spec: str | None, current_guild: discord.Guild | None, guilds: list[discord.Object] ) -> discord.Embed: """A worker function for both prefix/slash commands to sync application tree. @@ -131,7 +128,7 @@ async def sync_command_worker( # Need to split the commands bcs commands.Greedy can't be transferred to app_commands @app_commands.guilds(const.Guild.hideout) - @app_commands.command(name="sync") + @app_commands.command(name="sync-dev") @app_commands.choices( method=[ app_commands.Choice(name="Global Sync", value="global"), @@ -143,21 +140,22 @@ async def sync_command_worker( app_commands.Choice(name="Specific Guilds", value="guilds"), ], ) - async def slash_sync(self, interaction: discord.Interaction[AluBot], method: str) -> None: + async def slash_sync(self, interaction: AluInteraction, method: str) -> None: """\N{GREY HEART} Hideout-Only | Sync bot's app tree. Parameters ---------- - method + method: str Method to sync bot's commands with. """ if method == "guilds": - # it's not worth to mirror commands.Greedy argument into a slash command + # I don't want to bother to mirror `commands.Greedy` argument into a slash command # so just redirect yourself to a prefix $sync command. - return await interaction.response.send_message( - f"Use prefix command `{self.bot.main_prefix}`sync guild1_id guild2_id ... ` !", + await interaction.response.send_message( + f"Use prefix command `{self.bot.main_prefix}sync guild1_id guild2_id ... ` !", ) + return embed = await self.sync_command_worker(method, interaction.guild, guilds=[]) await interaction.response.send_message(embed=embed) diff --git a/ext/dota_features/bugtracker.py b/ext/dota_features/bugtracker.py index 5563f59d..266fde18 100644 --- a/ext/dota_features/bugtracker.py +++ b/ext/dota_features/bugtracker.py @@ -305,8 +305,7 @@ async def cog_unload(self) -> None: @discord.utils.cached_property def news_channel(self) -> discord.TextChannel: """Dota 2 Bug tracker news channel.""" - channel = self.hideout.spam if self.bot.test else self.community.bugtracker_news - return channel + return self.hideout.spam if self.bot.test else self.community.bugtracker_news async def get_valve_devs(self) -> list[str]: """Get the list of known Valve developers.""" @@ -332,37 +331,29 @@ async def bugtracker_add(self, ctx: AluContext, *, login: str) -> None: error_logins = [] success_logins = [] - for l in logins: + for name in logins: # looks like executemany wont work bcs it executes strings letter by letter! - val = await self.bot.pool.fetchval(query, l) + val = await self.bot.pool.fetchval(query, name) if val: # query above returned True - success_logins.append(l) + success_logins.append(name) else: # query above returned None - error_logins.append(l) + error_logins.append(name) def embed_answer(logins: list[str], color: discord.Color, description: str) -> discord.Embed: - logins_join = ", ".join(f"`{l}`" for l in logins) + logins_join = ", ".join(f"`{name}`" for name in logins) return discord.Embed(color=color, description=f"{description}\n{logins_join}") embeds: list[discord.Embed] = [] if success_logins: self.valve_devs.extend(success_logins) embeds.append( - embed_answer( - success_logins, - const.Palette.green(), - "Added user(-s) to the list of Valve devs.", - ), + embed_answer(success_logins, const.Palette.green(), "Added user(-s) to the list of Valve devs."), ) if error_logins: embeds.append( - embed_answer( - error_logins, - const.Palette.red(), - "User(-s) were already in the list of Valve devs.", - ), + embed_answer(error_logins, const.Palette.red(), "User(-s) were already in the list of Valve devs."), ) await ctx.reply(embeds=embeds) @@ -405,8 +396,9 @@ async def bugtracker_news_worker(self) -> None: dt: datetime.datetime = await self.bot.pool.fetchval(query, const.Guild.community) now = datetime.datetime.now(datetime.UTC) - # if self.bot.test: # FORCE TESTING - # dt = now - datetime.timedelta(hours=2) + if self.bot.test: + # FORCE TESTING + dt = now - datetime.timedelta(hours=2) issue_dict: dict[int, TimeLine] = {} @@ -560,4 +552,5 @@ async def get_issue(self, issue_number: int) -> Issue: async def setup(bot: AluBot) -> None: """Load AluBot extension. Framework of discord.py.""" - # await bot.add_cog(BugTracker(bot)) + # uncomment when https://github.com/yanyongyu/githubkit/issues/188 fixed + # await bot.add_cog(BugTracker(bot)) # noqa: ERA001 diff --git a/ext/dota_features/profile.py b/ext/dota_features/profile.py index eb3f615f..425c9728 100644 --- a/ext/dota_features/profile.py +++ b/ext/dota_features/profile.py @@ -8,17 +8,17 @@ from tabulate import tabulate from bot import AluCog -from utils import const, errors +from utils import const, errors, fmt if TYPE_CHECKING: - from bot import AluBot + from bot import AluBot, AluInteraction class SteamUserTransformer(app_commands.Transformer): """Simple steam user converter.""" @override - async def transform(self, interaction: discord.Interaction[AluBot], argument: str) -> steam.User: + async def transform(self, interaction: AluInteraction, argument: str) -> steam.User: try: return await interaction.client.dota.fetch_user(steam.utils.parse_id64(argument)) except steam.InvalidID: @@ -32,42 +32,34 @@ async def transform(self, interaction: discord.Interaction[AluBot], argument: st raise errors.TimeoutError(msg) from None @override - async def autocomplete( - self, - interaction: discord.Interaction[AluBot], - arg: str, - ) -> list[app_commands.Choice[str]]: + async def autocomplete(self, interaction: AluInteraction, arg: str) -> list[app_commands.Choice[str]]: return [app_commands.Choice(name="Aluerie", value="112636165")] class SteamDotaProfiles(AluCog): + """Commands to get information about people's Dota/Steam profiles.""" + @app_commands.command() async def steam( - self, - interaction: discord.Interaction[AluBot], - user: app_commands.Transform[steam.User, SteamUserTransformer], + self, interaction: AluInteraction, user: app_commands.Transform[steam.User, SteamUserTransformer] ) -> None: - """🔎 Show some basic info on a steam user.""" + """\N{RIGHT-POINTING MAGNIFYING GLASS} Show some basic info on a steam user.""" await interaction.response.defer() + table = tabulate( + tabular_data=[ + ["ID64", str(user.id64)], + ["ID32", str(user.id)], + ["ID3", str(user.id3)], + ["ID2", str(user.id2)], + ], + tablefmt="plain", + ) + embed = ( discord.Embed(title=user.name) .set_thumbnail(url=user.avatar.url) - .add_field( - name="Steam IDs", - value=f"```{ - tabulate( - tabular_data=[ - ['ID64', str(user.id64)], - ['ID32', str(user.id)], - ['ID3', str(user.id3)], - ['ID2', str(user.id2)], - ], - tablefmt='plain', - ) - }\n```", - inline=False, - ) + .add_field(name="Steam IDs", value=fmt.code(table), inline=False) .add_field(name="Currently playing:", value=f"{user.app or 'Nothing'}") # .add_field(name="Friends:", value=len(await user.friends())) .add_field(name="Apps:", value=len(await user.apps())) @@ -76,24 +68,20 @@ async def steam( @app_commands.guilds(*const.MY_GUILDS) @app_commands.command(name="history") - async def match_history(self, interaction: discord.Interaction[AluBot]) -> None: - """🔎 Show Aluerie's Dota 2 recent match history.""" + async def match_history(self, interaction: AluInteraction) -> None: + """\N{RIGHT-POINTING MAGNIFYING GLASS} Show Aluerie's Dota 2 recent match history.""" await interaction.response.defer() player = interaction.client.dota.aluerie() history = await player.match_history() - embed = discord.Embed( - description="\n".join( - [ - ( - f"{count}. {match.hero} " - f"{(await interaction.client.dota.heroes.by_id(match.hero)).emote} - {match.id}" - ) - for count, match in enumerate(history) - ], - ), + description = "\n".join( + [ + (f"{count}. {match.hero} {(await interaction.client.dota.heroes.by_id(match.hero)).emote} - {match.id}") + for count, match in enumerate(history) + ] ) + embed = discord.Embed(description=description) await interaction.followup.send(embed=embed) diff --git a/ext/fpc/base_classes/settings.py b/ext/fpc/base_classes/settings.py index a4a9ec8a..f4e3f79f 100644 --- a/ext/fpc/base_classes/settings.py +++ b/ext/fpc/base_classes/settings.py @@ -8,7 +8,7 @@ import discord from discord import app_commands -from utils import const, errors, formats, mimics +from utils import const, errors, fmt, mimics from . import FPCCog, views @@ -382,8 +382,8 @@ async def setup_channel(self, interaction: discord.Interaction[AluBot]) -> None: "After choosing the channel, the bot will edit this message to showcase newly selected channel." ), ) - .add_field(name=f"Channel {formats.tick(bool(channel))}", value=channel.mention if channel else "Not set") - .add_field(name=f"Webhook {formats.tick(bool(webhook))}", value="Properly Set" if webhook else "Not set") + .add_field(name=f"Channel {fmt.tick(bool(channel))}", value=channel.mention if channel else "Not set") + .add_field(name=f"Webhook {fmt.tick(bool(webhook))}", value="Properly Set" if webhook else "Not set") .set_footer(text=self.game_display_name, icon_url=self.game_icon_url) ) view = views.SetupChannel(self, author_id=interaction.user.id, embed=embed) @@ -423,7 +423,7 @@ async def setup_misc(self, interaction: discord.Interaction[AluBot]) -> None: def state(on_off: bool) -> str: # noqa: FBT001 word = "`on`" if on_off else "`off`" - return f"{word} {formats.tick(on_off)}" + return f"{word} {fmt.tick(on_off)}" embed = ( discord.Embed( diff --git a/ext/fpc/base_classes/views.py b/ext/fpc/base_classes/views.py index 669b060f..23d42236 100644 --- a/ext/fpc/base_classes/views.py +++ b/ext/fpc/base_classes/views.py @@ -6,7 +6,7 @@ from discord.ext import menus from bot import AluView -from utils import const, errors, formats, mimics, pages +from utils import const, errors, fmt, mimics, pages if TYPE_CHECKING: from collections.abc import Mapping @@ -96,10 +96,10 @@ async def set_channel( await interaction.client.pool.execute(query, channel.guild.id, channel.guild.name, channel.id) self.embed.set_field_at( - 0, name=f"Channel {formats.tick(bool(channel))}", value=channel.mention if channel else "Not set", + 0, name=f"Channel {fmt.tick(bool(channel))}", value=channel.mention if channel else "Not set", ) self.embed.set_field_at( - 1, name=f"Webhook {formats.tick(bool(webhook))}", value="Properly Set" if webhook else "Not set", + 1, name=f"Webhook {fmt.tick(bool(webhook))}", value="Properly Set" if webhook else "Not set", ) await interaction.response.edit_message(embed=self.embed) @@ -134,7 +134,7 @@ async def toggle_worker(self, interaction: discord.Interaction[AluBot], setting: old_field_name = self.embed.fields[field_index].name assert isinstance(old_field_name, str) - new_field_name = f'{old_field_name.split(":")[0]}: {"`on`" if new_value else "`off`"} {formats.tick(new_value)}' + new_field_name = f'{old_field_name.split(":")[0]}: {"`on`" if new_value else "`off`"} {fmt.tick(new_value)}' old_field_value = self.embed.fields[field_index].value self.embed.set_field_at(field_index, name=new_field_name, value=old_field_value, inline=False) await interaction.response.edit_message(embed=self.embed) diff --git a/ext/fpc/dota/models.py b/ext/fpc/dota/models.py index f04780f6..c978da9e 100644 --- a/ext/fpc/dota/models.py +++ b/ext/fpc/dota/models.py @@ -10,7 +10,7 @@ import discord from PIL import Image, ImageDraw, ImageFont, ImageOps -from utils import const, formats +from utils import const, fmt from ..base_classes import BaseMatchToEdit, BaseMatchToSend @@ -180,7 +180,7 @@ async def webhook_send_kwargs(self) -> RecipientKwargs: title=f"{title} {self.player_hero.emote}", url=twitch_data["url"], description=( - f"`/match {self.match_id}` started {formats.human_timedelta(self.long_ago, mode='strip')}\n" + f"`/match {self.match_id}` started {fmt.human_timedelta(self.long_ago, mode='strip')}\n" f"{twitch_data['vod_url']}{self.links}" ), ) diff --git a/ext/fpc/lol/models.py b/ext/fpc/lol/models.py index 80ec6376..9c7fd44b 100644 --- a/ext/fpc/lol/models.py +++ b/ext/fpc/lol/models.py @@ -10,7 +10,7 @@ from PIL import Image, ImageDraw, ImageFont from utils import const, lol -from utils.formats import human_timedelta +from utils.fmt import human_timedelta from ..base_classes import BaseMatchToEdit, BaseMatchToSend diff --git a/ext/fun/fun/other.py b/ext/fun/fun/other.py index 57c0fcde..9720348d 100644 --- a/ext/fun/fun/other.py +++ b/ext/fun/fun/other.py @@ -14,15 +14,13 @@ from .._base import FunCog if TYPE_CHECKING: - from collections.abc import Callable - - from bot import AluBot + from bot import AluBot, AluInteraction class Other(FunCog): @app_commands.command() - async def coinflip(self, interaction: discord.Interaction[AluBot]) -> None: - """Flip a coin: Heads or Tails?""" + async def coinflip(self, interaction: AluInteraction) -> None: + """Flip a coin: Heads or Tails?.""" word = "Heads" if random.randint(0, 1) == 0 else "Tails" await interaction.response.send_message(content=word, file=discord.File(f"assets/images/coinflip/{word}.png")) @@ -41,7 +39,7 @@ async def reply_non_command_mentions(self, message: discord.Message) -> None: await message.add_reaction(r) @app_commands.command() - async def roll(self, interaction: discord.Interaction[AluBot], max_roll_number: app_commands.Range[int, 1]) -> None: + async def roll(self, interaction: AluInteraction, max_roll_number: app_commands.Range[int, 1]) -> None: """Roll an integer from 1 to `max_roll_number`. Parameters @@ -64,15 +62,15 @@ def fancify_text(text: str, *, style: dict[str, str]) -> str: combined_pattern = r"|".join(patterns) mentions_or_emotes = re.findall(combined_pattern, text) - style = style | {k: k for k in mentions_or_emotes} + style |= {k: k for k in mentions_or_emotes} pattern = "|".join(re.escape(k) for k in style) - match_repl: Callable[[re.Match[str]], str | re.Match[str]] = lambda c: ( - style.get(c.group(0)) or style.get(c.group(0).lower()) or c - ) + def match_repl(c: re.Match[str]) -> str | re.Match[str]: + return style.get(c.group(0)) or style.get(c.group(0).lower()) or c + return re.sub(pattern, match_repl, text) # type: ignore # i dont understand regex types x_x - async def send_mimic_text(self, interaction: discord.Interaction[AluBot], content: str) -> None: + async def send_mimic_text(self, interaction: AluInteraction, content: str) -> None: if not interaction.guild: # outside of guilds - probably DM await interaction.response.send_message(content) @@ -88,7 +86,7 @@ async def send_mimic_text(self, interaction: discord.Interaction[AluBot], conten ) @text_group.command() - async def emotify(self, interaction: discord.Interaction[AluBot], text: str) -> None: + async def emotify(self, interaction: AluInteraction, text: str) -> None: """Makes your text consist only of emotes. Parameters @@ -113,7 +111,9 @@ async def emotify(self, interaction: discord.Interaction[AluBot], text: str) -> @text_group.command() async def fancify( - self, interaction: discord.Interaction[AluBot], text: str, + self, + interaction: discord.Interaction[AluBot], + text: str, ) -> None: # cSpell:disable #fmt:off # black meeses it up x_x """𝓜𝓪𝓴𝓮𝓼 𝔂𝓸𝓾𝓻 𝓽𝓮𝔁𝓽 𝓵𝓸𝓸𝓴 𝓵𝓲𝓴𝓮 𝓽𝓱𝓲𝓼. @@ -121,7 +121,7 @@ async def fancify( ---------- Text to convert into fancy text. """ # noqa: RUF002 - # cSpell:enable #fmt:on + # cSpell:enable #fmt:on # noqa: ERA001 style = {chr(0x00000041 + x): chr(0x0001D4D0 + x) for x in range(26)} | { # A-Z into fancy font chr(0x00000061 + x): chr(0x0001D4EA + x) diff --git a/ext/fun/fun/rock_paper_scissors.py b/ext/fun/fun/rock_paper_scissors.py index 08146e7c..ad141890 100644 --- a/ext/fun/fun/rock_paper_scissors.py +++ b/ext/fun/fun/rock_paper_scissors.py @@ -46,7 +46,7 @@ def __init__( *, player1: discord.User | discord.Member, player2: discord.User | discord.Member, - message: discord.Message = None, # type: ignore # secured to be discord.Message + message: discord.Message = None, # type: ignore[reportArgumentType] # secured to be discord.Message ) -> None: super().__init__(author_id=None) self.player1: discord.User | discord.Member = player1 diff --git a/ext/hideout/moderation.py b/ext/hideout/moderation.py index 34f44d56..db4c3e24 100644 --- a/ext/hideout/moderation.py +++ b/ext/hideout/moderation.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from bot import AluBot + from bot import AluBot, AluInteraction class HideoutModeration(HideoutCog): @@ -38,7 +38,7 @@ async def jail_bots_kick_people_on_join(self, member: discord.Member) -> None: @commands.bot_has_permissions(manage_messages=True) async def purge( self, - interaction: discord.Interaction[AluBot], + interaction: AluInteraction, messages: app_commands.Range[int, 1, 2000] = 100, user: discord.User | None = None, after: int | None = None, @@ -52,19 +52,19 @@ async def purge( Parameters ---------- - messages - Amount of messages to be deleted. Default: 10. - user + messages: app_commands.Range[int, 1, 2000] = 100 + Amount of messages to be deleted. + user: discord.User | None = None User to delete messages of. Default: messages from all users will be deleted. - after + after: int | None = None Search for messages that come after this message ID. - before + before: int | None = None Search for messages that come before this message ID. - bot + bot: bool = False Remove messages from bots (not webhooks!). - webhooks + webhooks: bool = False Remove messages from webhooks. - require + require: Literal["any", "all"] = "all" Whether any or all of the flags should be met before deleting messages. Defaults to "all". Sources @@ -140,7 +140,7 @@ def predicate(m: discord.Message) -> bool: @app_commands.guilds(const.Guild.hideout) @app_commands.command() @app_commands.default_permissions(manage_messages=True) - async def spam_chat(self, interaction: discord.Interaction[AluBot]) -> None: + async def spam_chat(self, interaction: AluInteraction) -> None: """Make the bot to spam the chat. Useful when we want to move some bad messages out of sight, diff --git a/ext/information/info.py b/ext/information/info.py index 8abedc9a..e0f9d385 100644 --- a/ext/information/info.py +++ b/ext/information/info.py @@ -11,7 +11,7 @@ from PIL import Image, ImageColor from wordcloud import WordCloud -from utils import const, converters, formats +from utils import const, converters, fmt from ._base import InfoCog @@ -42,7 +42,7 @@ async def on_message(self, message: discord.Message) -> None: utc_offset = o.seconds if (o := dt.utcoffset()) else 0 dst = d.seconds if (d := dt.dst()) else 0 e.description = ( - f'"{date[0]}" in your timezone:\n {formats.format_dt_tdR(dt)}\n' + f'"{date[0]}" in your timezone:\n {fmt.format_dt_tdR(dt)}\n' f"{dt.tzname()} is GMT {utc_offset / 3600:+.1f}, dst: {dst / 3600:+.1f}" ) await message.channel.send(embed=e) diff --git a/ext/information/schedule.py b/ext/information/schedule.py index 5696e8a1..d9544a7c 100644 --- a/ext/information/schedule.py +++ b/ext/information/schedule.py @@ -10,7 +10,7 @@ from discord import app_commands from discord.ext import menus -from utils import cache, const, formats, pages +from utils import cache, const, fmt, pages from ._base import InfoCog @@ -208,7 +208,7 @@ async def format_page(self, menu: SchedulePages, matches: list[Match]) -> discor match_with_longest_teams = max(matches, key=lambda x: len(x.teams)) max_amount_of_chars = len(match_with_longest_teams.teams) - desc += f"`{'Datetime now '.ljust(max_amount_of_chars, ' ')}`{formats.format_dt_custom(dt_now, 't', 'd')}\n" + desc += f"`{'Datetime now '.ljust(max_amount_of_chars, ' ')}`{fmt.format_dt_custom(dt_now, 't', 'd')}\n" # matches.sort(key=lambda x: (x.league, x.dt)) # now it's sorted by leagues and dt @@ -228,7 +228,7 @@ async def format_page(self, menu: SchedulePages, matches: list[Match]) -> discor desc += ( f"[`{match.teams.ljust(max_amount_of_chars, ' ')}`]({match.twitch_url})" - f"{formats.format_dt_custom(match.dt, 't', 'R')}\n" + f"{fmt.format_dt_custom(match.dt, 't', 'R')}\n" ) embed.description = desc @@ -348,7 +348,7 @@ async def fixtures( ] dt = datetime.datetime.strptime(match_time, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.UTC) teams = f"{team1} - {team2}".ljust(40, " ") - match_strings.append(f"`{teams}` {formats.format_dt_tdR(dt)}") + match_strings.append(f"`{teams}` {fmt.format_dt_tdR(dt)}") embed = discord.Embed( colour=0xE0FA51, diff --git a/ext/reminders/reminders.py b/ext/reminders/reminders.py index 06116dfe..4bfbf68f 100644 --- a/ext/reminders/reminders.py +++ b/ext/reminders/reminders.py @@ -10,7 +10,7 @@ from discord.ext import commands from bot import AluContext -from utils import const, formats, pages, times +from utils import const, fmt, pages, times from ._base import RemindersCog @@ -49,7 +49,7 @@ async def on_submit(self, interaction: discord.Interaction[AluBot]) -> None: await interaction.response.edit_message(view=self.parent) zone = await interaction.client.tz_manager.get_timezone(interaction.user.id) - refreshed = await interaction.client.create_timer( + refreshed = await interaction.client.timers.create( event=self.timer.event, expires_at=when, created_at=interaction.created_at, @@ -58,7 +58,7 @@ async def on_submit(self, interaction: discord.Interaction[AluBot]) -> None: ) author_id = self.timer.data.get("author_id") text = self.timer.data.get("text") - delta = formats.human_timedelta(when, source=refreshed.created_at) + delta = fmt.human_timedelta(when, source=refreshed.created_at) msg = f"Alright <@{author_id}>, I've snoozed your reminder for {delta}: {text}" await interaction.followup.send(msg, ephemeral=True) @@ -118,17 +118,17 @@ async def remind_helper(self, ctx: AluContext, *, dt: datetime.datetime, text: s "text": text, "message_id": ctx.message.id, } - timer = await self.bot.create_timer( + timer = await self.bot.timers.create( event="reminder", expires_at=dt, created_at=ctx.message.created_at, timezone=zone or "UTC", data=data, ) - delta = formats.human_timedelta(dt, source=timer.created_at) + delta = fmt.human_timedelta(dt, source=timer.created_at) e = discord.Embed(colour=ctx.author.colour) e.set_author(name=f"Reminder for {ctx.author.display_name} is created", icon_url=ctx.author.display_avatar) - e.description = f"in {delta} — {formats.format_dt_tdR(dt)}\n{text}" + e.description = f"in {delta} — {fmt.format_dt_tdR(dt)}\n{text}" if zone is None: e.set_footer(text=f'\N{ELECTRIC LIGHT BULB} You can set your timezone with "{ctx.prefix}timezone set') await ctx.reply(embed=e) @@ -192,7 +192,7 @@ async def remind_list(self, interaction: discord.Interaction[AluBot]) -> None: string_list = [] for _id, expires, message in records: shorten = textwrap.shorten(message, width=512) - string_list.append(f"\N{BLACK CIRCLE} {_id}: {formats.format_dt_tdR(expires)}\n{shorten}") + string_list.append(f"\N{BLACK CIRCLE} {_id}: {fmt.format_dt_tdR(expires)}\n{shorten}") pgs = pages.EnumeratedPaginator( interaction, @@ -246,14 +246,11 @@ async def remind_delete(self, interaction: discord.Interaction[AluBot], id: int) embed = discord.Embed( colour=const.Colour.error, description="Could not delete any reminders with that ID.", - ).set_author(name="IDError") + ).set_author(name="NotFound") await interaction.response.send_message(embed=embed) return - # if the current timer is being deleted - if self.bot._current_timer and self.bot._current_timer.id == id: - # cancel the task and re-run it - self.bot.reschedule_timers() + self.bot.timers.check_reschedule(id) embed = discord.Embed(description="Successfully deleted reminder.", colour=const.Colour.prpl) await interaction.response.send_message(embed=embed) @@ -279,7 +276,7 @@ async def reminder_clear(self, interaction: discord.Interaction[AluBot]) -> None confirm_embed = discord.Embed( colour=interaction.user.colour, - description=f"Are you sure you want to delete {formats.plural(total):reminder}?", + description=f"Are you sure you want to delete {fmt.plural(total):reminder}?", ) if not await interaction.client.disambiguator.confirm(interaction, embed=confirm_embed): return @@ -288,19 +285,19 @@ async def reminder_clear(self, interaction: discord.Interaction[AluBot]) -> None DELETE FROM timers WHERE event = 'reminder' AND data #>> '{author_id}' = $1; - """ + """ # noqa: RUF027 await interaction.client.pool.execute(query, author_id) # Check if the current timer is the one being cleared and cancel it if so - current_timer = self.bot._current_timer + current_timer = self.bot.timers.current_timer if current_timer and current_timer.event == "reminder" and current_timer.data: author_id = current_timer.data.get("author_id") if author_id == interaction.user.id: - self.bot.reschedule_timers() + self.bot.timers.reschedule() response_embed = discord.Embed( colour=interaction.user.colour, - description=f"Successfully deleted {formats.plural(total):reminder}.", + description=f"Successfully deleted {fmt.plural(total):reminder}.", ) await interaction.response.send_message(embed=response_embed) @@ -328,7 +325,7 @@ async def on_reminder_timer_complete(self, timer: Timer[RemindTimerData]) -> Non view = ReminderView(url=url, timer=timer, cog=self, author_id=author_id) try: - msg = await channel.send(content, view=view) # type: ignore + msg = await channel.send(content, view=view) # type:ignore[reportAttributeAccessIssue] except discord.HTTPException: return else: diff --git a/ext/stats/emote.py b/ext/stats/emote.py index d4748c55..1f72fa19 100644 --- a/ext/stats/emote.py +++ b/ext/stats/emote.py @@ -14,7 +14,7 @@ from tabulate import tabulate from bot import AluBot, aluloop -from utils import const, errors, formats, pages +from utils import const, errors, fmt, pages from ._base import StatsCog @@ -267,13 +267,13 @@ async def emotestats_server( table = tabulate( tabular_data=[ [ - f"`{formats.label_indent(counter, counter - 1, split_size)}`", + f"`{fmt.label_indent(counter, counter - 1, split_size)}`", *self.emote_fmt(emote_id=row[0], count=row[1], total=all_emotes_total), ] for counter, row in enumerate(batch, start=offset + 1) ], headers=[ - f"`{formats.label_indent('N', offset + 1, split_size)}`", + f"`{fmt.label_indent('N', offset + 1, split_size)}`", "\N{BLACK LARGE SQUARE}", # "\N{FRAME WITH PICTURE}", "`Name", "Total", @@ -367,7 +367,7 @@ async def emotestats_specific(self, interaction: discord.Interaction[AluBot], em .set_thumbnail(url=emoji.url) .add_field( name="Emote Information", - value=f"ID: `{emoji.id}`\nCreated: {formats.format_dt_tdR(emoji.created_at)}", + value=f"ID: `{emoji.id}`\nCreated: {fmt.format_dt_tdR(emoji.created_at)}", inline=False, ) ) @@ -424,11 +424,11 @@ async def emotestats_specific(self, interaction: discord.Interaction[AluBot], em ), ] - server_stats = formats.code( + server_stats = fmt.code( tabulate( headers=["Period", "Usages", "Percent", "Per Day"], tabular_data=[all_time_period, last_year_period], - tablefmt=formats.no_pad_fmt, + tablefmt=fmt.no_pad_fmt, ) ) diff --git a/utils/dota/dota_client.py b/utils/dota/dota_client.py index 3bff4c64..2f605704 100644 --- a/utils/dota/dota_client.py +++ b/utils/dota/dota_client.py @@ -8,7 +8,7 @@ from steam.ext.dota2 import Client from config import config -from utils import const, formats +from utils import const, fmt from .pulsefire_clients import OpenDotaConstantsClient, StratzClient from .storage import Abilities, Facets, Heroes, Items @@ -113,7 +113,7 @@ async def on_error(self, event: str, error: Exception, *args: object, **kwargs: title=f"Error in steam.py's {self.__class__.__name__}", ) .set_author(name=f"Event: {event}", icon_url=const.Logo.Dota) - .add_field(name="Args", value=formats.code(args_join), inline=False) - .add_field(name="Kwargs", value=formats.code(kwargs_join), inline=False) + .add_field(name="Args", value=fmt.code(args_join), inline=False) + .add_field(name="Kwargs", value=fmt.code(kwargs_join), inline=False) ) await self.bot.exc_manager.register_error(error, embed) diff --git a/utils/dota/storage.py b/utils/dota/storage.py index 2ce80dc5..22cd90c7 100644 --- a/utils/dota/storage.py +++ b/utils/dota/storage.py @@ -5,7 +5,7 @@ import discord -from .. import const, formats +from .. import const, fmt from ..fpc import Character, CharacterStorage, CharacterTransformer, GameDataStorage if TYPE_CHECKING: @@ -154,7 +154,7 @@ async def create_hero_emote( return await self.create_character_emote_helper( character_id=hero_id, table="dota_heroes_info", - emote_name=formats.convert_camel_case_to_PascalCase(hero_short_name), + emote_name=fmt.convert_camel_case_to_PascalCase(hero_short_name), emote_source_url=f"{CDN_REACT}/heroes/icons/{hero_short_name}.png", # copy of `minimap_icon_url` guild_id=const.EmoteGuilds.DOTA[3], ) diff --git a/utils/formats.py b/utils/fmt.py similarity index 98% rename from utils/formats.py rename to utils/fmt.py index 8b1d146c..be749b0a 100644 --- a/utils/formats.py +++ b/utils/fmt.py @@ -35,7 +35,7 @@ # All-time 7232 12.0% 19.9 # Here, "Percent Per Day" blends one into another. tabulate.MIN_PADDING = 1 -# cSpell: disable +# cSpell: disable # noqa: ERA001 no_pad_fmt = tabulate.TableFormat( lineabove=None, linebelowheader=None, @@ -46,7 +46,7 @@ padding=0, with_header_hide=None, ) -# cSpell: enable +# cSpell: enable # noqa: ERA001 class plural: # noqa: N801 # pep8 allows lowercase names for classes that are used as functions @@ -394,7 +394,7 @@ def ansi( bold: bool = False, underline: bool = False, ) -> str: - """Format text in ANSI colours for discord. + r"""Format text in ANSI colours for discord. Discord doesn't support bright colours in ANSI formats (90-97 and 100-107) or dim text highlight. @@ -516,7 +516,9 @@ def __init__(self, *, outer: str, inner: str, separators: bool) -> None: self._aligns: list[LiteralAligns] = [] self._rows: list[list[str]] = [] - def set_columns(self, columns: list[str], *, aligns: list[LiteralAligns] = []) -> None: + def set_columns(self, columns: list[str], *, aligns: list[LiteralAligns] | None = None) -> None: + if aligns is None: + aligns = [] if aligns and len(aligns) != len(columns): msg = "columns and formats parameters should be the same length lists." raise ValueError(msg) @@ -618,7 +620,8 @@ def __init__(self) -> None: def code(text: str, language: str = "py") -> str: """Wrap text into a Python triple "`" discord codeblock. - It's just annoying to type sometimes. It's also 1 symbol shorter :D + It's just annoying to type sometimes. Also shorter like this. + For no code version we can just use `language=""`. """ return f"```{language}\n{text}```" diff --git a/utils/helpers.py b/utils/helpers.py index a2938ab0..04144ee1 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -11,7 +11,7 @@ import discord -from . import const, errors, formats +from . import const, errors, fmt __all__ = ("measure_time",) @@ -92,6 +92,6 @@ def error_handler_response_embed(error: Exception, desc: str, *, unexpected: boo # error was expected and has expected `desc` answer template embed = discord.Embed(colour=const.Colour.error, description=desc) if not isinstance(error, errors.ErroneousUsage): - embed.set_author(name=formats.convert_PascalCase_to_spaces(error.__class__.__name__)) + embed.set_author(name=fmt.convert_PascalCase_to_spaces(error.__class__.__name__)) return embed diff --git a/utils/twitch.py b/utils/twitch.py index a5556d36..4c7992ef 100644 --- a/utils/twitch.py +++ b/utils/twitch.py @@ -9,7 +9,7 @@ from config import config -from . import const, formats +from . import const, fmt if TYPE_CHECKING: from bot import AluBot @@ -233,8 +233,8 @@ async def vod_link(self, *, seconds_ago: int = 0, markdown: bool = True) -> str: if not video: return "" - duration = formats.hms_to_seconds(video.duration) - new_hms = formats.divmod_timedelta(duration - seconds_ago) + duration = fmt.hms_to_seconds(video.duration) + new_hms = fmt.divmod_timedelta(duration - seconds_ago) url = f"{video.url}?t={new_hms}" return f"/[VOD]({url})" if markdown else url