diff --git a/Bot.py b/Bot.py index a2f6e61..3a909f8 100644 --- a/Bot.py +++ b/Bot.py @@ -33,6 +33,7 @@ SparPings: dict[int, dict[str, int]] = {} # This array stores the active instances of blackjack. +# TODO: convert to a map, user id to game BlackjackGames: list[bucks.BlackjackGame] = [] # Replace OwnerId with your Discord user id @@ -89,16 +90,16 @@ async def on_ready() -> None: else: logger.info("Avatar updated!") - try: - members = set(BeardlessBot.guilds[0].members) - except IndexError: + if len(BeardlessBot.guilds) == 0: logger.exception("Bot is in no servers! Add it to a server.") else: for guild in BeardlessBot.guilds: # Do this first so all servers can spar immediately SparPings[guild.id] = dict.fromkeys(brawl.Regions, 0) - logger.info("Zeroed sparpings! Sparring is now possible.") + logger.info("Zeroed SparPings! Sparring is now possible.") + logger.info("Chunking guilds, collecting analytics...") + members: set[nextcord.Member] = set() for guild in BeardlessBot.guilds: members = members.union(set(guild.members)) await guild.chunk() @@ -122,9 +123,9 @@ async def on_guild_join(guild: nextcord.Guild) -> None: try: await channel.send(embed=misc.on_join(guild, role)) except nextcord.DiscordException: - logger.exception("Failed to send onJoin msg!") + logger.exception("Failed to send on_join msg!") else: - logger.info("Sent join message in %s.", channel.name) + logger.info("Sent on_join message in %s.", channel.name) break logger.info( "Beardless Bot is now in %i servers.", len(BeardlessBot.guilds), @@ -136,9 +137,9 @@ async def on_guild_join(guild: nextcord.Guild) -> None: try: await channel.send(embed=misc.NoPermsEmbed) except nextcord.DiscordException: - logger.exception("Failed to send noPerms msg!") + logger.exception("Failed to send NoPerms msg!") else: - logger.info("Sent no perms msg in %s.", channel.name) + logger.info("Sent NoPerms msg in %s.", channel.name) break await guild.leave() logger.info("Left %s.", guild.name) @@ -320,7 +321,7 @@ async def on_thread_update( # Commands: -@BeardlessBot.command(name="flip") # type: ignore[arg-type] +@BeardlessBot.command(name="flip") async def cmd_flip(ctx: misc.BotContext, bet: str = "10") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -333,9 +334,7 @@ async def cmd_flip(ctx: misc.BotContext, bet: str = "10") -> int: return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="blackjack", aliases=("bj",), -) +@BeardlessBot.command(name="blackjack", aliases=("bj",)) async def cmd_blackjack(ctx: misc.BotContext, bet: str = "10") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -349,7 +348,7 @@ async def cmd_blackjack(ctx: misc.BotContext, bet: str = "10") -> int: return 1 -@BeardlessBot.command(name="deal", aliases=("hit",)) # type: ignore[arg-type] +@BeardlessBot.command(name="deal", aliases=("hit",)) async def cmd_deal(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -369,9 +368,7 @@ async def cmd_deal(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="stay", aliases=("stand",), -) +@BeardlessBot.command(name="stay", aliases=("stand",)) async def cmd_stay(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -394,7 +391,7 @@ async def cmd_stay(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="av", aliases=("avatar",)) # type: ignore[arg-type] +@BeardlessBot.command(name="av", aliases=("avatar",)) async def cmd_av(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -404,7 +401,7 @@ async def cmd_av(ctx: misc.BotContext, *, target: str = "") -> int: return 1 -@BeardlessBot.command(name="info") # type: ignore[arg-type] +@BeardlessBot.command(name="info") async def cmd_info(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx) or not ctx.guild: return -1 @@ -421,9 +418,7 @@ async def cmd_info(ctx: misc.BotContext, *, target: str = "") -> int: return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="balance", aliases=("bal",), -) +@BeardlessBot.command(name="balance", aliases=("bal",)) async def cmd_balance(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -433,9 +428,7 @@ async def cmd_balance(ctx: misc.BotContext, *, target: str = "") -> int: return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="leaderboard", aliases=("leaderboards", "lb"), -) +@BeardlessBot.command(name="leaderboard", aliases=("leaderboards", "lb")) async def cmd_leaderboard(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -445,7 +438,7 @@ async def cmd_leaderboard(ctx: misc.BotContext, *, target: str = "") -> int: return 1 -@BeardlessBot.command(name="dice") # type: ignore[arg-type] +@BeardlessBot.command(name="dice") async def cmd_dice(ctx: misc.BotContext) -> int | nextcord.Embed: if misc.ctx_created_thread(ctx): return -1 @@ -460,7 +453,7 @@ async def cmd_dice(ctx: misc.BotContext) -> int | nextcord.Embed: return emb -@BeardlessBot.command(name="reset") # type: ignore[arg-type] +@BeardlessBot.command(name="reset") async def cmd_reset(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -468,7 +461,7 @@ async def cmd_reset(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="register") # type: ignore[arg-type] +@BeardlessBot.command(name="register") async def cmd_register(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -476,7 +469,7 @@ async def cmd_register(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="bucks") # type: ignore[arg-type] +@BeardlessBot.command(name="bucks") async def cmd_bucks(ctx: misc.BotContext) -> int | nextcord.Embed: if misc.ctx_created_thread(ctx): return -1 @@ -490,7 +483,7 @@ async def cmd_bucks(ctx: misc.BotContext) -> int | nextcord.Embed: return emb -@BeardlessBot.command(name="hello", aliases=("hi",)) # type: ignore[arg-type] +@BeardlessBot.command(name="hello", aliases=("hi",)) async def cmd_hello(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -498,7 +491,7 @@ async def cmd_hello(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="source") # type: ignore[arg-type] +@BeardlessBot.command(name="source") async def cmd_source(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -510,9 +503,7 @@ async def cmd_source(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="add", aliases=("join", "invite"), -) +@BeardlessBot.command(name="add", aliases=("join", "invite")) async def cmd_add(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -520,7 +511,7 @@ async def cmd_add(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="rohan") # type: ignore[arg-type] +@BeardlessBot.command(name="rohan") async def cmd_rohan(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -528,7 +519,7 @@ async def cmd_rohan(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="random") # type: ignore[arg-type] +@BeardlessBot.command(name="random") async def cmd_random_brawl( ctx: misc.BotContext, ran_type: str = "None", ) -> int: @@ -539,7 +530,7 @@ async def cmd_random_brawl( return 1 -@BeardlessBot.command(name="fact") # type: ignore[arg-type] +@BeardlessBot.command(name="fact") async def cmd_fact(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -549,9 +540,7 @@ async def cmd_fact(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="animals", aliases=("animal", "pets"), -) +@BeardlessBot.command(name="animals", aliases=("animal", "pets")) async def cmd_animals(ctx: misc.BotContext) -> int: """ Send an embed listing all the valid animal commands. @@ -579,7 +568,7 @@ async def cmd_animals(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="define") # type: ignore[arg-type] +@BeardlessBot.command(name="define") async def cmd_define(ctx: misc.BotContext, *, words: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -588,7 +577,7 @@ async def cmd_define(ctx: misc.BotContext, *, words: str = "") -> int: return 1 -@BeardlessBot.command(name="ping") # type: ignore[arg-type] +@BeardlessBot.command(name="ping") async def cmd_ping(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx) or BeardlessBot.user is None: return -1 @@ -600,7 +589,7 @@ async def cmd_ping(ctx: misc.BotContext) -> int: return 1 -@BeardlessBot.command(name="roll") # type: ignore[arg-type] +@BeardlessBot.command(name="roll") async def cmd_roll( ctx: misc.BotContext, dice: str = "None", ) -> int: @@ -610,7 +599,7 @@ async def cmd_roll( return 1 -@BeardlessBot.command(name="dog", aliases=("moose",)) # type: ignore[arg-type] +@BeardlessBot.command(name="dog", aliases=("moose",)) async def cmd_dog(ctx: misc.BotContext, *, breed: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -638,9 +627,7 @@ async def cmd_dog(ctx: misc.BotContext, *, breed: str = "") -> int: return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="bunny", aliases=misc.AnimalList, -) +@BeardlessBot.command(name="bunny", aliases=misc.AnimalList) async def cmd_animal(ctx: misc.BotContext, *, breed: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -665,7 +652,7 @@ async def cmd_animal(ctx: misc.BotContext, *, breed: str = "") -> int: # Server-only commands (not usable in DMs): -@BeardlessBot.command(name="mute") # type: ignore[arg-type] +@BeardlessBot.command(name="mute") async def cmd_mute( ctx: misc.BotContext, target: str | None = None, @@ -692,7 +679,7 @@ async def cmd_mute( addendum = (" for " + duration + ".") if duration is not None else "." emb = misc.bb_embed( "Beardless Bot Mute", "Muted " + mute_target.mention + addendum, - ).set_author(name=ctx.author, icon_url=misc.fetch_avatar(ctx.author)) + ).set_author(name=ctx.author.name, icon_url=misc.fetch_avatar(ctx.author)) if reason: emb.add_field(name="Mute Reason:", value=reason, inline=False) await ctx.send(embed=emb) @@ -725,7 +712,7 @@ async def cmd_mute( return 1 -@BeardlessBot.command(name="unmute") # type: ignore[arg-type] +@BeardlessBot.command(name="unmute") async def cmd_unmute( ctx: misc.BotContext, target: str | None = None, ) -> int: @@ -761,7 +748,7 @@ async def cmd_unmute( return 1 -@BeardlessBot.command(name="purge") # type: ignore[arg-type] +@BeardlessBot.command(name="purge") async def cmd_purge( ctx: misc.BotContext, num: str | None = None, ) -> int: @@ -788,7 +775,7 @@ async def cmd_purge( return 0 -@BeardlessBot.command(name="buy") # type: ignore[arg-type] +@BeardlessBot.command(name="buy") async def cmd_buy( ctx: misc.BotContext, color: str = "none", ) -> int: @@ -828,9 +815,7 @@ async def cmd_buy( return 1 -@BeardlessBot.command( # type: ignore[arg-type] - name="pins", aliases=("sparpins", "howtospar"), -) +@BeardlessBot.command(name="pins", aliases=("sparpins", "howtospar")) async def cmd_pins(ctx: misc.BotContext) -> int: if ( misc.ctx_created_thread(ctx) @@ -852,7 +837,7 @@ async def cmd_pins(ctx: misc.BotContext) -> int: return 0 -@BeardlessBot.command(name="spar") # type: ignore[arg-type] +@BeardlessBot.command(name="spar") async def cmd_spar( ctx: misc.BotContext, region: str | None = None, *, additional: str = "", ) -> int: @@ -904,7 +889,7 @@ async def cmd_spar( # Commands requiring a Brawlhalla API key: -@BeardlessBot.command(name="brawl") # type: ignore[arg-type] +@BeardlessBot.command(name="brawl") async def cmd_brawl(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): return -1 @@ -914,7 +899,7 @@ async def cmd_brawl(ctx: misc.BotContext) -> int: return 0 -@BeardlessBot.command(name="brawlclaim") # type: ignore[arg-type] +@BeardlessBot.command(name="brawlclaim") async def cmd_brawlclaim(ctx: misc.BotContext, url_or_id: str = "None") -> int: if misc.ctx_created_thread(ctx) or not BrawlKey: return -1 @@ -934,7 +919,7 @@ async def cmd_brawlclaim(ctx: misc.BotContext, url_or_id: str = "None") -> int: return 1 -@BeardlessBot.command(name="brawlrank") # type: ignore[arg-type] +@BeardlessBot.command(name="brawlrank") async def cmd_brawlrank(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx) or not ctx.guild or not BrawlKey: return -1 @@ -959,7 +944,7 @@ async def cmd_brawlrank(ctx: misc.BotContext, *, target: str = "") -> int: return 0 -@BeardlessBot.command(name="brawlstats") # type: ignore[arg-type] +@BeardlessBot.command(name="brawlstats") async def cmd_brawlstats(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx) or not ctx.guild or not BrawlKey: return -1 @@ -982,7 +967,7 @@ async def cmd_brawlstats(ctx: misc.BotContext, *, target: str = "") -> int: return 0 -@BeardlessBot.command(name="brawlclan") # type: ignore[arg-type] +@BeardlessBot.command(name="brawlclan") async def cmd_brawlclan(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx) or not ctx.guild or not BrawlKey: return -1 @@ -1005,7 +990,7 @@ async def cmd_brawlclan(ctx: misc.BotContext, *, target: str = "") -> int: return 0 -@BeardlessBot.command(name="brawllegend") # type: ignore[arg-type] +@BeardlessBot.command(name="brawllegend") async def cmd_brawllegend(ctx: misc.BotContext, legend: str = "") -> int: if misc.ctx_created_thread(ctx) or not BrawlKey: return -1 @@ -1031,9 +1016,7 @@ async def cmd_brawllegend(ctx: misc.BotContext, legend: str = "") -> int: # Server-specific commands: -@BeardlessBot.command( # type: ignore[arg-type] - name="tweet", aliases=("eggtweet",), -) +@BeardlessBot.command(name="tweet", aliases=("eggtweet",)) async def cmd_tweet(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx) or not ctx.guild: return -1 @@ -1049,7 +1032,7 @@ async def cmd_tweet(ctx: misc.BotContext) -> int: return 0 -@BeardlessBot.command(name="reddit") # type: ignore[arg-type] +@BeardlessBot.command(name="reddit") async def cmd_reddit(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx) or not ctx.guild: return -1 @@ -1059,7 +1042,7 @@ async def cmd_reddit(ctx: misc.BotContext) -> int: return 0 -@BeardlessBot.command(name="guide") # type: ignore[arg-type] +@BeardlessBot.command(name="guide") async def cmd_guide(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx) or not ctx.guild: return -1 @@ -1072,9 +1055,7 @@ async def cmd_guide(ctx: misc.BotContext) -> int: return 0 -@BeardlessBot.command( # type: ignore[arg-type] - name="search", aliases=("google", "lmgtfy"), -) +@BeardlessBot.command(name="search", aliases=("google", "lmgtfy")) async def cmd_search(ctx: misc.BotContext, *, searchterm: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 @@ -1174,7 +1155,9 @@ def launch() -> None: ) try: - BeardlessBot.run(env["DISCORDTOKEN"]) + token = env["DISCORDTOKEN"] + assert isinstance(token, str) + BeardlessBot.run(token) except KeyError: logger.exception( "Fatal error! DISCORDTOKEN environment variable has not" @@ -1186,6 +1169,9 @@ def launch() -> None: if __name__ == "__main__": # pragma: no cover # Pipe logs to stdout and logs folder + logs_folder = Path("./resources/logs") + if not logs_folder.exists(): + logs_folder.mkdir(parents=True) logging.basicConfig( format="%(asctime)s: %(levelname)s: %(message)s", datefmt="%m/%d %H:%M:%S", diff --git a/README.MD b/README.MD index d325b20..60d5f9e 100644 --- a/README.MD +++ b/README.MD @@ -1,6 +1,6 @@ # Beardless Bot -### Full Release 2.5.6 ![Coverage badge](./resources/images/coverage.svg) ![Unit tests badge](./resources/images/tests.svg) ![Docstring coverage badge](./resources/images/docstr-coverage.svg) ![flake8 badge](./resources/images/flake8-badge.svg) +### Full Release 2.6.0 ![Coverage badge](./resources/images/coverage.svg) ![Unit tests badge](./resources/images/tests.svg) ![Docstring coverage badge](./resources/images/docstr-coverage.svg) ![flake8 badge](./resources/images/flake8-badge.svg) A Discord bot supporting gambling (coin flips and blackjack), a currency system, fun facts, and more. diff --git a/bb_test.py b/bb_test.py index cb49fa2..e8c8f8a 100644 --- a/bb_test.py +++ b/bb_test.py @@ -24,12 +24,12 @@ import os import subprocess import sys -import weakref from collections import deque +from collections.abc import AsyncIterator from copy import copy from datetime import datetime from pathlib import Path -from typing import Any, Final, Literal, TypedDict, overload, override +from typing import Any, Final, Literal, Self, TypedDict, overload, override from urllib.parse import quote_plus import dotenv @@ -132,6 +132,25 @@ def valid_image_url(resp: httpx.Response) -> bool: return response_ok(resp) and resp.headers["content-type"] in ImageTypes +async def latest_message(ch: MessageableChannel) -> nextcord.Message | None: + """ + Get the most recent message from a channel's history. + + Args: + ch (MessageableChannel): The channel from which the most recent message + will be pulled. + + Returns: + nextcord.Message | None: The most recent message, if one exists; + else, None. + + """ + h = [i async for i in ch.history()] + if len(h) == 0: + return None + return h[-1] + + # TODO: Write generic MockState # https://github.com/LevBernstein/BeardlessBot/issues/48 @@ -146,11 +165,11 @@ def __init__( ) -> None: self.loop = loop self.user = user - self.user_agent = str(user) + self._user_agent = str(user) self.token = None self.proxy = None self.proxy_auth = None - self._locks = weakref.WeakValueDictionary() + # self._locks = weakref.WeakValueDictionary() self._global_over = asyncio.Event() self._global_over.set() @@ -204,7 +223,11 @@ async def send_message( # type: ignore[override] @override async def leave_guild( - self, guild_id: nextcord.types.snowflake.Snowflake, + self, + guild_id: nextcord.types.snowflake.Snowflake, + *, + auth: str | None = None, + retry_request: bool = True, ) -> None: if ( self.user @@ -215,8 +238,8 @@ async def leave_guild( self.user.guild = None -class MockHistoryIterator(nextcord.iterators.HistoryIterator): - """Mock HistoryIterator class for offline testing.""" +class MockHistoryIterator(AsyncIterator[nextcord.Message]): + """Mock AsyncIterator class for offline testing.""" @override def __init__( @@ -230,25 +253,35 @@ def __init__( ) -> None: self.messageable = messageable self.limit = min(limit or 100, 100) + self.yielded: int = 0 self.messages: asyncio.Queue[nextcord.Message] = asyncio.Queue() self.reverse = ( (after is not None) if oldest_first is None else oldest_first ) @override - async def fill_messages(self) -> None: + def __aiter__(self) -> Self: + return self + + @override + async def __anext__(self) -> nextcord.Message: if not hasattr(self, "channel"): self.channel = await self.messageable._get_channel() assert hasattr(self.channel, "messages") assert isinstance(self.channel.messages, list) assert self.limit is not None - data = ( - list(reversed(self.channel.messages)) - if self.reverse - else self.channel.messages - ) - for _ in range(min(len(data), self.limit)): - await self.messages.put(data.pop()) + self.limit = min(self.limit, len(self.channel.messages)) + if self.yielded < self.limit: + data = ( + list(reversed(self.channel.messages)) + if self.reverse + else self.channel.messages + ) + final = data[self.yielded] + assert isinstance(final, nextcord.Message) + self.yielded += 1 + return final + raise StopAsyncIteration class MockMember(nextcord.Member): @@ -322,8 +355,7 @@ async def send( # type: ignore[override] self, *args: str | None, **kwargs: Any, ) -> None: # It's not worth trying to match the original signature. Trust me. - ch = await self._get_channel() - await ch.send(*args, **kwargs) + await (await self._get_channel()).send(*args, **kwargs) @override async def _get_channel(self) -> nextcord.DMChannel: @@ -338,7 +370,7 @@ def history( after: SnowflakeTime | None = None, around: SnowflakeTime | None = None, oldest_first: bool | None = False, - ) -> nextcord.iterators.HistoryIterator: + ) -> AsyncIterator[nextcord.Message]: return self._user.history( limit=limit, before=before, @@ -353,7 +385,7 @@ class MockUser(nextcord.User): Mock User class for offline testing. MockUser also contains some features of nextcord.Member for the sake of - testing the Bot user's permissions. + testing the Bot user's permissions. That probably should not be the case. """ class MockUserState(nextcord.state.ConnectionState): @@ -448,7 +480,7 @@ def __init__( def set_user_state(self) -> None: """Assign the User object to its ConnectionState.""" self._state.user = MockBot.MockClientUser(self) - self._state.http.user_agent = str(self._state.user) + self._state.http._user_agent = str(self._state.user) @override def history( @@ -459,7 +491,7 @@ def history( after: SnowflakeTime | None = None, around: SnowflakeTime | None = None, oldest_first: bool | None = False, - ) -> nextcord.iterators.HistoryIterator: + ) -> AsyncIterator[nextcord.Message]: channel = self._state.get_channel(None) assert isinstance(channel, nextcord.abc.Messageable) return MockHistoryIterator( @@ -490,13 +522,15 @@ def __init__( self, user: nextcord.ClientUser | None = None, message_number: int = 0, + messages: list[nextcord.Message] | None = None, ) -> None: self.loop = asyncio.get_event_loop() self.http = MockHTTPClient(self.loop, user) self.allowed_mentions = nextcord.AllowedMentions(everyone=True) self.user = user self.last_message_id = message_number - self._messages: deque[nextcord.Message] = deque() + d = deque(messages if messages else []) + self._messages: deque[nextcord.Message] = d @override def create_message( @@ -550,9 +584,12 @@ def __init__( self.topic = None self.category_id = 0 self.guild = guild or nextcord.utils.MISSING - self.messages = messages or [] self._type: Literal[0] = 0 - self._state = self.MockChannelState(message_number=len(self.messages)) + clean_messages = messages or [] + self._state = self.MockChannelState( + message_number=len(clean_messages), + messages=clean_messages, + ) self.assign_channel_to_guild(self.guild) self._overwrites = [] @@ -641,7 +678,7 @@ def history( after: SnowflakeTime | None = None, around: SnowflakeTime | None = None, oldest_first: bool | None = False, - ) -> nextcord.iterators.HistoryIterator: + ) -> AsyncIterator[nextcord.Message]: return MockHistoryIterator( self, limit, @@ -653,9 +690,7 @@ def history( @override async def send(self, *args: str | None, **kwargs: Any) -> nextcord.Message: - msg = await super().send(*args, **kwargs) - self.messages.append(msg) - return msg + return (await super().send(*args, **kwargs)) def assign_channel_to_guild(self, guild: nextcord.Guild) -> None: """ @@ -668,6 +703,18 @@ def assign_channel_to_guild(self, guild: nextcord.Guild) -> None: if guild and self not in guild.channels: guild.channels.append(self) + @property + def messages(self) -> list[nextcord.Message]: + """ + Return the channel's sent messages as a list. + + Returns: + list[nextcord.Message]: A list of messages. + + """ + assert self._state._messages is not None + return list(self._state._messages) + # TODO: Write message.edit() class MockMessage(nextcord.Message): @@ -947,7 +994,7 @@ async def create_role( fields["icon"] = await nextcord.utils.obj_to_base64_data(icon) data = await self._state.http.create_role( - self.id, reason=reason, **fields, + self.id, reason=reason, auth=None, retry_request=False, **fields, ) role = nextcord.Role(guild=self, data=data, state=self._state) self._roles[len(self.roles)] = role @@ -1133,7 +1180,7 @@ def history( after: SnowflakeTime | None = None, around: SnowflakeTime | None = None, oldest_first: bool | None = False, - ) -> nextcord.iterators.HistoryIterator: + ) -> AsyncIterator[nextcord.Message]: assert hasattr(self._state, "channel") return MockHistoryIterator( self._state.channel, @@ -1207,8 +1254,10 @@ def __init__(self, base_user: nextcord.User | None = None) -> None: @override async def edit( self, + *, username: str = "", avatar: IconTypes | None = None, + banner: IconTypes | None = None, ) -> nextcord.ClientUser: self.name = username or self.name self._avatar = str(avatar) @@ -1322,19 +1371,16 @@ def test_full_type_checking_with_mypy_for_code_quality() -> None: # Ignores certain false positives that expect the "Never" type. # TODO: After mypy issue is resolved, test against exit code. # https://github.com/LevBernstein/BeardlessBot/issues/49 - stdout, stderr, _exit_code = mypy([ + stdout, stderr, exit_code = mypy([ *FilesToCheck, "--strict", "--follow-untyped-imports", "--python-executable=" + sys.executable, f"--python-version={sys.version_info.major}.{sys.version_info.minor}", ]) - errors = [ - i for i in stdout.split("\n") - if ": error: " in i and "\"__call__\" of \"Command\"" not in i - ] - assert len(errors) == 0 + assert stdout == "Success: no issues found in 6 source files\n" assert not stderr + assert exit_code == 0 def test_no_spelling_errors_with_codespell_for_code_quality() -> None: @@ -1426,8 +1472,9 @@ async def test_on_command_error(caplog: pytest.LogCaptureFixture) -> None: guild, commands.errors.UnexpectedQuoteError, ) - emb = (await ctx.history().next()).embeds[0] - assert emb.title == "Careful with quotation marks!" + m = await latest_message(ctx.channel) + assert m is not None + assert m.embeds[0].title == "Careful with quotation marks!" @MarkAsync @@ -1561,7 +1608,9 @@ async def test_on_guild_join(caplog: pytest.LogCaptureFixture) -> None: ) g._state.user = MockUser(admin_powers=True) # type: ignore[assignment] await Bot.on_guild_join(g) - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.title == "Hello, Foo!" assert isinstance(emb.description, str) assert emb.description.startswith("Thanks for adding me to Foo!") @@ -1577,7 +1626,9 @@ async def test_on_guild_join(caplog: pytest.LogCaptureFixture) -> None: assert hasattr(u, "guild") assert u.guild == g await Bot.on_guild_join(g) - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.title == "I need admin perms!" assert emb.description == misc.AdminPermsReasons assert caplog.records[3].getMessage() == "Left Foo." @@ -1622,7 +1673,9 @@ async def test_on_message_delete() -> None: "**Deleted message sent by <@123456789>" " in **<#123456789>\ntestcontent" ) - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description assert await Bot.on_message_delete(MockMessage()) is None @@ -1638,7 +1691,9 @@ async def test_on_bulk_message_delete() -> None: log = logs.log_purge(messages[0], messages) assert emb.description == log.description assert log.description == "Purged 2 messages in <#123456789>." - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description messages = [m] * 105 emb = await Bot.on_bulk_message_delete(messages) @@ -1675,7 +1730,9 @@ async def test_on_reaction_clear() -> None: assert emb.fields[0].value is not None assert emb.fields[0].value.startswith(msg.content) assert emb.fields[1].value == "<:foo:0>, <:bar:0>" - assert (await ch.history().next()).embeds[0].description == emb.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == emb.description assert await Bot.on_reaction_clear( MockMessage(guild=MockGuild()), [reaction, other_reaction], @@ -1692,7 +1749,9 @@ async def test_on_guild_channel_delete() -> None: log = logs.log_delete_channel(new_channel) assert emb.description == log.description assert log.description == "Channel \"testchannelname\" deleted." - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description assert await Bot.on_guild_channel_delete( MockChannel(guild=MockGuild()), @@ -1709,7 +1768,9 @@ async def test_on_guild_channel_create() -> None: log = logs.log_create_channel(new_channel) assert emb.description == log.description assert log.description == "Channel \"testchannelname\" created." - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description assert await Bot.on_guild_channel_create( MockChannel(guild=MockGuild()), @@ -1726,7 +1787,9 @@ async def test_on_member_ban() -> None: log = logs.log_ban(member) assert emb.description == log.description assert log.description == "Member <@123456789> banned\ntestname" - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description assert await Bot.on_member_ban( MockGuild(), MockMember(guild=MockGuild()), @@ -1745,7 +1808,9 @@ async def test_on_member_unban() -> None: assert ( log.description == "Member <@123456789> unbanned\ntestname" ) - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description assert await Bot.on_member_unban( MockGuild(), MockMember(guild=MockGuild()), @@ -1765,7 +1830,9 @@ async def test_on_member_join() -> None: "Member <@123456789> joined\nAccount registered" f" on {misc.truncate_time(member)}\nID: 123456789" ) - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description assert await Bot.on_member_join(MockMember(guild=MockGuild())) is None @@ -1787,7 +1854,9 @@ async def test_on_member_remove() -> None: log = logs.log_member_remove(member) assert emb.description == log.description assert log.fields[0].value == "<@&123456789>" - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description assert await Bot.on_member_remove(MockMember(guild=MockGuild())) is None @@ -1805,7 +1874,9 @@ async def test_on_member_update() -> None: assert log.description == "Nickname of <@123456789> changed." assert log.fields[0].value == old.nick assert log.fields[1].value == new.nick - assert (await ch.history().next()).embeds[0].description == log.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == log.description r = MockRole() r.guild = guild @@ -1858,10 +1929,10 @@ async def test_on_message_edit() -> None: assert len(g.roles) == 2 assert g.roles[1].name == "Muted" # TODO: edit after to have content of len > 1024 via message.edit - h = ch.history() + h = [i async for i in ch.history()] assert not any(i.content == after.content for i in ch.messages) - assert (await h.next()).embeds[0].description == log.description - assert (await h.next()).content.startswith("Deleted possible") + assert h[0].embeds[0].description == log.description + assert h[1].content.startswith("Deleted possible") assert await Bot.on_message_edit(MockMessage(), MockMessage()) is None @@ -1882,7 +1953,9 @@ async def test_on_thread_join() -> None: assert emb.description == ( "Thread \"Foo\" created in parent channel <#0>." ) - assert (await ch.history().next()).embeds[0].description == emb.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == emb.description ch.name = "bar" assert await Bot.on_thread_join( @@ -1901,7 +1974,9 @@ async def test_on_thread_delete() -> None: assert emb.description == ( "Thread \"Foo\" deleted." ) - assert (await ch.history().next()).embeds[0].description == emb.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == emb.description ch.name = "bar" assert await Bot.on_thread_delete( @@ -1923,12 +1998,16 @@ async def test_on_thread_update() -> None: emb = await Bot.on_thread_update(before, after) assert emb is not None assert emb.description == "Thread \"Foo\" unarchived." - assert (await ch.history().next()).embeds[0].description == emb.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == emb.description emb = await Bot.on_thread_update(after, before) assert emb is not None assert emb.description == "Thread \"Foo\" archived." - assert (await ch.history().next()).embeds[0].description == emb.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == emb.description ch.name = "bar" th = MockThread(parent=ch, name="Foo") @@ -1950,7 +2029,9 @@ async def test_cmd_dice() -> None: assert emb.description.startswith( "Welcome to Beardless Bot dice, <@400005678>!", ) - assert (await ch.history().next()).embeds[0].description == emb.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == emb.description @MarkAsync @@ -1965,7 +2046,9 @@ async def test_cmd_bucks() -> None: assert emb.description.startswith( "BeardlessBucks are this bot's special currency.", ) - assert (await ch.history().next()).embeds[0].description == emb.description + latest = await latest_message(ch) + assert latest is not None + assert latest.embeds[0].description == emb.description @MarkAsync @@ -1977,12 +2060,16 @@ async def test_cmd_hello() -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr("random.choice", operator.itemgetter(0)) assert await Bot.cmd_hello(ctx) == 1 - assert (await ch.history().next()).content == "How ya doin'?" + latest = await latest_message(ch) + assert latest is not None + assert latest.content == "How ya doin'?" with pytest.MonkeyPatch.context() as mp: mp.setattr("random.choice", operator.itemgetter(5)) assert await Bot.cmd_hello(ctx) == 1 - assert (await ch.history().next()).content == "Hi!" + latest = await latest_message(ch) + assert latest is not None + assert latest.content == "Hi!" @MarkAsync @@ -1992,7 +2079,9 @@ async def test_cmd_source() -> None: Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), ) assert await Bot.cmd_source(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.title == "Beardless Bot Fun Facts" @@ -2003,7 +2092,9 @@ async def test_cmd_add() -> None: Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), ) assert await Bot.cmd_add(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] misc_emb = misc.Invite_Embed assert emb.title == misc_emb.title assert emb.description == misc_emb.description @@ -2027,7 +2118,9 @@ async def test_fact() -> None: mp.setattr("random.randint", lambda _, y: y) assert await Bot.cmd_fact(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description == first_fact assert emb.title == "Beardless Bot Fun Fact #111111111" @@ -2039,7 +2132,9 @@ async def test_cmd_animals() -> None: Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), ) assert await Bot.cmd_animals(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert len(emb.fields) == 9 assert emb.fields[0].value == ( "Can also do !dog breeds to see breeds you" @@ -2050,7 +2145,9 @@ async def test_cmd_animals() -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr("misc.AnimalList", ("frog", "cat")) assert await Bot.cmd_animals(ctx) == 1 - assert len((await ch.history().next()).embeds[0].fields) == 3 + latest = await latest_message(ch) + assert latest is not None + assert len(latest.embeds[0].fields) == 3 def test_tweet() -> None: @@ -2082,7 +2179,9 @@ async def test_cmd_tweet() -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr("misc.tweet", lambda: "foobar!") assert await Bot.cmd_tweet(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description == "\nfoobar" ctx = MockContext(Bot.BeardlessBot, guild=MockGuild()) @@ -2205,7 +2304,7 @@ def test_fetch_avatar_default() -> None: member = MockMember(MockUser(user_id=5000000, custom_avatar=False)) assert member.avatar is None assert member.default_avatar.url == ( - f"https://cdn.discordapp.com/embed/avatars/{member.id >> 22}.png" + f"https://cdn.discordapp.com/embed/avatars/{(member.id >> 22) - 1}.png" ) assert misc.fetch_avatar(member) == member.default_avatar.url @@ -2348,6 +2447,24 @@ async def test_define_valid(httpx_mock: HTTPXMock) -> None: assert word.description == "Audio: spam" +@MarkAsync +async def test_define_more_than_max_embed_fields( + httpx_mock: HTTPXMock, +) -> None: + excess_defns = [{"definition": "Foobar"}] * (misc.MaxEmbedFields + 5) + httpx_mock.add_response( + url="https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", + json=[{ + "word": "foo", + "phonetics": [], + "meanings": [{"definitions": excess_defns}], + }], + ) + word = await misc.define("foo") + assert word.title == "FOO" + assert len(word.fields) == misc.MaxEmbedFields + + @MarkAsync async def test_define_no_audio_has_blank_description( httpx_mock: HTTPXMock, @@ -2400,7 +2517,9 @@ async def test_cmd_define(httpx_mock: HTTPXMock) -> None: url="https://api.dictionaryapi.dev/api/v2/entries/en_US/f", json=resp, ) assert await Bot.cmd_define(ctx, words="f") == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] definition = await misc.define("f") assert emb.title == definition.title == "F" @@ -2411,7 +2530,9 @@ async def test_cmd_ping() -> None: ch = MockChannel(guild=MockGuild()) ctx = MockContext(Bot.BeardlessBot, channel=ch) assert await Bot.cmd_ping(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description == "Beardless Bot's latency is 25 ms." Bot.BeardlessBot._connection.user = None @@ -2489,14 +2610,17 @@ async def test_cmd_flip() -> None: ) Bot.BlackjackGames = [] assert await Bot.cmd_flip(ctx, bet="0") == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description is not None assert emb.description.endswith("actually bet anything.") Bot.BlackjackGames.append(bucks.BlackjackGame(bb, 10)) assert await Bot.cmd_flip(ctx, bet="0") == 1 - emb = (await ch.history().next()).embeds[0] - assert emb.description == bucks.FinMsg.format(f"<@{misc.BbId}>") + m = await latest_message(ch) + assert m is not None + assert m.embeds[0].description == bucks.FinMsg.format(f"<@{misc.BbId}>") def test_blackjack() -> None: @@ -2551,13 +2675,17 @@ async def test_cmd_blackjack() -> None: ) Bot.BlackjackGames = [] assert await Bot.cmd_blackjack(ctx, bet="all") == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description is not None assert emb.description.startswith("Your starting hand consists of") Bot.BlackjackGames.append(bucks.BlackjackGame(bb, 10)) assert await Bot.cmd_blackjack(ctx, bet="0") == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description is not None assert emb.description == bucks.FinMsg.format(f"<@{misc.BbId}>") @@ -2579,20 +2707,24 @@ async def test_cmd_deal() -> None: Bot.BeardlessBot, MockMessage("!hit"), ch, bb, MockGuild(), ) assert await Bot.cmd_deal(ctx) == 1 - emb = (await ch.history().next()).embeds[0] - assert emb.description == bucks.CommaWarn.format(f"<@{misc.BbId}>") + m = await latest_message(ch) + assert m is not None + assert m.embeds[0].description == bucks.CommaWarn.format(f"<@{misc.BbId}>") bb._user.name = "Beardless Bot" assert await Bot.cmd_deal(ctx) == 1 - emb = (await ch.history().next()).embeds[0] - assert emb.description == bucks.NoGameMsg.format(f"<@{misc.BbId}>") + m = await latest_message(ch) + assert m is not None + assert m.embeds[0].description == bucks.NoGameMsg.format(f"<@{misc.BbId}>") game = bucks.BlackjackGame(bb, 0) game.hand = [2, 2] Bot.BlackjackGames = [] Bot.BlackjackGames.append(game) assert await Bot.cmd_deal(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert len(game.hand) == 3 assert emb.description is not None assert emb.description.startswith("You were dealt") @@ -2602,7 +2734,9 @@ async def test_cmd_deal() -> None: Bot.BlackjackGames = [] Bot.BlackjackGames.append(game) assert await Bot.cmd_deal(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description is not None assert f"You busted. Game over, <@{misc.BbId}>." in emb.description assert len(Bot.BlackjackGames) == 0 @@ -2614,7 +2748,9 @@ async def test_cmd_deal() -> None: mp.setattr("bucks.BlackjackGame.perfect", lambda _: True) mp.setattr("bucks.BlackjackGame.check_bust", lambda _: False) assert await Bot.cmd_deal(ctx) == 1 - emb = (await ch.history().next()).embeds[0] + m = await latest_message(ch) + assert m is not None + emb = m.embeds[0] assert emb.description is not None assert f"You hit 21! You win, <@{misc.BbId}>!" in emb.description assert len(Bot.BlackjackGames) == 0 @@ -2655,6 +2791,23 @@ def test_blackjack_check_bust() -> None: assert not game.check_bust() +@MarkAsync +async def test_cmd_stay() -> None: + Bot.BlackjackGames = [] + bb = MockMember( + MockUser("Beardless,Bot", discriminator="5757", user_id=misc.BbId), + ) + ch = MockChannel(guild=MockGuild()) + ctx = MockContext( + Bot.BeardlessBot, MockMessage("!stay"), ch, bb, MockGuild(), + ) + assert await Bot.cmd_stay(ctx) == 1 + m = await latest_message(ch) + assert m is not None + assert m.embeds[0].description == bucks.CommaWarn.format(f"<@{misc.BbId}>") + # TODO: other branches + + def test_blackjack_stay() -> None: game = bucks.BlackjackGame(MockMember(), 0) game.hand = [10, 10, 1] @@ -2711,20 +2864,35 @@ def test_info() -> None: assert misc.info("!infoerror", text).title == "Invalid target!" -def test_avatar() -> None: +@MarkAsync +async def test_cmd_av() -> None: m = MockMember(MockUser("searchterm")) guild = MockGuild(members=[MockMember(), m]) text = MockMessage("!av searchterm", guild=guild) + ch = text.channel + assert isinstance(ch, nextcord.TextChannel) + ctx = MockContext( + Bot.BeardlessBot, text, ch, m, MockGuild(), + ) avatar = str(misc.fetch_avatar(m)) - assert misc.avatar("searchterm", text).image.url == avatar + emb = misc.avatar("searchterm", text) + assert emb.image.url == avatar + assert await Bot.cmd_av(ctx, target="searchterm") == 1 + latest = await latest_message(ch) + assert latest is not None + assert emb.image.url == latest.embeds[0].image.url - assert misc.avatar("error", text).title == "Invalid target!" + emb = misc.avatar("error", text) + assert emb.title == "Invalid target!" - assert misc.avatar(m, text).image.url == avatar + emb = misc.avatar(m, text) + assert emb.image.url == avatar text.guild = None + ctx.guild = None text.author = m - assert misc.avatar("searchterm", text).image.url == avatar + emb = misc.avatar("searchterm", text) + assert emb.image.url == avatar @MarkAsync @@ -2745,13 +2913,13 @@ async def test_bb_help_command(caplog: pytest.LogCaptureFixture) -> None: author.guild_permissions = nextcord.Permissions(manage_messages=False) await help_command.send_bot_help({}) - h = ch.history() - assert len((await h.next()).embeds[0].fields) == 17 - assert len((await h.next()).embeds[0].fields) == 20 - assert len((await h.next()).embeds[0].fields) == 15 + h = [i async for i in ch.history()] + assert len(h[2].embeds[0].fields) == 17 + assert len(h[1].embeds[0].fields) == 20 + assert len(h[0].embeds[0].fields) == 15 help_command.context.message.type = nextcord.MessageType.thread_created - assert await help_command.send_bot_help({}) == -1 + await help_command.send_bot_help({}) # For the time being, just pass on all invalid help calls; # don't send any messages. @@ -2795,7 +2963,9 @@ async def test_search_valid(searchterm: str) -> None: Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), ) assert await Bot.cmd_search(ctx, searchterm=searchterm) == 1 - url = (await ch.history().next()).embeds[0].description + latest = await latest_message(ch) + assert latest is not None + url = latest.embeds[0].description assert isinstance(url, str) async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url) @@ -2813,7 +2983,9 @@ async def test_search_empty_argument_redirects_to_home() -> None: Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), ) assert await Bot.cmd_search(ctx, searchterm="") == 1 - url = (await ch.history().next()).embeds[0].description + latest = await latest_message(ch) + assert latest is not None + url = latest.embeds[0].description assert isinstance(url, str) async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url, follow_redirects=True) @@ -2973,30 +3145,44 @@ async def test_handle_messages() -> None: ) u._user.bot = False assert len(u.roles) == 1 - ch.messages = [m] - msg = await ch.history().next() - assert msg.content == "http://dizcort.com free nitro!" + assert ch._state._messages is not None + assert len(list(ch.messages)) == 1 + assert len(list(ch._state._messages)) == 1 + assert ch.messages[0] == m + latest = await latest_message(ch) + assert latest is not None + assert latest.content == ( + "http://dizcort.com free nitro!" + ) + assert (await latest_message(infractions)) is None assert len(list(infractions.messages)) == 0 + assert len(list(ch.messages)) == 1 + assert len(list(ch._state._messages)) == 1 + assert m in ch._state._messages assert await Bot.handle_messages(m) == -1 assert len(u.roles) == 2 assert len(list(ch.messages)) == 1 - msg = await ch.history().next() - assert msg.content == ( + assert len(list(ch._state._messages)) == 1 + latest = await latest_message(ch) + assert latest is not None + assert latest.content == ( "**Deleted possible nitro scam link. Alerting mods.**" ) assert ch._state._messages is not None assert m not in ch._state._messages + assert m not in ch.messages assert len(list(infractions.messages)) == 1 - msg = await infractions.history().next() + msg = await latest_message(infractions) + assert msg is not None assert isinstance(msg.content, str) assert msg.content.startswith( "Deleted possible scam nitro link sent by <@999999999>", ) - assert (await m.author.history().next()).content.startswith( - "This is an automated message.", - ) + dm = await latest_message(await m.author._get_channel()) + assert dm is not None + assert dm.content.startswith("This is an automated message.") @MarkAsync @@ -3008,7 +3194,7 @@ async def test_cmd_guide() -> None: assert ctx.guild is not None ctx.guild.id = Bot.EggGuildId assert await Bot.cmd_guide(ctx) == 1 - assert (await ctx.history().next()).embeds[0].title == ( + assert [i async for i in ctx.history()][-1].embeds[0].title == ( "The Eggsoup Improvement Guide" ) @@ -3022,7 +3208,7 @@ async def test_cmd_reddit() -> None: assert ctx.guild is not None ctx.guild.id = Bot.EggGuildId assert await Bot.cmd_reddit(ctx) == 1 - assert (await ctx.history().next()).embeds[0].title == ( + assert [i async for i in ctx.history()][-1].embeds[0].title == ( "The Official Eggsoup Subreddit" ) @@ -3183,14 +3369,14 @@ async def test_cmd_pins() -> None: ctx.guild = MockGuild() assert (await Bot.cmd_pins(ctx)) == 0 - assert (await ctx.history().next()).embeds[0].title == ( + assert [i async for i in ctx.history()][-1].embeds[0].title == ( f"Try using !spar in the {misc.SparChannelName} channel." ) assert hasattr(ctx.channel, "name") ctx.channel.name = misc.SparChannelName assert (await Bot.cmd_pins(ctx)) == 1 - assert (await ctx.history().next()).embeds[0].title == ( + assert [i async for i in ctx.history()][-1].embeds[0].title == ( "How to use this channel." ) @@ -3346,7 +3532,7 @@ async def test_get_rank_unclaimed() -> None: @MarkAsync -@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +@pytest.mark.httpx_mock async def test_get_brawl_id_returns_none_when_never_played( httpx_mock: HTTPXMock, ) -> None: @@ -3354,19 +3540,16 @@ async def test_get_brawl_id_returns_none_when_never_played( url="https://api.brawlhalla.com/search?steamid=37&api_key=foo", json=[], ) - assert await brawl.brawl_api_call( - "search?steamid=", "37", "foo", "&", - ) == [] with pytest.MonkeyPatch.context() as mp: - mp.setattr("steam.steamid.SteamID.from_url", lambda _: "37") + mp.setattr("steam.steamid.from_url", lambda _: "37") assert await brawl.get_brawl_id("foo", "foo.bar") is None @MarkAsync async def test_get_brawl_id_returns_none_when_id_is_invalid() -> None: with pytest.MonkeyPatch.context() as mp: - mp.setattr("steam.steamid.SteamID.from_url", lambda _: None) + mp.setattr("steam.steamid.from_url", lambda _: None) assert await brawl.get_brawl_id("foo", "foo.bar") is None diff --git a/brawl.py b/brawl.py index 3967afb..e779cfc 100644 --- a/brawl.py +++ b/brawl.py @@ -77,7 +77,7 @@ def get_brawl_data() -> dict[ r = httpx.get("https://www.brawlhalla.com/legends", timeout=10) soup = BeautifulSoup(r.content.decode("utf-8"), "html.parser") brawl_dict = json.loads( - json.loads(soup.findAll("script")[3].contents[0])["body"], + json.loads(soup.find_all("script")[3].contents[0])["body"], )["data"] assert isinstance(brawl_dict, dict) return brawl_dict @@ -337,11 +337,11 @@ async def get_rank(target: Member | User, brawl_key: str) -> Embed: "Beardless Bot Brawlhalla Rank", "You haven't played ranked yet this season.", ).set_footer(text=f"Brawl ID {brawl_id}").set_author( - name=target, icon_url=fetch_avatar(target), + name=target.name, icon_url=fetch_avatar(target), ) emb = bb_embed(f"{r["name"]}, {r["region"]}").set_footer( text=f"Brawl ID {brawl_id}", - ).set_author(name=target, icon_url=fetch_avatar(target)) + ).set_author(name=target.name, icon_url=fetch_avatar(target)) if "games" in r and r["games"] != 0: emb = get_ones_rank(emb, r) if "2v2" in r and len(r["2v2"]) != 0: @@ -427,7 +427,7 @@ async def get_stats(target: Member | User, brawl_key: str) -> Embed: text=f"Brawl ID {brawl_id}", ).add_field(name="Name", value=r["name"]).add_field( name="Overall W/L", value=win_loss, - ).set_author(name=target, icon_url=fetch_avatar(target)) + ).set_author(name=target.name, icon_url=fetch_avatar(target)) if "legends" in r: most_used, top_winrate, top_dps, lowest_ttk = get_top_legend_stats( r["legends"], diff --git a/bucks.py b/bucks.py index 413af9d..9479139 100644 --- a/bucks.py +++ b/bucks.py @@ -489,13 +489,15 @@ def leaderboard( ) emb.add_field( name=f"{i + 1}. {head.split("#")[0]}", - value=body, + value=str(body), inline=last_entry, ) if target and pos: assert not isinstance(target, str) - emb.add_field(name=f"{target.name}'s position:", value=pos) - emb.add_field(name=f"{target.name}'s balance:", value=target_balance) + emb.add_field(name=f"{target.name}'s position:", value=str(pos)) + emb.add_field( + name=f"{target.name}'s balance:", value=str(target_balance), + ) return emb diff --git a/logs.py b/logs.py index 7692972..551a213 100644 --- a/logs.py +++ b/logs.py @@ -23,7 +23,10 @@ def log_delete_msg(message: nextcord.Message) -> nextcord.Embed: value=f"{prefix}{content_check(message, len(prefix))}", col=0xFF0000, show_time=True, - ).set_author(name=message.author, icon_url=fetch_avatar(message.author)) + ).set_author( + name=message.author.name, + icon_url=fetch_avatar(message.author), + ) def log_purge( @@ -58,7 +61,7 @@ def log_edit_msg( 0xFFFF00, show_time=True, ).set_author( - name=before.author, icon_url=fetch_avatar(before.author), + name=before.author.name, icon_url=fetch_avatar(before.author), ).add_field( name="Before:", value=content_check(before, 7), inline=False, ).add_field( @@ -80,7 +83,7 @@ def log_clear_reacts( 0xFF0000, show_time=True, ).set_author( - name=message.author, icon_url=fetch_avatar(message.author), + name=message.author.name, icon_url=fetch_avatar(message.author), ).add_field( name="Message content:", value=content_check(message, len(jump_link)) + jump_link, @@ -135,10 +138,16 @@ def log_member_nick_change( return bb_embed( "", f"Nickname of {after.mention} changed.", 0xFFFF00, show_time=True, ).set_author( - name=after, icon_url=fetch_avatar(after), + name=after.name, icon_url=fetch_avatar(after), ).add_field( - name="Before:", value=before.nick, inline=False, - ).add_field(name="After:", value=after.nick, inline=False) + name="Before:", + value=before.nick if before.nick else before.name, + inline=False, + ).add_field( + name="After:", + value=after.nick if after.nick else after.name, + inline=False, + ) def log_member_roles_change( @@ -154,7 +163,7 @@ def log_member_roles_change( value=f"Role {role.mention} {verb} {after.mention}.", col=color, show_time=True, - ).set_author(name=after, icon_url=fetch_avatar(after)) + ).set_author(name=after.name, icon_url=fetch_avatar(after)) def log_ban(member: nextcord.Member) -> nextcord.Embed: @@ -187,7 +196,10 @@ def log_mute( f"Muted {member.mention}{mid} in {message.channel.mention}.", 0xFF0000, show_time=True, - ).set_author(name=message.author, icon_url=fetch_avatar(message.author)) + ).set_author( + name=message.author.name, + icon_url=fetch_avatar(message.author), + ) def log_unmute( @@ -198,7 +210,7 @@ def log_unmute( f"Unmuted {member.mention}.", 0x00FF00, show_time=True, - ).set_author(name=author, icon_url=fetch_avatar(author)) + ).set_author(name=author.name, icon_url=fetch_avatar(author)) def log_create_thread(thread: nextcord.Thread) -> nextcord.Embed: diff --git a/misc.py b/misc.py index f7bfb53..a0fc73f 100644 --- a/misc.py +++ b/misc.py @@ -376,10 +376,10 @@ def get_frog_list() -> list[str]: ) soup = BeautifulSoup(r.content.decode("utf-8"), "html.parser") try: - j = loads(soup.findAll("script")[-1].text)["payload"] + j = loads(soup.find_all("script")[-1].text)["payload"] except KeyError: j = loads( - soup.findAll("script")[-2].text.replace("\\", "\\\\"), + soup.find_all("script")[-2].text.replace("\\", "\\\\"), )["payload"] return [i["name"] for i in j["tree"]["items"]] @@ -567,7 +567,7 @@ def info( emb = bb_embed( value=activity, col=member.color, ).set_author( - name=member, icon_url=fetch_avatar(member), + name=member.name, icon_url=fetch_avatar(member), ).set_thumbnail( url=fetch_avatar(member), ).add_field( @@ -606,7 +606,7 @@ def avatar( return bb_embed( col=member.color, ).set_image(url=fetch_avatar(member)).set_author( - name=member, icon_url=fetch_avatar(member), + name=member.name, icon_url=fetch_avatar(member), ) return InvalidTargetEmbed @@ -651,12 +651,12 @@ def __init__(self) -> None: @override async def send_bot_help( self, - _mapping: Mapping[ + _mapping: Mapping[ # type: ignore[type-arg] commands.Cog | None, list[commands.core.Command[Any, Any, Any]], ], - ) -> int: + ) -> None: if ctx_created_thread(self.context): - return -1 + return if not self.context.guild: commands_to_display = 15 elif ( @@ -730,10 +730,7 @@ async def send_bot_help( emb = bb_embed("Beardless Bot Commands") for command, description in command_list[:commands_to_display]: emb.add_field(name=command, value=description) - await self.get_destination().send( # type: ignore[no-untyped-call] - embed=emb, - ) - return 1 + await self.context.channel.send(embed=emb) @override async def send_error_message(self, error: str) -> None: @@ -894,7 +891,13 @@ def tweet() -> str: The below Markov code was originally provided by CSTUY SHIP for use in another project; I have since migrated it to Python3 and made various other improvements, including adding type annotations, the walrus - operator, the ternary operator, and other simplification. + operator, the ternary operator, and other simplifications. + + If I may get on my soapbox a moment: This is not an LLM or anything + approaching machine learning. It is also certainly not "AI." That does + raise the question, of course: if this block of code that is clearly not + intelligent can generate almost-coherent prose, what does that say about + the "intelligence" of LLM-based "AI" tools? But I digress. Returns: str: A fake eggsoup tweet. diff --git a/resources/images/tests.svg b/resources/images/tests.svg index 4e58d7c..92cbe0b 100644 --- a/resources/images/tests.svg +++ b/resources/images/tests.svg @@ -1 +1 @@ -tests: 157/158tests157/158 \ No newline at end of file +tests: 160tests160 \ No newline at end of file diff --git a/resources/requirements.txt b/resources/requirements.txt index 6081351..03ab2ce 100644 --- a/resources/requirements.txt +++ b/resources/requirements.txt @@ -1,5 +1,5 @@ aiofiles==24.1.0 -aiohttp==3.12.15 +aiohttp==3.13.0 audioop-lts==0.2.2; python_version>="3.13" beautifulsoup4==4.14.2 codespell==2.4.1 @@ -10,15 +10,16 @@ flake8-comprehensions==3.17.0 genbadge[all]==1.1.2 httpx==0.28.1 mypy[faster-cache, reports]==1.18.2 -nextcord==2.6.0 +nextcord==3.1.1 pytest==8.4.2 pytest-asyncio==1.2.0 pytest-github-actions-annotate-failures==0.3.0 pytest-httpx==0.35.0 python-dotenv==1.1.1 requests==2.32.5 -ruff==0.13.3 +ruff==0.14.0 steam==1.4.4 types-aiofiles==24.1.0.20250822 types-beautifulsoup4==4.12.0.20250516 -types-requests==2.32.4.20250913 \ No newline at end of file +types-requests==2.32.4.20250913 +types-flake8==7.3.0.20250622