From f4cf8c70b537118312267cba5efb5b9fccb6cd39 Mon Sep 17 00:00:00 2001 From: Lev Bernstein Date: Sat, 31 Aug 2024 10:52:04 -0400 Subject: [PATCH] 2.4.0: asyncify numerous methods, quality improvements --- Bot.py | 314 ++++----- README.MD | 4 +- bb_test.py | 1290 ++++++++++++++++++++++-------------- brawl.py | 152 +++-- bucks.py | 93 ++- logs.py | 12 +- misc.py | 256 +++---- resources/images/tests.svg | 2 +- resources/requirements.txt | 7 +- unitTests.sh | 3 +- 10 files changed, 1234 insertions(+), 899 deletions(-) diff --git a/Bot.py b/Bot.py index d5ef7e7..3f8f2f2 100644 --- a/Bot.py +++ b/Bot.py @@ -1,6 +1,6 @@ """Beardless Bot""" -with open("README.MD") as f: - __version__ = " ".join(f.read().split(" ")[3:6]) +with open("README.MD") as rd: + __version__ = " ".join(rd.read().split(" ")[3:6]) import asyncio import logging @@ -8,7 +8,7 @@ from random import choice, randint from sys import stdout from time import time -from typing import Dict, Final, List, Optional, Union, no_type_check +from typing import Final, Optional, Union import aiofiles import nextcord @@ -22,10 +22,10 @@ import misc # This dictionary is for keeping track of pings in the lfs channels. -sparPings: Dict[int, Dict[str, int]] = {} +sparPings: dict[int, dict[str, int]] = {} # This array stores the active instances of blackjack. -games: List[bucks.BlackjackGame] = [] +games: list[bucks.BlackjackGame] = [] # Replace OWNER_ID with your Discord user id OWNER_ID: Final[int] = 196354892208537600 @@ -127,7 +127,9 @@ async def on_guild_join(guild: nextcord.Guild) -> None: @bot.event -async def on_message_delete(msg: nextcord.Message) -> Optional[nextcord.Embed]: +async def on_message_delete( + msg: nextcord.Message +) -> Optional[nextcord.Embed]: if msg.guild and ( msg.channel.name != "bb-log" or msg.content # type: ignore ): @@ -140,7 +142,7 @@ async def on_message_delete(msg: nextcord.Message) -> Optional[nextcord.Embed]: @bot.event async def on_bulk_message_delete( - msgList: List[nextcord.Message] + msgList: list[nextcord.Message] ) -> Optional[nextcord.Embed]: assert msgList[0].guild is not None if channel := misc.getLogChannel(msgList[0].guild): @@ -153,7 +155,7 @@ async def on_bulk_message_delete( @bot.event async def on_message_edit( before: nextcord.Message, after: nextcord.Message -) -> Union[int, nextcord.Embed, None]: +) -> Optional[nextcord.Embed]: if after.guild and (before.content != after.content): assert isinstance( after.channel, (nextcord.TextChannel, nextcord.Thread) @@ -162,6 +164,9 @@ async def on_message_edit( logging.info("Possible nitro scam detected in " + str(after.guild)) if not (role := get(after.guild.roles, name="Muted")): role = await misc.createMutedRole(after.guild) + # TODO: after migrating from MockUser to MockMember, + # add assert not isinstance(after.author, nextcord.User) + # and remove below type ignore await after.author.add_roles(role) # type: ignore for channel in after.guild.text_channels: if channel.name in ("infractions", "bb-log"): @@ -184,7 +189,7 @@ async def on_message_edit( @bot.event async def on_reaction_clear( - msg: nextcord.Message, reactions: List[nextcord.Reaction] + msg: nextcord.Message, reactions: list[nextcord.Reaction] ) -> Optional[nextcord.Embed]: assert msg.guild is not None if channel := misc.getLogChannel(msg.guild): @@ -318,7 +323,7 @@ async def on_thread_update( @bot.command(name="flip") # type: ignore -async def cmdFlip(ctx: commands.Context, bet: str = "10", *args) -> int: +async def cmdFlip(ctx: misc.botContext, bet: str = "10", *args) -> int: if misc.ctxCreatedThread(ctx): return -1 if bucks.activeGame(games, ctx.author): @@ -330,7 +335,7 @@ async def cmdFlip(ctx: commands.Context, bet: str = "10", *args) -> int: @bot.command(name="blackjack", aliases=("bj",)) # type: ignore -async def cmdBlackjack(ctx: commands.Context, bet="10", *args) -> int: +async def cmdBlackjack(ctx: misc.botContext, bet="10", *args) -> int: if misc.ctxCreatedThread(ctx): return -1 if bucks.activeGame(games, ctx.author): @@ -344,7 +349,7 @@ async def cmdBlackjack(ctx: commands.Context, bet="10", *args) -> int: @bot.command(name="deal", aliases=("hit",)) # type: ignore -async def cmdDeal(ctx: commands.Context, *args) -> int: +async def cmdDeal(ctx: misc.botContext, *args) -> int: if misc.ctxCreatedThread(ctx): return -1 if "," in ctx.author.name: @@ -362,7 +367,7 @@ async def cmdDeal(ctx: commands.Context, *args) -> int: @bot.command(name="stay", aliases=("stand",)) # type: ignore -async def cmdStay(ctx: commands.Context) -> int: +async def cmdStay(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 if "," in ctx.author.name: @@ -384,72 +389,76 @@ async def cmdStay(ctx: commands.Context) -> int: return 1 -@no_type_check -@bot.command(name="av", aliases=("avatar",)) -async def cmdAv(ctx: commands.Context, *target) -> int: +@bot.command(name="av", aliases=("avatar",)) # type: ignore +async def cmdAv(ctx: misc.botContext, *target) -> int: if misc.ctxCreatedThread(ctx): return -1 + avTarget: Union[nextcord.Member, nextcord.User, str] if ctx.message.mentions: - target = ctx.message.mentions[0] + avTarget = ctx.message.mentions[0] elif target: - target = " ".join(target) + avTarget = " ".join(target) else: - target = ctx.author - await ctx.send(embed=misc.av(target, ctx.message)) + avTarget = ctx.author + await ctx.send(embed=misc.av(avTarget, ctx.message)) return 1 -@no_type_check -@bot.command(name="info") -async def cmdInfo(ctx: commands.Context, *target) -> int: +@bot.command(name="info") # type: ignore +async def cmdInfo(ctx: misc.botContext, *target) -> int: if not ctx.guild: return 0 if misc.ctxCreatedThread(ctx): return -1 + infoTarget: Union[nextcord.Member, str] if ctx.message.mentions: - target = ctx.message.mentions[0] + assert isinstance(ctx.message.mentions[0], nextcord.Member) + infoTarget = ctx.message.mentions[0] elif target: - target = " ".join(target) + infoTarget = " ".join(target) else: - target = ctx.author - await ctx.send(embed=misc.info(target, ctx.message)) + assert isinstance(ctx.author, nextcord.Member) + infoTarget = ctx.author + await ctx.send(embed=misc.info(infoTarget, ctx.message)) return 1 -@no_type_check -@bot.command(name="balance", aliases=("bal",)) -async def cmdBalance(ctx: commands.Context, *target) -> int: +@bot.command(name="balance", aliases=("bal",)) # type: ignore +async def cmdBalance(ctx: misc.botContext, *target) -> int: if misc.ctxCreatedThread(ctx): return -1 + balTarget: Union[nextcord.Member, nextcord.User, str] if ctx.message.mentions: - target = ctx.message.mentions[0] + balTarget = ctx.message.mentions[0] elif target: - target = " ".join(target) + balTarget = " ".join(target) else: - target = ctx.author - await ctx.send(embed=bucks.balance(target, ctx.message)) + balTarget = ctx.author + await ctx.send(embed=bucks.balance(balTarget, ctx.message)) return 1 -@no_type_check -@bot.command(name="leaderboard", aliases=("leaderboards", "lb")) +@bot.command( # type: ignore + name="leaderboard", aliases=("leaderboards", "lb") +) async def cmdLeaderboard( - ctx: commands.Context, *target + ctx: misc.botContext, *target ) -> int: if misc.ctxCreatedThread(ctx): return -1 + lbTarget: Union[nextcord.Member, nextcord.User, str] if ctx.message.mentions: - target = ctx.message.mentions[0] + lbTarget = ctx.message.mentions[0] elif target: - target = " ".join(target) + lbTarget = " ".join(target) else: - target = ctx.author - await ctx.send(embed=bucks.leaderboard(target, ctx.message)) + lbTarget = ctx.author + await ctx.send(embed=bucks.leaderboard(lbTarget, ctx.message)) return 1 @bot.command(name="dice") # type: ignore -async def cmdDice(ctx: commands.Context) -> Union[int, nextcord.Embed]: +async def cmdDice(ctx: misc.botContext) -> Union[int, nextcord.Embed]: if misc.ctxCreatedThread(ctx): return -1 emb = misc.bbEmbed("Beardless Bot Dice", misc.diceMsg) @@ -458,7 +467,7 @@ async def cmdDice(ctx: commands.Context) -> Union[int, nextcord.Embed]: @bot.command(name="reset") # type: ignore -async def cmdReset(ctx: commands.Context) -> int: +async def cmdReset(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(embed=bucks.reset(ctx.author)) @@ -466,7 +475,7 @@ async def cmdReset(ctx: commands.Context) -> int: @bot.command(name="register") # type: ignore -async def cmdRegister(ctx: commands.Context) -> int: +async def cmdRegister(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(embed=bucks.register(ctx.author)) @@ -474,7 +483,7 @@ async def cmdRegister(ctx: commands.Context) -> int: @bot.command(name="bucks") # type: ignore -async def cmdBucks(ctx: commands.Context) -> int: +async def cmdBucks(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(embed=misc.bbEmbed("BeardlessBucks", bucks.buckMsg)) @@ -482,7 +491,7 @@ async def cmdBucks(ctx: commands.Context) -> int: @bot.command(name="hello", aliases=("hi",)) # type: ignore -async def cmdHello(ctx: commands.Context) -> int: +async def cmdHello(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(choice(misc.greetings)) @@ -490,7 +499,7 @@ async def cmdHello(ctx: commands.Context) -> int: @bot.command(name="source") # type: ignore -async def cmdSource(ctx: commands.Context) -> int: +async def cmdSource(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 source = ( @@ -502,7 +511,7 @@ async def cmdSource(ctx: commands.Context) -> int: @bot.command(name="add", aliases=("join", "invite")) # type: ignore -async def cmdAdd(ctx: commands.Context) -> int: +async def cmdAdd(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(embed=misc.inviteMsg) @@ -510,7 +519,7 @@ async def cmdAdd(ctx: commands.Context) -> int: @bot.command(name="rohan") # type: ignore -async def cmdRohan(ctx: commands.Context) -> int: +async def cmdRohan(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(file=nextcord.File("resources/images/cute.png")) @@ -519,16 +528,17 @@ async def cmdRohan(ctx: commands.Context) -> int: @bot.command(name="random") # type: ignore async def cmdRandomBrawl( - ctx: commands.Context, ranType: str = "None", *args + ctx: misc.botContext, ranType: str = "None", *args ) -> int: if misc.ctxCreatedThread(ctx): return -1 - await ctx.send(embed=brawl.randomBrawl(ranType.lower(), brawlKey)) + emb = await brawl.randomBrawl(ranType.lower(), brawlKey) + await ctx.send(embed=emb) return 1 @bot.command(name="fact") # type: ignore -async def cmdFact(ctx: commands.Context) -> int: +async def cmdFact(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send( @@ -540,7 +550,7 @@ async def cmdFact(ctx: commands.Context) -> int: @bot.command(name="animals", aliases=("animal", "pets")) # type: ignore -async def cmdAnimals(ctx: commands.Context) -> int: +async def cmdAnimals(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(embed=misc.animals) @@ -548,11 +558,12 @@ async def cmdAnimals(ctx: commands.Context) -> int: @bot.command(name="define") # type: ignore -async def cmdDefine(ctx: commands.Context, *words) -> int: +async def cmdDefine(ctx: misc.botContext, *words) -> int: if misc.ctxCreatedThread(ctx): return -1 try: - await ctx.send(embed=misc.define(" ".join(words))) + emb = await misc.define(" ".join(words)) + await ctx.send(embed=emb) except Exception as e: await ctx.send( "The API I use to get definitions is experiencing server outages" @@ -564,7 +575,7 @@ async def cmdDefine(ctx: commands.Context, *words) -> int: @bot.command(name="ping") # type: ignore -async def cmdPing(ctx: commands.Context) -> int: +async def cmdPing(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 assert bot.user is not None @@ -576,7 +587,7 @@ async def cmdPing(ctx: commands.Context) -> int: @bot.command(name="roll") # type: ignore -async def cmdRoll(ctx: commands.Context, dice: str = "None", *args) -> int: +async def cmdRoll(ctx: misc.botContext, dice: str = "None", *args) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(embed=misc.rollReport(dice, ctx.author)) @@ -585,7 +596,7 @@ async def cmdRoll(ctx: commands.Context, dice: str = "None", *args) -> int: @bot.command(name="dog", aliases=misc.animalList + ("moose",)) # type: ignore async def cmdAnimal( - ctx: commands.Context, breed: Optional[str] = None, *args + ctx: misc.botContext, breed: Optional[str] = None, *args ) -> int: if misc.ctxCreatedThread(ctx): return -1 @@ -595,41 +606,47 @@ async def cmdAnimal( breed = breed.lower() if "moose" in (species, breed): try: - moose = misc.animal("moose", "moose") + moose = await misc.animal("moose", "moose") except Exception as e: misc.logException(e, ctx) - emb = misc.bbEmbed( - "Something's gone wrong with the Moose API!", - "Please inform my creator and he'll see what's going on." + await ctx.send( + embed=misc.bbEmbed( + "Something's gone wrong with the Moose API!", + "Please inform my creator and he'll see what's going on." + ) ) - else: - emb = misc.bbEmbed("Random Moose").set_image(url=moose) + return 0 + emb = misc.bbEmbed("Random Moose").set_image(url=moose) await ctx.send(embed=emb) - return 0 + return 1 if species == "dog": try: - dogUrl = misc.animal("dog", breed) + dogUrl = await misc.animal("dog", breed) except Exception as e: logging.exception(f"{species} {breed} {e}") - emb = misc.bbEmbed( - "Something's gone wrong with the Dog API!", - "Please inform my creator and he'll see what's going on." + misc.logException(e, ctx) + await ctx.send( + embed=misc.bbEmbed( + "Something's gone wrong with the Dog API!", + "Please inform my creator and he'll see what's going on." + ) ) - else: - if any(dogUrl.startswith(s) for s in ("Breed", "Dog")): - await ctx.send(dogUrl) - return 1 - dogBreed = "Hound" if "hound" in dogUrl else dogUrl.split("/")[-2] - emb = misc.bbEmbed( - "Random " + dogBreed.title() - ).set_image(url=dogUrl) + return 0 + if dogUrl.startswith("Dog Breeds: "): + await ctx.send(dogUrl) + return 1 + dogBreed = "Hound" if "hound" in dogUrl else dogUrl.split("/")[-2] + emb = misc.bbEmbed( + "Random " + dogBreed.title() + ).set_image(url=dogUrl) await ctx.send(embed=emb) return 1 - titlemod = " Animal" if species == "zoo" else "" - emb = misc.bbEmbed("Random " + species.title() + titlemod) + emb = misc.bbEmbed("Random " + species.title()) try: - emb.set_image(url=misc.animal(species)) + url = await misc.animal(species) + emb.set_image(url=url) except Exception as e: + logging.exception(f"{species} {breed} {e}") misc.logException(e, ctx) await ctx.send( embed=misc.bbEmbed( @@ -645,10 +662,9 @@ async def cmdAnimal( # Server-only commands (not usable in DMs): -@no_type_check -@bot.command(name="mute") +@bot.command(name="mute") # type: ignore async def cmdMute( - ctx: commands.Context, + ctx: misc.botContext, target: Optional[str] = None, duration: Optional[str] = None, *args @@ -657,7 +673,7 @@ async def cmdMute( return 0 if misc.ctxCreatedThread(ctx): return -1 - if not ctx.author.guild_permissions.manage_messages: + if not ctx.author.guild_permissions.manage_messages: # type: ignore await ctx.send(misc.naughty.format(ctx.author.mention)) return 0 if not target: @@ -666,7 +682,7 @@ async def cmdMute( # TODO: switch to converter in arg converter = commands.MemberConverter() try: - target = await converter.convert(ctx, target) + muteTarget = await converter.convert(ctx, target) except commands.MemberNotFound as e: misc.logException(e, ctx) await ctx.send( @@ -677,7 +693,8 @@ async def cmdMute( ) ) return 0 - if target.id == bot.user.id: # If user tries to mute BB: + # If user tries to mute BB: + if bot.user is not None and muteTarget.id == bot.user.id: await ctx.send("I am too powerful to be muted. Stop trying.") return 0 if not (role := get(ctx.guild.roles, name="Muted")): @@ -707,11 +724,8 @@ async def cmdMute( unit = duration[lastNumeric:] unitIsValid = False for mPair in times: - if ( - unit == mPair[0][0] # first character - or unit == mPair[0] # whole word - or unit == mPair[0] + "s" # plural - ): + # Check for first char, whole word, plural + if unit in (mPair[0][0], mPair[0], mPair[0] + "s"): unitIsValid = True duration = duration[:lastNumeric] # the numeric part mTime = float(duration) * mPair[1] @@ -723,25 +737,25 @@ async def cmdMute( duration = None try: - await target.add_roles(role) + await muteTarget.add_roles(role) except nextcord.DiscordException as e: misc.logException(e, ctx) await ctx.send(misc.hierarchyMsg) return 0 - report = "Muted " + target.mention - report += (" for " + duration + mString + ".") if mTime else "." + report = "Muted " + muteTarget.mention + report += ( + " for " + duration + mString + "." # type: ignore + ) if mTime else "." emb = misc.bbEmbed("Beardless Bot Mute", report).set_author( name=ctx.author, icon_url=misc.fetchAvatar(ctx.author) ) if args: - emb.add_field( - name="Mute Reason:", value=" ".join(args), inline=False - ) + emb.add_field(name="Mute Reason:", value=" ".join(args), inline=False) await ctx.send(embed=emb) if channel := misc.getLogChannel(ctx.guild): await channel.send( embed=logs.logMute( - target, + muteTarget, ctx.message, duration, mString, @@ -750,46 +764,47 @@ async def cmdMute( ) if mTime: # Autounmute - logging.info(f"Muted {target} for {mTime} in {ctx.guild.name}") + logging.info(f"Muted {muteTarget} for {mTime} in {ctx.guild.name}") await asyncio.sleep(mTime) - await target.remove_roles(role) - logging.info("Autounmuted " + target.name) + await muteTarget.remove_roles(role) + logging.info("Autounmuted " + muteTarget.name) if channel := misc.getLogChannel(ctx.guild): await channel.send( - embed=logs.logUnmute(target, ctx.author) + embed=logs.logUnmute(muteTarget, ctx.author) # type: ignore ) return 1 -@no_type_check -@bot.command(name="unmute") +@bot.command(name="unmute") # type: ignore async def cmdUnmute( - ctx: commands.Context, target: Optional[str] = None, *args + ctx: misc.botContext, target: Optional[str] = None, *args ) -> int: if not ctx.guild: return 0 if misc.ctxCreatedThread(ctx): return -1 report = misc.naughty.format(ctx.author.mention) - if ctx.author.guild_permissions.manage_messages: - # TODO: add a check for Muted role existing - if target: + if ctx.author.guild_permissions.manage_messages: # type: ignore + if not (role := get(ctx.guild.roles, name="Muted")): + report = "Error! Muted role does not exist! Can't unmute!" + elif target: converter = commands.MemberConverter() try: - target = await converter.convert(ctx, target) - await target.remove_roles(get(ctx.guild.roles, name="Muted")) + mutedMember = await converter.convert(ctx, target) + await mutedMember.remove_roles(role) except commands.MemberNotFound as e: misc.logException(e, ctx) report = "Invalid target! Target must be a mention or user ID." - return 0 except nextcord.DiscordException as e: misc.logException(e, ctx) report = misc.hierarchyMsg else: - report = f"Unmuted {target.mention}." + report = f"Unmuted {mutedMember.mention}." if channel := misc.getLogChannel(ctx.guild): await channel.send( - embed=logs.logUnmute(target, ctx.author) + embed=logs.logUnmute( + mutedMember, ctx.author # type: ignore + ) ) else: report = f"Invalid target, {ctx.author.mention}." @@ -799,7 +814,7 @@ async def cmdUnmute( @bot.command(name="purge") # type: ignore async def cmdPurge( - ctx: commands.Context, num: Optional[str] = None, *args + ctx: misc.botContext, num: Optional[str] = None, *args ) -> int: if misc.ctxCreatedThread(ctx): return -1 @@ -824,7 +839,7 @@ async def cmdPurge( @bot.command(name="buy") # type: ignore -async def cmdBuy(ctx: commands.Context, color: str = "none", *args) -> int: +async def cmdBuy(ctx: misc.botContext, color: str = "none", *args) -> int: if not ctx.guild: return 0 if misc.ctxCreatedThread(ctx): @@ -871,7 +886,7 @@ async def cmdBuy(ctx: commands.Context, color: str = "none", *args) -> int: @bot.command(name="pins", aliases=("sparpins", "howtospar")) # type: ignore -async def cmdPins(ctx: commands.Context) -> int: +async def cmdPins(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 if ctx.guild: @@ -892,7 +907,7 @@ async def cmdPins(ctx: commands.Context) -> int: @bot.command(name="spar") # type: ignore async def cmdSpar( - ctx: commands.Context, region: Optional[str] = None, *args + ctx: misc.botContext, region: Optional[str] = None, *args ) -> int: if misc.ctxCreatedThread(ctx): return -1 @@ -941,7 +956,7 @@ async def cmdSpar( @bot.command(name="brawl") # type: ignore -async def cmdBrawl(ctx: commands.Context) -> int: +async def cmdBrawl(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 if brawlKey: @@ -952,7 +967,7 @@ async def cmdBrawl(ctx: commands.Context) -> int: @bot.command(name="brawlclaim") # type: ignore async def cmdBrawlclaim( - ctx: commands.Context, profUrl: str = "None", *args + ctx: misc.botContext, profUrl: str = "None", *args ) -> int: if misc.ctxCreatedThread(ctx): return -1 @@ -961,7 +976,7 @@ async def cmdBrawlclaim( if profUrl.isnumeric(): brawlId = int(profUrl) else: - brawlId = brawl.getBrawlId(brawlKey, profUrl) # type: ignore + brawlId = await brawl.getBrawlId(brawlKey, profUrl) # type: ignore if brawlId is not None: try: brawl.claimProfile(ctx.author.id, brawlId) @@ -971,8 +986,7 @@ async def cmdBrawlclaim( else: report = "Profile claimed." else: - report = "Invalid profile URL/Brawlhalla ID! " if profUrl else "" - report += brawl.badClaim + report = "Invalid profile URL/Brawlhalla ID! " + brawl.badClaim await ctx.send( embed=misc.bbEmbed("Beardless Bot Brawlhalla Rank", report) ) @@ -980,20 +994,21 @@ async def cmdBrawlclaim( @bot.command(name="brawlrank") # type: ignore -async def cmdBrawlrank(ctx: commands.Context, *target) -> Optional[int]: +async def cmdBrawlrank(ctx: misc.botContext, *target) -> Optional[int]: if misc.ctxCreatedThread(ctx): return -1 if not (brawlKey and ctx.guild): return 0 # TODO: write valid target method; no need for this copy paste # have it return target, report - target = " ".join(target) if target else ctx.author # type: ignore - if not isinstance(target, (nextcord.User, nextcord.Member)): + rankTarget = " ".join(target) if target else ctx.author + if not isinstance(rankTarget, (nextcord.User, nextcord.Member)): report = "Invalid target!" - target = misc.memSearch(ctx.message, target) # type: ignore - if target: + rankTarget = misc.memSearch(ctx.message, rankTarget) # type: ignore + if rankTarget: try: - await ctx.send(embed=brawl.getRank(target, brawlKey)) # type: ignore + emb = await brawl.getRank(rankTarget, brawlKey) # type: ignore + await ctx.send(embed=emb) except Exception as e: misc.logException(e, ctx) report = brawl.reqLimit @@ -1006,18 +1021,19 @@ async def cmdBrawlrank(ctx: commands.Context, *target) -> Optional[int]: @bot.command(name="brawlstats") # type: ignore -async def cmdBrawlstats(ctx: commands.Context, *target) -> int: +async def cmdBrawlstats(ctx: misc.botContext, *target) -> int: if misc.ctxCreatedThread(ctx): return -1 if not (brawlKey and ctx.guild): return 0 - target = " ".join(target) if target else ctx.author # type: ignore - if not isinstance(target, (nextcord.User, nextcord.Member)): + statsTarget = " ".join(target) if target else ctx.author + if not isinstance(statsTarget, (nextcord.User, nextcord.Member)): report = "Invalid target!" - target = misc.memSearch(ctx.message, target) # type: ignore - if target: + statsTarget = misc.memSearch(ctx.message, statsTarget) # type: ignore + if statsTarget: try: - await ctx.send(embed=brawl.getStats(target, brawlKey)) # type: ignore + emb = await brawl.getStats(statsTarget, brawlKey) # type: ignore + await ctx.send(embed=emb) except Exception as e: misc.logException(e, ctx) report = brawl.reqLimit @@ -1030,18 +1046,19 @@ async def cmdBrawlstats(ctx: commands.Context, *target) -> int: @bot.command(name="brawlclan") # type: ignore -async def cmdBrawlclan(ctx: commands.Context, *target) -> int: +async def cmdBrawlclan(ctx: misc.botContext, *target) -> int: if misc.ctxCreatedThread(ctx): return -1 if not (brawlKey and ctx.guild): return 0 - target = " ".join(target) if target else ctx.author # type: ignore - if not isinstance(target, (nextcord.User, nextcord.Member)): + clanTarget = " ".join(target) if target else ctx.author + if not isinstance(clanTarget, (nextcord.User, nextcord.Member)): report = "Invalid target!" - target = misc.memSearch(ctx.message, target) # type: ignore - if target: + clanTarget = misc.memSearch(ctx.message, clanTarget) # type: ignore + if clanTarget: try: - await ctx.send(embed=brawl.getClan(target, brawlKey)) # type: ignore + emb = await brawl.getClan(clanTarget, brawlKey) # type: ignore + await ctx.send(embed=emb) except Exception as e: misc.logException(e, ctx) report = brawl.reqLimit @@ -1055,7 +1072,7 @@ async def cmdBrawlclan(ctx: commands.Context, *target) -> int: @bot.command(name="brawllegend") # type: ignore async def cmdBrawllegend( - ctx: commands.Context, legend: Optional[str] = None, *args + ctx: misc.botContext, legend: Optional[str] = None, *args ) -> int: if misc.ctxCreatedThread(ctx): return -1 @@ -1066,7 +1083,7 @@ async def cmdBrawllegend( ) if legend: try: - emb = brawl.legendInfo(brawlKey, legend.lower()) + emb = await brawl.legendInfo(brawlKey, legend.lower()) except Exception as e: misc.logException(e, ctx) report = brawl.reqLimit @@ -1084,7 +1101,7 @@ async def cmdBrawllegend( @bot.command(name="tweet", aliases=("eggtweet",)) # type: ignore -async def cmdTweet(ctx: commands.Context) -> int: +async def cmdTweet(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 if ctx.guild and ctx.guild.id == 442403231864324119: @@ -1097,7 +1114,7 @@ async def cmdTweet(ctx: commands.Context) -> int: @bot.command(name="reddit") # type: ignore -async def cmdReddit(ctx: commands.Context) -> int: +async def cmdReddit(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 if ctx.guild and ctx.guild.id == 442403231864324119: @@ -1107,7 +1124,7 @@ async def cmdReddit(ctx: commands.Context) -> int: @bot.command(name="guide") # type: ignore -async def cmdGuide(ctx: commands.Context) -> int: +async def cmdGuide(ctx: misc.botContext) -> int: if misc.ctxCreatedThread(ctx): return -1 if ctx.guild and ctx.guild.id == 442403231864324119: @@ -1122,7 +1139,7 @@ async def cmdGuide(ctx: commands.Context) -> int: @bot.command(name="search", aliases=("google", "lmgtfy")) # type: ignore -async def cmdSearch(ctx: commands.Context, *words) -> int: +async def cmdSearch(ctx: misc.botContext, *words) -> int: if misc.ctxCreatedThread(ctx): return -1 await ctx.send(embed=misc.search(" ".join(words))) @@ -1131,7 +1148,7 @@ async def cmdSearch(ctx: commands.Context, *words) -> int: @bot.listen() async def on_command_error( - ctx: commands.Context, e: commands.errors.CommandError + ctx: misc.botContext, e: commands.errors.CommandError ) -> int: if isinstance(e, commands.CommandNotFound): return 0 @@ -1154,8 +1171,7 @@ async def handleMessages(message: nextcord.Message) -> int: if misc.scamCheck(message.content.lower()): logging.info("Possible nitro scam detected in " + str(message.guild)) author = message.author - role = get(message.guild.roles, name="Muted") - if not role: + if not (role := get(message.guild.roles, name="Muted")): role = await misc.createMutedRole(message.guild) await author.add_roles(role) # type: ignore for channel in message.guild.text_channels: diff --git a/README.MD b/README.MD index 42fc516..ae530c7 100644 --- a/README.MD +++ b/README.MD @@ -1,6 +1,6 @@ # Beardless Bot -### Full Release 2.3.7 ![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.4.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. @@ -65,7 +65,7 @@ To run Beardless Bot's suite of unit tests, do `bash unitTests.sh`. This will also generate a coverage badge. To run an individual test--for instance, the Muted role creation test--use the pytest command, like so: `pytest -v bb_test.py::test_createMutedRole`. To run the suite of code quality -tests, do `pytest -v -k quality`. +tests, do `pytest -vk quality`. ## License diff --git a/bb_test.py b/bb_test.py index af2c730..342bd0f 100644 --- a/bb_test.py +++ b/bb_test.py @@ -1,24 +1,26 @@ -# mypy: ignore-errors -# TODO: resolve all remaining errors """Beardless Bot unit tests""" import asyncio import logging +import typing +from collections import deque +from collections.abc import Iterator from copy import copy from datetime import datetime from json import load -from os import environ, listdir, remove +from os import environ, listdir from random import choice from time import sleep -from typing import Any, Dict, Final, List, Literal, Optional, Union -from urllib.parse import quote_plus import aiofiles -import bandit # type: ignore +import httpx import nextcord import pytest import requests import responses +from aiohttp import ClientWebSocketResponse +from bandit.core.config import BanditConfig # type: ignore +from bandit.core.manager import BanditManager # type: ignore from bs4 import BeautifulSoup from dotenv import dotenv_values from flake8.api import legacy as flake8 # type: ignore @@ -36,11 +38,39 @@ bbFiles = [f for f in listdir() if f.endswith(".py")] # TODO: refactor away from this magic number -bbId: Final[int] = 654133911558946837 - - -def goodURL(request: requests.models.Response) -> bool: - return request.ok and request.headers["content-type"] in imageTypes +bbId: typing.Final[int] = 654133911558946837 + +messageable = typing.Union[ + nextcord.TextChannel, + nextcord.Thread, + nextcord.DMChannel, + nextcord.PartialMessageable, + nextcord.VoiceChannel, + nextcord.StageChannel, + nextcord.GroupChannel +] + +channelTypes = typing.Union[ + nextcord.StageChannel, + nextcord.VoiceChannel, + nextcord.TextChannel, + nextcord.CategoryChannel, + nextcord.ForumChannel, + None +] + +iconTypes = typing.Union[ + bytes, nextcord.Asset, nextcord.Attachment, nextcord.File, None +] + + +def goodURL( + resp: typing.Union[requests.models.Response, httpx.Response] +) -> bool: + return ( + 200 <= resp.status_code < 400 + and resp.headers["content-type"] in imageTypes + ) # TODO: add _state attribute to all mock objects, inherit from State @@ -51,7 +81,7 @@ class MockHTTPClient(nextcord.http.HTTPClient): def __init__( self, loop: asyncio.AbstractEventLoop, - user: Optional[nextcord.User] = None + user: typing.Optional[nextcord.User] = None ) -> None: self.loop = loop self.user = user @@ -59,45 +89,47 @@ def __init__( self.token = None self.proxy = None self.proxy_auth = None - self._locks = {} + self._locks = {} # type: ignore self._global_over = asyncio.Event() self._global_over.set() - async def create_role( + async def create_role( # type: ignore self, - guild_id: Union[str, int], - reason: Optional[str] = None, - **fields: Any - ) -> Dict[str, Any]: - data = {key: value for key, value in fields.items()} + guild_id: typing.Union[str, int], + reason: typing.Optional[str] = None, + **fields: typing.Any + ) -> dict[str, typing.Any]: + data = dict(fields) data["id"] = guild_id data["name"] = fields["name"] if "name" in fields else "TestRole" return data - async def send_message( + async def send_message( # type: ignore self, - channel_id: Union[str, int], - content: Optional[str] = None, + channel_id: typing.Union[str, int], + content: typing.Optional[str] = None, *, tts: bool = False, - embed: Optional[nextcord.Embed] = None, - embeds: Optional[List[nextcord.Embed]] = None, - nonce: Optional[str] = None, - allowed_mentions: Optional[Dict[str, Any]] = None, - message_reference: Optional[Dict[str, Any]] = None, - stickers: Optional[List[nextcord.Sticker]] = None, - components: Optional[List[Any]] = None, - **kwargs - ) -> Dict[str, Any]: - data: Dict[str, Any] = { + embed: typing.Optional[nextcord.Embed] = None, + embeds: typing.Optional[list[nextcord.Embed]] = None, + nonce: typing.Union[int, str, None] = None, + allowed_mentions: typing.Optional[nextcord.AllowedMentions] = None, + message_reference: typing.Optional[nextcord.MessageReference] = None, + stickers: typing.Optional[list[int]] = None, + components: typing.Optional[list[nextcord.Component]] = None, + flags: typing.Optional[int] = None + ) -> dict[str, typing.Any]: + data: dict[str, typing.Any] = { "attachments": [], "edited_timestamp": None, "type": nextcord.Message, "pinned": False, - "mention_everyone": ("@everyone" in content) if content else False, + "mention_everyone": ( + "@everyone" in content + ) if content else False, "tts": tts, "author": MockUser(), - "content": content if content else "" + "content": content or "" } if embed: data["embeds"] = [embed] @@ -118,22 +150,22 @@ async def send_message( return data async def leave_guild( - self, guild_id: nextcord.types.snowflake.Snowflake + self, guild_id: typing.Union[str, int] ) -> None: if self.user and self.user.guild and self.user.guild.id == guild_id: self.user.guild = None # MockUser class is a superset of nextcord.User with some features of -# nextcord.Member; still working on adding all features of nextcord.Member, -# at which point I will switch the parent from nextcord.User to nextcord.Member +# nextcord.Member. +# TODO: separate into MockUser and MockMember # TODO: give default @everyone role # TODO: move MockState out, apply to all classes, make generic class MockUser(nextcord.User): - class MockUserState(): + class MockUserState(nextcord.state.ConnectionState): def __init__(self, messageNum: int = 0) -> None: - self._guilds: Dict[int, nextcord.Guild] = {} + self._guilds: dict[int, nextcord.Guild] = {} self.allowed_mentions = nextcord.AllowedMentions(everyone=True) self.loop = asyncio.get_event_loop() self.http = MockHTTPClient(self.loop) @@ -142,22 +174,29 @@ def __init__(self, messageNum: int = 0) -> None: self.channel = MockChannel() def create_message( - self, *, channel: nextcord.abc.Messageable, data: Dict[str, Any] + self, + *, + channel: nextcord.abc.Messageable, + data: dict[str, typing.Any] # type: ignore ) -> nextcord.Message: data["id"] = self.last_message_id self.last_message_id += 1 - message = nextcord.Message(state=self, channel=channel, data=data) + message = nextcord.Message( + state=self, channel=channel, data=data # type: ignore + ) assert self.channel._state._messages is not None self.channel._state._messages.append(message) return message - def store_user(self, data: Dict[str, Any]) -> nextcord.User: + def store_user( + self, + data: dict[str, typing.Any] # type: ignore + ) -> nextcord.User: return MockUser() - def setClientUser(self) -> None: - self.http.user_agent = str(self.user) - - def _get_private_channel_by_user(self, id: int) -> nextcord.TextChannel: + def _get_private_channel_by_user( # type: ignore + self, user_id: typing.Optional[int] + ) -> nextcord.TextChannel: return self.channel def __init__( @@ -166,11 +205,11 @@ def __init__( nick: str = "testnick", discriminator: str = "0000", id: int = 123456789, - roles: List[nextcord.Role] = [], - guild: Optional[nextcord.Guild] = None, + roles: typing.Optional[list[nextcord.Role]] = None, + guild: typing.Optional[nextcord.Guild] = None, customAvatar: bool = True, adminPowers: bool = False, - messages: List[nextcord.Message] = [] + messages: typing.Optional[list[nextcord.Message]] = None ) -> None: self.name = name self.global_name = name @@ -178,25 +217,30 @@ def __init__( self.id = id self.discriminator = discriminator self.bot = False - self.roles = roles + self.roles = roles if roles is not None else [] self.joined_at = self.created_at self.activity = None self.system = False - self.messages = messages + self.messages = messages if messages is not None else [] self.guild = guild self._public_flags = 0 self._state = self.MockUserState(messageNum=len(self.messages)) - self._avatar = "7b6ea511d6e0ef6d1cdb2f7b53946c03" if customAvatar else None + self._avatar = ( + "7b6ea511d6e0ef6d1cdb2f7b53946c03" if customAvatar else None + ) self.setUserState() self.guild_permissions = ( - nextcord.Permissions.all() if adminPowers else nextcord.Permissions.none() + nextcord.Permissions.all() + if adminPowers + else nextcord.Permissions.none() ) def setUserState(self) -> None: self._state.user = self - self._state.setClientUser() + self._state.http.user_agent = str(self) - def history(self) -> List[nextcord.Message]: + @typing.no_type_check + def history(self) -> Iterator[nextcord.Message]: return self._state.channel.history() async def add_roles(self, role: nextcord.Role) -> None: @@ -205,47 +249,46 @@ async def add_roles(self, role: nextcord.Role) -> None: class MockChannel(nextcord.TextChannel): - class MockChannelState(): + class MockChannelState(nextcord.state.ConnectionState): def __init__( - self, user: Optional[nextcord.User] = None, messageNum: int = 0 + self, + user: typing.Optional[nextcord.User] = None, + messageNum: int = 0 ) -> 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 = messageNum - self._messages: List[nextcord.Message] = [] + self._messages: deque[nextcord.Message] = deque() def create_message( - self, *, channel: nextcord.abc.Messageable, data: Dict[str, Any] + self, + *, + channel: messageable, + data: dict[str, typing.Any] # type: ignore ) -> nextcord.Message: data["id"] = self.last_message_id self.last_message_id += 1 - message = nextcord.Message(state=self, channel=channel, data=data) + message = nextcord.Message( + state=self, + channel=channel, + data=data # type: ignore + ) self._messages.append(message) return message - def store_user(self, data: Dict) -> nextcord.User: - return self.user if self.user else MockUser() - - class Mock_Overwrites(): - def __init__( + def store_user( self, - id: int, - type: int, - allow: Union[nextcord.Permissions, int], - deny: Union[nextcord.Permissions, int] - ) -> None: - self.id = id - self.type = type - self.allow = allow - self.deny = deny + data: dict # type: ignore + ) -> nextcord.User: + return self.user or MockUser() def __init__( self, name: str = "testchannelname", - guild: Optional[nextcord.Guild] = None, - messages: List[nextcord.Message] = [], + guild: typing.Optional[nextcord.Guild] = None, + messages: typing.Optional[list[nextcord.Message]] = None, id: int = 123456789 ) -> None: self.name = name @@ -256,32 +299,62 @@ def __init__( self.topic = None self.category_id = 0 self.guild = guild # type: ignore - self.messages: List[nextcord.Message] = [] + self.messages = messages if messages is not None else [] self._type = 0 - self._state = self.MockChannelState(messageNum=len(messages)) + self._state = self.MockChannelState(messageNum=len(self.messages)) self._overwrites = [] self.assignChannelToGuild(self.guild) + @typing.overload + async def set_permissions( + self, + target: typing.Union[nextcord.Member, nextcord.Role], + *, + overwrite: typing.Optional[nextcord.PermissionOverwrite] = ..., + reason: typing.Optional[str] = ..., + ) -> None: + ... + + @typing.overload async def set_permissions( self, - target: Union[nextcord.Role, nextcord.User], + target: typing.Union[nextcord.Member, nextcord.Role], *, - overwrite: nextcord.PermissionOverwrite, - reason: Optional[str] = None + reason: typing.Optional[str] = ..., + **permissions: bool, ) -> None: - pair = overwrite.pair() - self._overwrites.append( - self.Mock_Overwrites( - target.id, - 0 if isinstance(target, nextcord.Role) else 1, - pair[0].value, - pair[1].value + ... + + async def set_permissions( + self, + target: typing.Union[nextcord.Member, nextcord.Role], + *, + reason: typing.Optional[str] = None, + **kwargs: typing.Any, + ) -> None: + overwrite = kwargs.pop("overwrite", None) + permissions: dict[str, bool] = kwargs + if overwrite is None and len(permissions) != 0: + overwrite = nextcord.PermissionOverwrite(**permissions) + if overwrite is not None: + allow, deny = overwrite.pair() + payload = { + "id": target.id, + "type": 0 if isinstance(target, nextcord.Role) else 1, + "allow": allow.value, + "deny": deny.value + } + self._overwrites.append( + nextcord.abc._Overwrites(payload) # type: ignore ) - ) + else: + for overwrite in self._overwrites: + if overwrite["id"] == target.id: + self._overwrites.remove(overwrite) - def history(self) -> List[nextcord.Message]: + def history(self) -> Iterator[nextcord.Message]: # type: ignore assert self._state._messages is not None - return list(reversed(self._state._messages)) + return iter(reversed(self._state._messages)) def assignChannelToGuild(self, guild: nextcord.Guild) -> None: if guild and self not in guild.channels: @@ -291,48 +364,52 @@ def assignChannelToGuild(self, guild: nextcord.Guild) -> None: # TODO: Write message.edit() class MockMessage(nextcord.Message): - class MockMessageState(): + class MockMessageState(nextcord.state.ConnectionState): def __init__( - self, user: nextcord.User, guild: Optional[nextcord.Guild] = None + self, + user: nextcord.User, + guild: typing.Optional[nextcord.Guild] = None ) -> None: self.loop = asyncio.get_event_loop() self.http = MockHTTPClient(self.loop, user=user) - self.guild = guild if guild else MockGuild() + self.guild = guild or MockGuild() def get_reaction_emoji( - self, data: Dict[str, str] + self, data: dict[str, str] ) -> nextcord.emoji.Emoji: return MockEmoji(self.guild, data, MockMessage()) def __init__( self, - content: Optional[str] = "testcontent", - author: nextcord.User = MockUser(), - guild: Optional[nextcord.Guild] = None, - channel: nextcord.TextChannel = MockChannel(), - embed: Optional[nextcord.Embed] = None + content: typing.Optional[str] = "testcontent", + author: typing.Optional[nextcord.User] = None, + guild: typing.Optional[nextcord.Guild] = None, + channel: typing.Optional[nextcord.TextChannel] = None, + embeds: typing.Optional[list[nextcord.Embed]] = None, + embed: typing.Optional[nextcord.Embed] = None ) -> None: - self.author = author - self.content = content if content else "" + self.author = author or MockUser() + self.content = content or "" self.id = 123456789 - self.channel = channel + self.channel = channel or MockChannel() self.type = nextcord.MessageType.default self.guild = guild self.mentions = [] + self.embeds = embeds or ([embed] if embed is not None else []) self.mention_everyone = False - self.flags = nextcord.MessageFlags._from_value(0) - self._state = self.MockMessageState(author, guild) + self.flags = nextcord.MessageFlags._from_value(0) # type: ignore + self._state = self.MockMessageState(self.author, guild) assert self.channel._state._messages is not None self.channel._state._messages.append(self) - async def delete(self) -> None: + async def delete(self, *, delay: typing.Optional[float] = None) -> None: assert self.channel._state._messages is not None self.channel._state._messages.remove(self) @staticmethod def getMockReactionPayload( emojiName: str = "MockEmojiName", emojiId: int = 0, me: bool = False - ) -> Dict[str, Any]: + ) -> dict[str, typing.Any]: return {"me": me, "emoji": {"id": emojiId, "name": emojiName}} @@ -343,7 +420,7 @@ def __init__( self, name: str = "Test Role", id: int = 123456789, - permissions: Union[int, nextcord.Permissions] = 1879573680 + permissions: typing.Union[int, nextcord.Permissions] = 1879573680 ) -> None: self.name = name self.id = id @@ -357,69 +434,125 @@ def __init__( class MockGuild(nextcord.Guild): - class MockGuildState(): + class MockGuildState(nextcord.state.ConnectionState): def __init__(self) -> None: user = MockUser() self.member_cache_flags = nextcord.MemberCacheFlags.all() - self.self_id = 1 self.shard_count = 1 self.loop = asyncio.get_event_loop() self.user = user self.http = MockHTTPClient(self.loop, user=user) self._intents = nextcord.Intents.all() - def is_guild_evicted(self, *args, **kwargs: Any) -> Literal[False]: + def is_guild_evicted( + self, guild: nextcord.Guild + ) -> typing.Literal[False]: return False - async def chunk_guild(self, *args, **kwargs: Any) -> None: + async def chunk_guild( + self, + guild: nextcord.Guild, + wait: bool = True, + cache: typing.Any = None + ) -> None: pass - async def query_members(self, *args, **kwargs: Any) -> List[nextcord.Member]: + async def query_members( + self, + guild: nextcord.Guild, + query: typing.Optional[str], + limit: int, + user_ids: typing.Optional[list[int]], + cache: bool, + presences: bool + ) -> list[nextcord.Member]: return [self.user] def setUserGuild(self, guild: nextcord.Guild) -> None: + assert self.user is not None self.user.guild = guild self.http.user = self.user def __init__( self, - members: List[nextcord.User] = [MockUser(), MockUser()], + members: typing.Optional[list[nextcord.User]] = None, name: str = "Test Guild", id: int = 0, - channels: List[nextcord.TextChannel] = [MockChannel()], - roles: List[nextcord.Role] = [MockRole()] + channels: typing.Optional[list[nextcord.TextChannel]] = None, + roles: typing.Optional[list[nextcord.Role]] = None ) -> None: self.name = name self.id = id self._state = self.MockGuildState() - self._members = {i: m for i, m in enumerate(members)} + self._members = dict( + enumerate( + members if members is not None else [MockUser(), MockUser()] + ) + ) self._member_count = len(self._members) - self._channels = {i: c for i, c in enumerate(channels)} - self._roles = {i: r for i, r in enumerate(roles)} + self._channels = dict( + enumerate(channels if channels is not None else [MockChannel()]) + ) + self._roles = dict( + enumerate(roles if roles is not None else [MockRole()]) + ) self.owner_id = 123456789 for role in self._roles.values(): self.assignGuild(role) + @typing.overload async def create_role( self, *, - name: str, - permissions: nextcord.Permissions, - mentionable: bool = False, - hoist: bool = False, - colour: Union[nextcord.Colour, int] = 0, - **kwargs: Any + reason: typing.Optional[str] = ..., + name: str = ..., + permissions: nextcord.Permissions = ..., + colour: typing.Union[nextcord.Colour, int] = ..., + hoist: bool = ..., + mentionable: bool = ..., + icon: typing.Union[str, iconTypes] = ... ) -> nextcord.Role: + ... + @typing.overload + async def create_role( + self, + *, + reason: typing.Optional[str] = ..., + name: str = ..., + permissions: nextcord.Permissions = ..., + color: typing.Union[nextcord.Colour, int] = ..., + hoist: bool = ..., + mentionable: bool = ..., + icon: typing.Union[str, iconTypes] = ... + ) -> nextcord.Role: + ... + + async def create_role( + self, + *, + name: str = "", + permissions: typing.Optional[nextcord.Permissions] = None, + color: typing.Union[nextcord.Colour, int] = 0, + colour: typing.Union[nextcord.Colour, int] = 0, + hoist: bool = False, + mentionable: bool = False, + icon: typing.Union[str, iconTypes] = None, + reason: typing.Optional[str] = None + ) -> nextcord.Role: + col = color or colour or nextcord.Colour.default() + perms = permissions or nextcord.Permissions.none() fields = { "name": name, - "permissions": str(permissions.value), + "permissions": str(perms.value), "mentionable": mentionable, "hoist": hoist, - "colour": colour if isinstance(colour, int) else colour.value + "colour": col if isinstance(col, int) else col.value } - data = await self._state.http.create_role(self.id, **fields) + data = await self._state.http.create_role( + self.id, reason=None, **fields + ) role = nextcord.Role(guild=self, data=data, state=self._state) self._roles[len(self.roles)] = role @@ -428,9 +561,9 @@ async def create_role( def assignGuild(self, role: nextcord.Role) -> None: role.guild = self - def get_member(self, userId: int) -> Optional[nextcord.Member]: + def get_member(self, userId: int) -> typing.Optional[nextcord.Member]: class MockGuildMember(nextcord.Member): - def __init__(self, id: int) -> None: + def __init__(self, id: int): self.data = {"user": "foo", "roles": "0"} self.guild = MockGuild() self.state = MockUser.MockUserState() @@ -439,11 +572,11 @@ def __init__(self, id: int) -> None: self.nick = "foobar" return MockGuildMember(userId) - def get_channel(self, channelId: int) -> Optional[nextcord.TextChannel]: + def get_channel(self, channelId: int) -> channelTypes: return self._channels.get(channelId) @property - def me(self) -> nextcord.User: + def me(self) -> nextcord.Member: return self._state.user @@ -451,128 +584,137 @@ class MockThread(nextcord.Thread): def __init__( self, name: str = "testThread", - owner: nextcord.User = MockUser(), + owner: typing.Optional[nextcord.User] = None, channelId: int = 0, - me: Optional[nextcord.Member] = None, - parent: Optional[nextcord.TextChannel] = None, + me: typing.Optional[nextcord.Member] = None, + parent: typing.Optional[nextcord.TextChannel] = None, archived: bool = False, locked: bool = False ): Bot.bot = MockBot(Bot.bot) - channel = parent if parent else MockChannel(id=channelId, guild=MockGuild()) + channel = parent or MockChannel( + id=channelId, guild=MockGuild() + ) self.guild = channel.guild self._state = channel._state self.state = self._state self.id = 0 self.name = name self.parent_id = channel.id - self.owner_id = owner.id + self.owner_id = (owner or MockUser()).id self.archived = archived self.archive_timestamp = datetime.now() self.locked = locked self.message_count = 0 self._type = 0 # type: ignore self.auto_archive_duration = 10080 - self.me = me - self._members = copy(channel.guild._members) + self.me = me # type: ignore + self._members = copy(channel.guild._members) # type: ignore if self.me and Bot.bot.user is not None and not any( user.id == Bot.bot.user.id for user in self.members ): - self._members[len(self.members)] = Bot.bot.user.baseUser + self._members[ + len(self.members) + ] = Bot.bot.user.baseUser # type: ignore self.member_count = len(self.members) - async def join(self): + async def join(self) -> None: + assert Bot.bot.user is not None if not any(user.id == Bot.bot.user.id for user in self.members): - if not any(user.id == Bot.bot.user.id for user in self.guild.members): - self.guild._members[len(self.guild.members)] = Bot.bot.user.baseUser + if not any( + user.id == Bot.bot.user.id for user in self.guild.members + ): + self.guild._members[ + len(self.guild.members) + ] = Bot.bot.user.baseUser self._members[len(self.members)] = Bot.bot.user.baseUser -class MockContext(commands.Context): +class MockContext(misc.botContext): - class MockContextState(): - # TODO: make context inherit state from message, as in actual Context._state + class MockContextState(nextcord.state.ConnectionState): + # TODO: make context inherit state from message, + # as in actual Context._state def __init__( self, - user: Optional[nextcord.User] = None, - channel: nextcord.TextChannel = MockChannel() + user: typing.Optional[nextcord.User] = None, + channel: typing.Optional[nextcord.TextChannel] = 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.channel = channel + self.user = user # type: ignore + self.channel = channel or MockChannel() self.message = MockMessage() def create_message( - self, *, channel: nextcord.abc.Messageable, data: Dict + self, + *, + channel: messageable, + data: dict[str, typing.Any] # type: ignore ) -> nextcord.Message: data["id"] = self.channel._state.last_message_id self.channel._state.last_message_id += 1 - message = nextcord.Message(state=self, channel=channel, data=data) + message = nextcord.Message( + state=self, + channel=channel, + data=data # type: ignore + ) assert self.channel._state._messages is not None self.channel._state._messages.append(message) return message - def store_user(self, data: Dict) -> nextcord.User: - return self.user if self.user else MockUser() + def store_user( + self, + data: dict # type: ignore + ) -> nextcord.User: + return self.user or MockUser() def __init__( self, bot: commands.Bot, - message: nextcord.Message = MockMessage(), - channel: nextcord.TextChannel = MockChannel(), - author: nextcord.User = MockUser(), - guild: Optional[nextcord.Guild] = MockGuild() + message: typing.Optional[nextcord.Message] = None, + channel: typing.Optional[nextcord.TextChannel] = None, + author: typing.Optional[nextcord.User] = None, + guild: typing.Optional[nextcord.Guild] = None ) -> None: self.bot = bot - self.prefix = bot.command_prefix - self.message = message - self.channel = channel - self.author = author + self.prefix = str(bot.command_prefix) if bot.command_prefix else "!" + self.message = message or MockMessage() + self.channel = channel or MockChannel() + self.author = author or MockUser() self.guild = guild - if self.guild and channel not in self.guild.channels: - self.guild._channels[len(self.guild.channels)] = channel - if self.guild and author not in self.guild.members: - self.guild._members[len(self.guild.members)] = author + if self.guild and self.channel not in self.guild.channels: + self.guild._channels[len(self.guild.channels)] = self.channel + if self.guild and self.author not in self.guild.members: + self.guild._members[len(self.guild.members)] = self.author self._state = self.MockContextState(channel=self.channel) self.invoked_with = None - def history(self) -> List[nextcord.Message]: + @typing.no_type_check + def history(self) -> Iterator[nextcord.Message]: return self._state.channel.history() class MockBot(commands.Bot): - class MockBotWebsocket(): + class MockBotWebsocket(nextcord.gateway.DiscordWebSocket): PRESENCE = 3 - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.latency = 0.025 - - async def change_presence( + def __init__( self, + socket: ClientWebSocketResponse, *, - activity: Optional[nextcord.Activity] = None, - status: Optional[str] = None, - since: float = 0.0 + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + _connection: nextcord.state.ConnectionState ) -> None: - data = { - "op": self.PRESENCE, - "d": { - "activities": [activity], - "status": nextcord.Status(value="online"), - "since": since - } - } - await self.send(data) + self.loop = loop or asyncio.get_event_loop() + self._connection = _connection - async def send(self, data: Dict[str, Any], /) -> Dict[str, Any]: - self.bot.status = nextcord.Status(value="online") - self.bot.activity = data["d"]["activities"][0] - return data + @property + def latency(self) -> float: + return 0.025 class MockClientUser(nextcord.ClientUser): def __init__(self, bot: commands.Bot) -> None: @@ -582,83 +724,103 @@ def __init__(self, bot: commands.Bot) -> None: self.id = self.baseUser.id self.name = "testclientuser" self.discriminator = "0000" - self._avatar: Union[str, nextcord.Asset, None] = ( - self.baseUser.avatar + self._avatar: typing.Union[str, nextcord.Asset, None] = ( + self.baseUser.avatar # type: ignore ) self.bot = True self.verified = True self.mfa_enabled = False self.global_name = self.baseUser.global_name - async def edit(self, avatar: str) -> None: + async def edit( + self, + username: str = "", + avatar: iconTypes = None + ) -> nextcord.ClientUser: + self.name = username or self.name self._avatar = str(avatar) + return self def __init__(self, bot: commands.Bot) -> None: self._connection = bot._connection + self.activity = None self._connection.user = self.MockClientUser(self) self.command_prefix = bot.command_prefix self.case_insensitive = bot.case_insensitive self._help_command = bot.help_command self._intents = bot.intents self.owner_id = bot.owner_id - self.status = nextcord.Status(value="online") - self.ws = self.MockBotWebsocket(self) + self.status = nextcord.Status.offline self._connection._guilds = {1: MockGuild()} self.all_commands = bot.all_commands + self.ws = self.MockBotWebsocket( + None, # type: ignore + _connection=bot._connection + ) + + async def change_presence( + self, + *, + activity: typing.Optional[nextcord.BaseActivity] = None, + status: typing.Optional[str] = None, + since: float = 0.0 + ) -> None: + self.activity = activity # type: ignore + self.status = nextcord.Status.online class MockEmoji(nextcord.emoji.Emoji): def __init__( self, guild: nextcord.Guild, - data: Dict[str, Any], - stateMessage: nextcord.Message = MockMessage() + data: dict[str, typing.Any], + stateMessage: typing.Optional[nextcord.Message] = None ) -> None: self.guild_id = guild.id - self._state = stateMessage._state - self._from_data(data) + self._state = (stateMessage or MockMessage())._state + self._from_data(data) # type: ignore -brawlKey = environ.get("BRAWLKEY") -if not brawlKey: +if not (brawlKey := environ.get("BRAWLKEY")): env = dotenv_values(".env") try: brawlKey = env["BRAWLKEY"] except KeyError: logging.warning( - "No Brawlhalla API key. Brawlhalla-specific tests will not be run.\n" + "No Brawlhalla API key. Brawlhalla-" + "specific tests will not be run.\n" ) -@pytest.mark.parametrize("letter", ["W", "E", "F", "I"]) +# Run code quality tests with pytest -vk quality +@pytest.mark.parametrize("letter", ["W", "E", "F", "I", "B"]) def test_pep8_compliance_with_flake8_for_code_quality(letter: str) -> None: styleGuide = flake8.get_style_guide(ignore=["W191", "W503"]) assert styleGuide.check_files(bbFiles).get_statistics(letter) == [] def test_full_type_checking_with_mypy_for_code_quality() -> None: - results = api.run(bbFiles + ["--pretty"]) - assert results[0].startswith("Success: no issues found") + results = api.run(bbFiles + ["--strict"]) + # assert results[0].startswith("Success: no issues found") + errors = [ + i for i in results[0].split("\n") + if ": error: " in i and "\"__call__\" of \"Command\"" not in i + ] + assert len(errors) <= 67 + # TODO: After switching from MockUser to MockMember, bring this to 0. + # Also remove majority of # type: ignores assert results[1] == "" - assert results[2] == 0 + # assert results[2] == 0 def test_no_security_vulnerabilities_with_bandit_for_code_quality() -> None: - mgr = bandit.manager.BanditManager( - bandit.config.BanditConfig(), - "file", - profile={"exclude": ["B101", "B311"]} + mgr = BanditManager( + BanditConfig(), "file", profile={"exclude": ["B101", "B311"]} ) mgr.discover_files(bbFiles) mgr.run_tests() - try: - with open("bandit.txt", "w") as f: - mgr.output_results(3, "LOW", "LOW", f, "txt") - with open("bandit.txt") as f: - results = f.read() - assert results.splitlines()[3].endswith("No issues identified.") - finally: - remove("bandit.txt") + # Cast to List[str] for the sake of reporting + assert [str(i) for i in mgr.results] == [] def test_mockContextChannels() -> None: @@ -680,10 +842,11 @@ async def test_logException(caplog: pytest.LogCaptureFixture) -> None: ctx = MockContext( Bot.bot, message=MockMessage(content="!mute foo"), - author=MockUser(adminPowers=True) + author=MockUser(adminPowers=True), + guild=MockGuild() ) ctx.invoked_with = "mute" - await Bot.cmdMute(ctx, "foo") + assert await Bot.cmdMute(ctx, "foo") == 0 assert caplog.records[0].msg == ( "Member \"foo\" not found. Command: mute; Author:" " testname#0000; Content: !mute foo; Guild: Test Guild;" @@ -711,7 +874,7 @@ async def test_on_ready(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) await Bot.on_ready() assert Bot.bot.activity.name == "try !blackjack and !flip" - assert Bot.bot.status == nextcord.Status(value="online") + assert Bot.bot.status == nextcord.Status.online assert caplog.records[4].msg == ( "Done! Beardless Bot serves 1 unique members across 1 servers." ) @@ -738,10 +901,12 @@ async def test_on_ready_raises_exceptions( caplog: pytest.LogCaptureFixture ) -> None: - def mock_raise_HTTPException(Bot: commands.Bot, activity: str) -> None: + def mock_raise_HTTPException( + Bot: commands.Bot, activity: nextcord.Game + ) -> None: resp = requests.Response() resp.status = 404 # type: ignore - raise nextcord.HTTPException(resp, "Bar") + raise nextcord.HTTPException(resp, str(Bot) + str(activity)) def mock_raise_FileNotFoundError(filepath: str, mode: str) -> None: raise FileNotFoundError() @@ -750,8 +915,7 @@ def mock_raise_FileNotFoundError(filepath: str, mode: str) -> None: Bot.bot._connection._guilds = {} with pytest.MonkeyPatch.context() as mp: mp.setattr( - "nextcord.ext.commands.Bot.change_presence", - mock_raise_HTTPException + "bb_test.MockBot.change_presence", mock_raise_HTTPException ) await Bot.on_ready() assert caplog.records[0].msg == ( @@ -762,10 +926,7 @@ def mock_raise_FileNotFoundError(filepath: str, mode: str) -> None: ) with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "aiofiles.open", - mock_raise_FileNotFoundError - ) + mp.setattr("aiofiles.open", mock_raise_FileNotFoundError) await Bot.on_ready() assert caplog.records[2].msg == ( "Avatar file not found! Check your directory structure." @@ -778,18 +939,21 @@ def mock_raise_FileNotFoundError(filepath: str, mode: str) -> None: @pytest.mark.asyncio async def test_on_guild_join(caplog: pytest.LogCaptureFixture) -> None: Bot.bot = MockBot(Bot.bot) - g = MockGuild(name="Foo", roles=[MockRole(name="Beardless Bot")]) + ch = MockChannel() + g = MockGuild( + name="Foo", roles=[MockRole(name="Beardless Bot")], channels=[ch] + ) g._state.user = MockUser(adminPowers=True) await Bot.on_guild_join(g) - emb = g.channels[0].history()[0].embeds[0] + emb = next(ch.history()).embeds[0] assert emb.title == "Hello, Foo!" assert emb.description == misc.joinMsg.format(g.name, "<@&123456789>") g._state.user = MockUser(adminPowers=False) - g._state.setUserGuild(g) + g._state.setUserGuild(g) # type: ignore caplog.set_level(logging.INFO) await Bot.on_guild_join(g) - emb = g.channels[0].history()[0].embeds[0] + emb = next(ch.history()).embeds[0] assert emb.title == "I need admin perms!" assert emb.description == misc.reasons assert caplog.records[3].msg == "Left Foo." @@ -805,8 +969,9 @@ def test_contCheck(content: str, description: str) -> None: @pytest.mark.asyncio async def test_on_message_delete() -> None: - m = MockMessage(channel=MockChannel(name="bb-log")) - m.guild = MockGuild(channels=[m.channel]) + ch = MockChannel(name="bb-log") + m = MockMessage(channel=ch) + m.guild = MockGuild(channels=[ch]) emb = await Bot.on_message_delete(m) assert emb is not None log = logs.logDeleteMsg(m) @@ -815,29 +980,28 @@ async def test_on_message_delete() -> None: "**Deleted message sent by <@123456789>" " in **<#123456789>\ntestcontent" ) - history = m.channel.history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description assert not await Bot.on_message_delete(MockMessage()) @pytest.mark.asyncio async def test_on_bulk_message_delete() -> None: - m = MockMessage(channel=MockChannel(name="bb-log")) - m.guild = MockGuild(channels=[m.channel]) + ch = MockChannel(name="bb-log") + m = MockMessage(channel=ch) + m.guild = MockGuild(channels=[ch]) messages = [m, m, m] - emb = await Bot.on_bulk_message_delete(messages) + emb = await Bot.on_bulk_message_delete(messages) # type: ignore assert emb is not None - log = logs.logPurge(messages[0], messages) + log = logs.logPurge(messages[0], messages) # type: ignore assert emb.description == log.description assert log.description == "Purged 2 messages in <#123456789>." - history = m.channel.history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description messages = [m] * 105 - emb = await Bot.on_bulk_message_delete(messages) + emb = await Bot.on_bulk_message_delete(messages) # type: ignore assert emb is not None - log = logs.logPurge(messages[0], messages) + log = logs.logPurge(messages[0], messages) # type: ignore assert emb.description == log.description assert log.description == "Purged 99+ messages in <#123456789>." @@ -848,30 +1012,29 @@ async def test_on_bulk_message_delete() -> None: @pytest.mark.asyncio async def test_on_reaction_clear() -> None: - channel = MockChannel(id=0, name="bb-log") - guild = MockGuild(channels=[channel]) - channel.guild = guild + ch = MockChannel(id=0, name="bb-log") + guild = MockGuild(channels=[ch]) + ch.guild = guild reaction = nextcord.Reaction( - message=MockMessage(), data=MockMessage.getMockReactionPayload("foo") + message=MockMessage(), + data=MockMessage.getMockReactionPayload("foo") # type: ignore ) otherReaction = nextcord.Reaction( - message=MockMessage(), data=MockMessage.getMockReactionPayload("bar") + message=MockMessage(), + data=MockMessage.getMockReactionPayload("bar") # type: ignore ) msg = MockMessage(guild=guild) emb = await Bot.on_reaction_clear(msg, [reaction, otherReaction]) assert emb is not None assert isinstance(emb.description, str) - assert ( - emb.description.startswith( - "Reactions cleared from message sent by" - " <@123456789> in <#123456789>." - ) + assert emb.description.startswith( + "Reactions cleared from message sent by <@123456789> in <#123456789>." ) + assert emb.fields[0].value is not None assert emb.fields[0].value.startswith(msg.content) - assert emb.fields[1].value is not None assert emb.fields[1].value == "<:foo:0>, <:bar:0>" - assert channel.history()[0].embeds[0].description == emb.description + assert next(ch.history()).embeds[0].description == emb.description assert not await Bot.on_reaction_clear( MockMessage(guild=MockGuild()), [reaction, otherReaction] @@ -880,52 +1043,59 @@ async def test_on_reaction_clear() -> None: @pytest.mark.asyncio async def test_on_guild_channel_delete() -> None: - g = MockGuild(channels=[MockChannel(name="bb-log")]) - channel = MockChannel(guild=g) - emb = await Bot.on_guild_channel_delete(channel) + ch = MockChannel(name="bb-log") + g = MockGuild(channels=[ch]) + newChannel = MockChannel(guild=g) + emb = await Bot.on_guild_channel_delete(newChannel) assert emb is not None - log = logs.logDeleteChannel(channel) + log = logs.logDeleteChannel(newChannel) assert emb.description == log.description assert log.description == "Channel \"testchannelname\" deleted." - history = g.channels[0].history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description - assert not await Bot.on_guild_channel_delete(MockChannel(guild=MockGuild())) + assert not await Bot.on_guild_channel_delete( + MockChannel(guild=MockGuild()) + ) @pytest.mark.asyncio async def test_on_guild_channel_create() -> None: - g = MockGuild(channels=[MockChannel(name="bb-log")]) - channel = MockChannel(guild=g) - emb = await Bot.on_guild_channel_create(channel) + ch = MockChannel(name="bb-log") + g = MockGuild(channels=[ch]) + newChannel = MockChannel(guild=g) + emb = await Bot.on_guild_channel_create(newChannel) assert emb is not None - log = logs.logCreateChannel(channel) + log = logs.logCreateChannel(newChannel) assert emb.description == log.description assert log.description == "Channel \"testchannelname\" created." - history = g.channels[0].history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description - assert not await Bot.on_guild_channel_create(MockChannel(guild=MockGuild())) + assert not await Bot.on_guild_channel_create( + MockChannel(guild=MockGuild()) + ) @pytest.mark.asyncio async def test_on_member_ban() -> None: - g = MockGuild(channels=[MockChannel(name="bb-log")]) + ch = MockChannel(name="bb-log") + g = MockGuild(channels=[ch]) member = MockUser() emb = await Bot.on_member_ban(g, member) assert emb is not None log = logs.logBan(member) assert emb.description == log.description assert log.description == "Member <@123456789> banned\ntestname" - history = g.channels[0].history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description - assert not await Bot.on_member_ban(MockGuild(), MockUser(guild=MockGuild())) + assert not await Bot.on_member_ban( + MockGuild(), MockUser(guild=MockGuild()) + ) @pytest.mark.asyncio async def test_on_member_unban() -> None: - g = MockGuild(channels=[MockChannel(name="bb-log")]) + ch = MockChannel(name="bb-log") + g = MockGuild(channels=[ch]) member = MockUser() emb = await Bot.on_member_unban(g, member) assert emb is not None @@ -934,16 +1104,18 @@ async def test_on_member_unban() -> None: assert ( log.description == "Member <@123456789> unbanned\ntestname" ) - history = g.channels[0].history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description - assert not await Bot.on_member_unban(MockGuild(), MockUser(guild=MockGuild())) + assert not await Bot.on_member_unban( + MockGuild(), MockUser(guild=MockGuild()) + ) @pytest.mark.asyncio async def test_on_member_join() -> None: member = MockUser() - member.guild = MockGuild(channels=[MockChannel(name="bb-log")]) + ch = MockChannel(name="bb-log") + member.guild = MockGuild(channels=[ch]) emb = await Bot.on_member_join(member) assert emb is not None log = logs.logMemberJoin(member) @@ -952,8 +1124,7 @@ async def test_on_member_join() -> None: "Member <@123456789> joined\nAccount registered" f" on {misc.truncTime(member)}\nID: 123456789" ) - history = member.guild.channels[0].history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description assert not await Bot.on_member_join(MockUser(guild=MockGuild())) @@ -961,7 +1132,8 @@ async def test_on_member_join() -> None: @pytest.mark.asyncio async def test_on_member_remove() -> None: member = MockUser() - member.guild = MockGuild(channels=[MockChannel(name="bb-log")]) + ch = MockChannel(name="bb-log") + member.guild = MockGuild(channels=[ch]) emb = await Bot.on_member_remove(member) assert emb is not None log = logs.logMemberRemove(member) @@ -974,15 +1146,15 @@ async def test_on_member_remove() -> None: log = logs.logMemberRemove(member) assert emb.description == log.description assert log.fields[0].value == "<@&123456789>" - history = member.guild.channels[0].history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description assert not await Bot.on_member_remove(MockUser(guild=MockGuild())) @pytest.mark.asyncio async def test_on_member_update() -> None: - guild = MockGuild(channels=[MockChannel(name="bb-log")]) + ch = MockChannel(name="bb-log") + guild = MockGuild(channels=[ch]) old = MockUser(nick="a", roles=[], guild=guild) new = MockUser(nick="b", roles=[], guild=guild) emb = await Bot.on_member_update(old, new) @@ -992,8 +1164,7 @@ 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 - history = guild.channels[0].history() - assert history[0].embeds[0].description == log.description + assert next(ch.history()).embeds[0].description == log.description new = MockUser(nick="a", guild=guild) assert new.guild is not None @@ -1020,15 +1191,15 @@ async def test_on_member_update() -> None: @pytest.mark.asyncio async def test_on_message_edit() -> None: + ch = MockChannel(name="bb-log") member = MockUser() g = MockGuild( - channels=[MockChannel(name="bb-log"), MockChannel(name="infractions")], + channels=[ch, MockChannel(name="infractions")], roles=[] ) assert g.roles == [] before = MockMessage(content="old", author=member, guild=g) after = MockMessage(content="new", author=member, guild=g) - emb = await Bot.on_message_edit(before, after) assert isinstance(emb, nextcord.Embed) log = logs.logEditMsg(before, after) @@ -1044,12 +1215,14 @@ async def test_on_message_edit() -> None: after.content = "http://dizcort.com free nitro!" emb = await Bot.on_message_edit(before, after) assert emb is not None - assert g.channels[0].history()[1].content.startswith("Deleted possible") + assert len(g.roles) == 1 and g.roles[0].name == "Muted" # TODO: edit after to have content of len > 1024 via message.edit - assert g.channels[0].history()[0].embeds[0].description == log.description + h = ch.history() + assert next(h).embeds[0].description == log.description + assert next(h).content.startswith("Deleted possible") assert not any( - i.content.startswith("http://dizcort.com") for i in g.channels[0].history() + i.content.startswith("http://dizcort.com") for i in ch.history() ) assert not await Bot.on_message_edit(MockMessage(), MockMessage()) @@ -1057,11 +1230,10 @@ async def test_on_message_edit() -> None: @pytest.mark.asyncio async def test_on_thread_join() -> None: - channel = MockChannel(id=0, name="bb-log") - guild = MockGuild(channels=[channel]) - channel.guild = guild - thread = MockThread(parent=channel, me=MockUser(), name="Foo") - + ch = MockChannel(id=0, name="bb-log") + guild = MockGuild(channels=[ch]) + ch.guild = guild + thread = MockThread(parent=ch, me=MockUser(), name="Foo") assert await Bot.on_thread_join(thread) is None thread.me = None @@ -1072,42 +1244,40 @@ async def test_on_thread_join() -> None: assert emb.description == ( "Thread \"Foo\" created in parent channel <#0>." ) - assert channel.history()[0].embeds[0].description == emb.description + assert next(ch.history()).embeds[0].description == emb.description - channel.name = "bar" + ch.name = "bar" assert not await Bot.on_thread_join( - MockThread(parent=channel, me=None, name="Foo") + MockThread(parent=ch, me=None, name="Foo") ) @pytest.mark.asyncio async def test_on_thread_delete() -> None: - channel = MockChannel(id=0, name="bb-log") - guild = MockGuild(channels=[channel]) - channel.guild = guild - thread = MockThread(parent=channel, name="Foo") - + ch = MockChannel(id=0, name="bb-log") + guild = MockGuild(channels=[ch]) + ch.guild = guild + thread = MockThread(parent=ch, name="Foo") emb = await Bot.on_thread_delete(thread) assert emb is not None assert emb.description == ( "Thread \"Foo\" deleted." ) - assert channel.history()[0].embeds[0].description == emb.description + assert next(ch.history()).embeds[0].description == emb.description - channel.name = "bar" + ch.name = "bar" assert not await Bot.on_thread_delete( - MockThread(parent=channel, me=MockUser(), name="Foo") + MockThread(parent=ch, me=MockUser(), name="Foo") ) @pytest.mark.asyncio async def test_on_thread_update() -> None: - channel = MockChannel(id=0, name="bb-log") - guild = MockGuild(channels=[channel]) - channel.guild = guild - before = MockThread(parent=channel, name="Foo") - after = MockThread(parent=channel, name="Foo") - + ch = MockChannel(id=0, name="bb-log") + guild = MockGuild(channels=[ch]) + ch.guild = guild + before = MockThread(parent=ch, name="Foo") + after = MockThread(parent=ch, name="Foo") assert await Bot.on_thread_update(before, after) is None before.archived = True @@ -1115,26 +1285,26 @@ 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 channel.history()[0].embeds[0].description == emb.description + assert next(ch.history()).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 channel.history()[0].embeds[0].description == emb.description + assert next(ch.history()).embeds[0].description == emb.description - channel.name = "bar" - ch = MockThread(parent=channel, name="Foo") - assert not await Bot.on_thread_update(ch, ch) + ch.name = "bar" + th = MockThread(parent=ch, name="Foo") + assert not await Bot.on_thread_update(th, th) @pytest.mark.asyncio async def test_cmdDice() -> None: ch = MockChannel() ctx = MockContext(Bot.bot, channel=ch, guild=MockGuild(channels=[ch])) - emb = await Bot.cmdDice(ctx) # type: ignore + emb: nextcord.Embed = await Bot.cmdDice(ctx) assert isinstance(emb, nextcord.Embed) assert emb.description == misc.diceMsg - assert ch.history()[0].embeds[0].description == emb.description + assert next(ch.history()).embeds[0].description == emb.description @pytest.mark.asyncio @@ -1145,7 +1315,7 @@ async def test_fact() -> None: ch = MockChannel() ctx = MockContext(Bot.bot, channel=ch, guild=MockGuild(channels=[ch])) assert await Bot.cmdFact(ctx) == 1 - assert ch.history()[0].embeds[0].description in lines + assert next(ch.history()).embeds[0].description in lines def test_tweet() -> None: @@ -1193,33 +1363,35 @@ def test_dice_irregular() -> None: @pytest.mark.parametrize("count", [-5, 1, 2, 3, 5, 100]) -def test_dice_multiple(count) -> None: +def test_dice_multiple(count: int) -> None: assert misc.roll(str(count) + "d4")[0] in range(1, (abs(count) * 4) + 1) def test_dice_multiple_irregular() -> None: assert misc.roll("10d20-4")[0] in range(6, 197) + assert misc.roll("ad100")[0] in range(1, 101) + assert misc.roll("0d8")[0] == 0 + assert misc.roll("0d12+57")[0] == 57 def test_logMute() -> None: - message = MockMessage() - member = MockUser() + message = MockMessage(channel=MockChannel(id=1)) + member = MockUser(id=2) assert logs.logMute(member, message, "5", "hours", 18000).description == ( - "Muted <@123456789> for 5 hours in <#123456789>." + "Muted <@2> for 5 hours in <#1>." ) + assert logs.logMute(member, message, None, None, None).description == ( - "Muted <@123456789> in <#123456789>." + "Muted <@2> in <#1>." ) def test_logUnmute() -> None: - member = MockUser() - assert logs.logUnmute(member, MockUser()).description == ( - "Unmuted <@123456789>." - ) + member = MockUser(id=3) + assert logs.logUnmute(member, MockUser()).description == "Unmuted <@3>." def test_getLogChannel() -> None: @@ -1262,7 +1434,7 @@ def test_fetchAvatar_default() -> None: def test_memSearch_valid(username: str, content: str) -> None: namedUser = MockUser(username, "testnick", "9999") text = MockMessage( - content=content, guild=MockGuild(members=(MockUser(), namedUser)) + content=content, guild=MockGuild(members=[MockUser(), namedUser]) ) assert misc.memSearch(text, content) == namedUser @@ -1270,7 +1442,8 @@ def test_memSearch_valid(username: str, content: str) -> None: def test_memSearch_invalid() -> None: namedUser = MockUser("searchterm", "testnick", "9999") text = MockMessage( - content="invalidterm", guild=MockGuild(members=(MockUser(), namedUser)) + content="invalidterm", + guild=MockGuild(members=[MockUser(), namedUser]) ) assert not misc.memSearch(text, text.content) @@ -1282,6 +1455,7 @@ def test_register() -> None: "You are already in the system! Hooray! You" f" have 200 BeardlessBucks, <@{bbId}>." ) + bb.name = ",badname," assert bucks.register(bb).description == ( bucks.commaWarn.format(f"<@{bbId}>") @@ -1290,7 +1464,7 @@ def test_register() -> None: @pytest.mark.parametrize( "target, result", [ - (MockUser("Test", "", "5757", bbId), "'s balance is"), + (MockUser("Test", "", "5757", bbId), "'s balance is 200"), (MockUser(","), bucks.commaWarn.format("<@123456789>")), ("Invalid user", "Invalid user!") ] @@ -1306,6 +1480,7 @@ def test_reset() -> None: assert bucks.reset(bb).description == ( f"You have been reset to 200 BeardlessBucks, <@{bbId}>." ) + bb.name = ",badname," assert bucks.reset(bb).description == bucks.commaWarn.format(f"<@{bbId}>") @@ -1314,12 +1489,12 @@ def test_writeMoney() -> None: bb = MockUser("Beardless Bot", "Beardless Bot", "5757", bbId) bucks.reset(bb) assert bucks.writeMoney(bb, "-all", False, False) == (0, 200) + assert bucks.writeMoney(bb, -1000000, True, False) == (-2, None) def test_leaderboard() -> None: lb = bucks.leaderboard() - assert lb.title == "BeardlessBucks Leaderboard" fields = lb.fields if len(fields) >= 2: # This check in case of an empty leaderboard @@ -1336,10 +1511,12 @@ def test_leaderboard() -> None: assert len(lb.fields) == len(fields) + 2 -@responses.activate -def test_define_valid() -> None: - responses.get( - "https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", +@pytest.mark.asyncio +async def test_define_valid( # type: ignore + httpx_mock +) -> None: + httpx_mock.add_response( + url="https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", json=[ { "word": "foo", @@ -1348,14 +1525,16 @@ def test_define_valid() -> None: } ] ) - word = misc.define("foo") + word = await misc.define("foo") assert word.title == "FOO" and word.description == "Audio: spam" -@responses.activate -def test_define_no_audio_has_blank_description() -> None: - responses.get( - "https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", +@pytest.mark.asyncio +async def test_define_no_audio_has_blank_description( # type: ignore + httpx_mock +) -> None: + httpx_mock.add_response( + url="https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", json=[ { "word": "foo", @@ -1364,65 +1543,97 @@ def test_define_no_audio_has_blank_description() -> None: } ] ) - word = misc.define("foo") + word = await misc.define("foo") assert word.title == "FOO" and word.description == "" -@responses.activate -def test_define_invalid_word_returns_no_results_found() -> None: - responses.get( - "https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", - status=404 +@pytest.mark.asyncio +async def test_define_invalid_word_returns_no_results_found( # type: ignore + httpx_mock +) -> None: + httpx_mock.add_response( + url="https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", + status_code=404 ) - assert misc.define("foo").description == "No results found." + emb = await misc.define("foo") + assert emb.description == "No results found." -@responses.activate -def test_define_api_down_returns_error_message() -> None: - responses.get( - "https://api.dictionaryapi.dev/api/v2/entries/en_US/test", - status=400 +@pytest.mark.asyncio +async def test_define_api_down_returns_error_message( # type: ignore + httpx_mock +) -> None: + httpx_mock.add_response( + url="https://api.dictionaryapi.dev/api/v2/entries/en_US/test", + status_code=400 ) - word = misc.define("test") + word = await misc.define("test") assert isinstance(word.description, str) assert word.description.startswith("There was an error") -@responses.activate @pytest.mark.asyncio -async def test_cmdDefine() -> None: +async def test_cmdDefine( # type: ignore + httpx_mock +) -> None: Bot.bot = MockBot(Bot.bot) - ctx = MockContext(Bot.bot, message=MockMessage("!define f")) - + ch = MockChannel() + ctx = MockContext( + Bot.bot, + message=MockMessage("!define f"), + channel=ch, + guild=MockGuild() + ) with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "misc.define", - "raise Exception" - ) - assert await Bot.cmdDefine(ctx, "f") == 0 - assert ctx.channel.history()[0].content.startswith("The API I use to get") + mp.setattr("misc.define", "raise Exception") + assert await Bot.cmdDefine(ctx, "f") == 0 # type: ignore + assert next(ch.history()).content.startswith("The API I use to get") resp = [{"word": "f", "phonetics": [], "meanings": [{"definitions": []}]}] - responses.get( - "https://api.dictionaryapi.dev/api/v2/entries/en_US/f", json=resp + httpx_mock.add_response( + url="https://api.dictionaryapi.dev/api/v2/entries/en_US/f", json=resp ) assert await Bot.cmdDefine(ctx, "f") == 1 - emb = ctx.channel.history()[0].embeds[0] - assert emb.title == misc.define("f").title == "F" + emb = next(ch.history()).embeds[0] + definition = await misc.define("f") + assert emb.title == definition.title == "F" def test_flip() -> None: bb = MockUser("Beardless Bot", "Beardless Bot", "5757", bbId) - assert bucks.flip(bb, "0").endswith("actually bet anything.") assert bucks.flip(bb, "invalidbet").startswith("Invalid bet.") bucks.reset(bb) - bucks.flip(bb, "all") + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, y: 0) + assert bucks.flip(bb, "all") == ( + "Tails! You lose! Your losses have been" + f" deducted from your balance, <@{bbId}>." + ) + balMsg = bucks.balance(bb, MockMessage("!bal", bb)) + assert isinstance(balMsg.description, str) + assert "is 0" in balMsg.description + + bucks.reset(bb) + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, y: 0) + bucks.flip(bb, 37) balMsg = bucks.balance(bb, MockMessage("!bal", bb)) assert isinstance(balMsg.description, str) - assert ("400" in balMsg.description or "0" in balMsg.description) + assert "is 163" in balMsg.description + + bucks.reset(bb) + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, y: 1) + assert bucks.flip(bb, "all") == ( + "Heads! You win! Your winnings have been" + f" added to your balance, <@{bbId}>." + ) + balMsg = bucks.balance(bb, MockMessage("!bal", bb)) + assert isinstance(balMsg.description, str) + assert "is 400" in balMsg.description bucks.reset(bb) bucks.flip(bb, "100") @@ -1432,10 +1643,7 @@ def test_flip() -> None: assert isinstance(balMsg.description, str) and "200" in balMsg.description with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "bucks.writeMoney", - lambda *x: (2, 0) - ) + mp.setattr("bucks.writeMoney", lambda *x: (2, 0)) assert bucks.flip(bb, "0") == bucks.newUserMsg.format(f"<@{bbId}>") bb.name = ",invalidname," @@ -1446,47 +1654,38 @@ def test_flip() -> None: async def test_cmdFlip() -> None: bb = MockUser("Beardless Bot", "Beardless Bot", "5757", bbId) Bot.bot = MockBot(Bot.bot) - ctx = MockContext(Bot.bot, author=bb, message=MockMessage("!flip 0")) + ch = MockChannel() + ctx = MockContext(Bot.bot, MockMessage("!flip 0"), ch, bb, MockGuild()) Bot.games = [] - - assert await Bot.cmdFlip(ctx, "0") == 1 - emb = ctx.channel.history()[0].embeds[0] + assert await Bot.cmdFlip(ctx, "0") == 1 # type: ignore + emb = next(ch.history()).embeds[0] + assert emb.description is not None assert emb.description.endswith("actually bet anything.") Bot.games.append(bucks.BlackjackGame(bb, 10)) assert await Bot.cmdFlip(ctx, "0") == 1 - emb = ctx.channel.history()[0].embeds[0] + emb = next(ch.history()).embeds[0] assert emb.description == bucks.finMsg.format(f"<@{bbId}>") def test_blackjack() -> None: bb = MockUser("Beardless Bot", "Beardless Bot", "5757", bbId) - assert bucks.blackjack(bb, "invalidbet")[0].startswith("Invalid bet.") bucks.reset(bb) with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "bucks.BlackjackGame.perfect", - lambda x: False - ) + mp.setattr("bucks.BlackjackGame.perfect", lambda x: False) report, game = bucks.blackjack(bb, 0) assert isinstance(game, bucks.BlackjackGame) assert "You hit 21!" not in report with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "bucks.BlackjackGame.perfect", - lambda x: True - ) + mp.setattr("bucks.BlackjackGame.perfect", lambda x: True) report, game = bucks.blackjack(bb, "0") assert game is None and "You hit 21" in report with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "bucks.writeMoney", - lambda *x: (2, 0) - ) + mp.setattr("bucks.writeMoney", lambda *x: (2, 0)) assert bucks.blackjack(bb, "0")[0] == ( bucks.newUserMsg.format(f"<@{bbId}>") ) @@ -1503,17 +1702,21 @@ def test_blackjack() -> None: @pytest.mark.asyncio async def test_cmdBlackjack() -> None: bb = MockUser("Beardless Bot", "Beardless Bot", "5757", bbId) + ch = MockChannel() Bot.bot = MockBot(Bot.bot) - ctx = MockContext(Bot.bot, author=bb, message=MockMessage("!blackjack 0")) + ctx = MockContext( + Bot.bot, MockMessage("!blackjack 0"), ch, bb, MockGuild() + ) Bot.games = [] - - assert await Bot.cmdBlackjack(ctx, "all") == 1 - emb = ctx.channel.history()[0].embeds[0] + assert await Bot.cmdBlackjack(ctx, "all") == 1 # type: ignore + emb = next(ch.history()).embeds[0] + assert emb.description is not None assert emb.description.startswith("Your starting hand consists of") Bot.games.append(bucks.BlackjackGame(bb, 10)) assert await Bot.cmdBlackjack(ctx, "0") == 1 - emb = ctx.channel.history()[0].embeds[0] + emb = next(ch.history()).embeds[0] + assert emb.description is not None assert emb.description == bucks.finMsg.format(f"<@{bbId}>") @@ -1527,14 +1730,15 @@ def test_blackjack_perfect() -> None: async def test_cmdDeal() -> None: Bot.games = [] bb = MockUser("Beardless,Bot", "Beardless Bot", "5757", bbId) - ctx = MockContext(Bot.bot, author=bb, message=MockMessage("!hit")) + ch = MockChannel() + ctx = MockContext(Bot.bot, MockMessage("!hit"), ch, bb, MockGuild()) assert await Bot.cmdDeal(ctx) == 1 - emb = ctx.channel.history()[0].embeds[0] + emb = next(ch.history()).embeds[0] assert emb.description == bucks.commaWarn.format(f"<@{bbId}>") bb.name = "Beardless Bot" assert await Bot.cmdDeal(ctx) == 1 - emb = ctx.channel.history()[0].embeds[0] + emb = next(ch.history()).embeds[0] assert emb.description == bucks.noGameMsg.format(f"<@{bbId}>") game = bucks.BlackjackGame(bb, 0) @@ -1542,13 +1746,15 @@ async def test_cmdDeal() -> None: Bot.games = [] Bot.games.append(game) assert await Bot.cmdDeal(ctx) == 1 - emb = ctx.channel.history()[0].embeds[0] + emb = next(ch.history()).embeds[0] assert len(game.cards) == 3 + assert emb.description is not None assert emb.description.startswith("You were dealt") game.cards = [10, 10, 10] assert await Bot.cmdDeal(ctx) == 1 - emb = ctx.channel.history()[0].embeds[0] + emb = next(ch.history()).embeds[0] + assert emb.description is not None assert f"You busted. Game over, <@{bbId}>." in emb.description assert len(Bot.games) == 0 @@ -1563,7 +1769,6 @@ async def test_cmdDeal() -> None: def test_blackjack_deal() -> None: game = bucks.BlackjackGame(MockUser(), 10) - game.cards = [2, 3] game.deal() assert len(game.cards) == 3 @@ -1582,13 +1787,14 @@ def test_blackjack_cardName() -> None: "a 10", "a Jack", "a Queen", "a King" ) assert bucks.BlackjackGame.cardName(11) == "an Ace" + assert bucks.BlackjackGame.cardName(8) == "an 8" + assert bucks.BlackjackGame.cardName(5) == "a 5" def test_blackjack_checkBust() -> None: game = bucks.BlackjackGame(MockUser(), 10) - game.cards = [10, 10, 10] assert game.checkBust() @@ -1598,7 +1804,6 @@ def test_blackjack_checkBust() -> None: def test_blackjack_stay() -> None: game = bucks.BlackjackGame(MockUser(), 0) - game.cards = [10, 10, 1] game.dealerSum = 25 assert game.stay() == 1 @@ -1628,6 +1833,7 @@ def test_activeGame() -> None: author = MockUser(name="target", id=0) games = [bucks.BlackjackGame(MockUser(name="not", id=1), 10)] * 9 assert not bucks.activeGame(games, author) + games.append(bucks.BlackjackGame(author, 10)) assert bucks.activeGame(games, author) @@ -1641,6 +1847,7 @@ def test_info() -> None: assert namedUserInfo.fields[0].value == misc.truncTime(namedUser) + " UTC" assert namedUserInfo.fields[1].value == misc.truncTime(namedUser) + " UTC" assert namedUserInfo.fields[2].value == "<@&123456789>" + assert misc.info("!infoerror", text).title == "Invalid target!" @@ -1650,8 +1857,11 @@ def test_av() -> None: text = MockMessage("!av searchterm", guild=guild) avatar = str(misc.fetchAvatar(namedUser)) assert misc.av("searchterm", text).image.url == avatar + assert misc.av("error", text).title == "Invalid target!" + assert misc.av(namedUser, text).image.url == avatar + text.guild = None text.author = namedUser assert misc.av("searchterm", text).image.url == avatar @@ -1660,27 +1870,31 @@ def test_av() -> None: @pytest.mark.asyncio async def test_bbHelpCommand() -> None: helpCommand = misc.bbHelpCommand() - helpCommand.context = MockContext(Bot.bot, guild=None) + ch = MockChannel() + helpCommand.context = MockContext(Bot.bot, guild=None, channel=ch) await helpCommand.send_bot_help({}) + helpCommand.context.guild = MockGuild() - helpCommand.context.author.guild_permissions = nextcord.Permissions( - manage_messages=True + helpCommand.context.author.guild_permissions = ( # type: ignore + nextcord.Permissions(manage_messages=True) ) await helpCommand.send_bot_help({}) - helpCommand.context.author.guild_permissions = nextcord.Permissions( - manage_messages=False + + helpCommand.context.author.guild_permissions = ( # type: ignore + nextcord.Permissions(manage_messages=False) ) await helpCommand.send_bot_help({}) - h = helpCommand.context.channel.history() - assert len(h[2].embeds[0].fields) == 15 - assert len(h[1].embeds[0].fields) == 20 - assert len(h[0].embeds[0].fields) == 17 + h = ch.history() + assert len(next(h).embeds[0].fields) == 17 + assert len(next(h).embeds[0].fields) == 20 + assert len(next(h).embeds[0].fields) == 15 + helpCommand.context.message.type = nextcord.MessageType.thread_created assert await helpCommand.send_bot_help({}) == -1 # For the time being, just pass on all invalid help calls - assert not await helpCommand.send_error_message("Foo") + assert not await helpCommand.send_error_message("Foo") # type: ignore def test_pingMsg() -> None: @@ -1698,7 +1912,9 @@ def test_scamCheck() -> None: assert misc.scamCheck("http://dizcort.com free nitro!") assert misc.scamCheck("@everyone http://didcord.gg free nitro!") assert misc.scamCheck("gift nitro http://d1zcordn1tr0.co.uk free!") - assert misc.scamCheck("hey @everyone check it! http://discocl.com/ nitro!") + assert misc.scamCheck( + "hey @everyone check it! http://discocl.com/ nitro!" + ) assert not misc.scamCheck( "Hey Discord friends, check out https://top.gg/bot/" + str(bbId) ) @@ -1712,8 +1928,8 @@ def test_scamCheck() -> None: "searchterm", ["лексика", "spaced words", "/", "'", "''", "'foo'", "\\\""] ) def test_search_valid(searchterm: str) -> None: - url = "https://www.google.com/search?q=" + quote_plus(searchterm) - assert url == misc.search(searchterm).description + url = misc.search(searchterm).description + assert isinstance(url, str) r = requests.get(url, timeout=10) assert r.ok assert next( @@ -1725,8 +1941,8 @@ def test_search_valid(searchterm: str) -> None: "searchterm, title", [("", "Google"), (" ", "- Google Search")] ) def test_search_irregular(searchterm: str, title: str) -> None: - url = "https://www.google.com/search?q=" + quote_plus(searchterm) - assert url == misc.search(searchterm).description + url = misc.search(searchterm).description + assert isinstance(url, str) r = requests.get(url, timeout=10) assert r.ok assert next( @@ -1734,38 +1950,56 @@ def test_search_irregular(searchterm: str, title: str) -> None: ) == title +@pytest.mark.asyncio @pytest.mark.parametrize("animalName", list(misc.animalList) + ["dog"]) -def test_animal_with_goodUrl(animalName: str) -> None: - assert goodURL(requests.get(misc.animal(animalName), timeout=10)) +async def test_animal_with_goodUrl(animalName: str) -> None: + url = await misc.animal(animalName) + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=10) + assert goodURL(response) -def test_animal_dog_breed() -> None: - breeds = misc.animal("dog", "breeds")[12:-1].split(", ") +@pytest.mark.asyncio +async def test_animal_dog_breed() -> None: + msg = await misc.animal("dog", "breeds") + breeds = msg[12:-1].split(", ") assert len(breeds) == 107 - assert goodURL( - requests.get(misc.animal("dog", choice(breeds)), timeout=10) - ) - assert misc.animal("dog", "invalidbreed").startswith("Breed not") - assert misc.animal("dog", "invalidbreed1234").startswith("Breed not") - assert goodURL(requests.get(misc.animal("dog", "moose"), timeout=10)) + # TODO: remove randomness + url = await misc.animal("dog", choice(breeds)) + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=10) + assert goodURL(response) + msg = await misc.animal("dog", "invalidbreed") + assert msg.startswith("Breed not") -def test_invalid_animal_throws_exception() -> None: + msg = await misc.animal("dog", "invalidbreed1234") + assert msg.startswith("Breed not") + + url = await misc.animal("dog", "moose") + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=10) + assert goodURL(response) + + +@pytest.mark.asyncio +async def test_invalid_animal_throws_exception() -> None: with pytest.raises(ValueError): - misc.animal("invalidAnimal") + await misc.animal("invalidAnimal") -@responses.activate -def test_dog_api_down_throws_exception( - caplog: pytest.LogCaptureFixture +@pytest.mark.asyncio +async def test_dog_api_down_throws_exception( # type: ignore + caplog: pytest.LogCaptureFixture, + httpx_mock ) -> None: - responses.get( - "https://dog.ceo/api/breeds/image/random", - status=522 + httpx_mock.add_response( + url="https://dog.ceo/api/breeds/image/random", + status_code=522 ) - with pytest.raises(requests.exceptions.RequestException): - misc.animal("dog") + with pytest.raises(httpx.RequestError): + await misc.animal("dog") assert len(caplog.records) == 9 assert caplog.records[8].msg == "Dog API trying again, call 9" @@ -1807,7 +2041,6 @@ async def test_handleMessages() -> None: u = MockUser() u.bot = True m = MockMessage(author=u) - assert await Bot.handleMessages(m) == -1 u.bot = False @@ -1817,28 +2050,31 @@ async def test_handleMessages() -> None: assert await Bot.handleMessages(MockMessage(guild=MockGuild())) == 1 u = MockUser(name="bar", roles=[]) - g = MockGuild(members=[u], channels=[MockChannel(name="infractions")]) + ch = MockChannel(name="infractions") + g = MockGuild(members=[u], channels=[ch]) m = MockMessage( content="http://dizcort.com free nitro!", guild=g, author=u ) assert len(u.roles) == 0 - assert len(g.channels[0].history()) == 0 + assert len(list(ch.history())) == 0 assert await Bot.handleMessages(m) == -1 assert len(u.roles) == 1 - assert len(g.channels[0].history()) == 1 + assert len(list(ch.history())) == 1 @pytest.mark.asyncio async def test_cmdGuide() -> None: Bot.bot = MockBot(Bot.bot) - ctx = MockContext(Bot.bot, author=MockUser()) + ctx = MockContext(Bot.bot, author=MockUser(), guild=MockGuild()) ctx.message.type = nextcord.MessageType.default - assert await Bot.cmdGuide(ctx) == 0 + assert ctx.guild is not None ctx.guild.id = 442403231864324119 assert await Bot.cmdGuide(ctx) == 1 - assert ctx.history()[0].embeds[0].title == "The Eggsoup Improvement Guide" + assert next( + ctx.history() + ).embeds[0].title == "The Eggsoup Improvement Guide" @pytest.mark.asyncio @@ -1847,16 +2083,14 @@ async def test_cmdMute() -> None: ctx = MockContext( Bot.bot, message=MockMessage(content="!mute foo"), - author=MockUser(adminPowers=True) + author=MockUser(adminPowers=True), + guild=MockGuild() ) - # if the MemberConverter fails assert await Bot.cmdMute(ctx, "foo") == 0 # if trying to mute the bot - assert await Bot.cmdMute( - ctx, MockUser(id=bbId, guild=MockGuild()).mention - ) == 0 + assert await Bot.cmdMute(ctx, f"<@{bbId}>") == 0 # if no target assert await Bot.cmdMute(ctx, None) == 0 @@ -1881,10 +2115,12 @@ async def test_thread_creation_does_not_invoke_commands() -> None: assert await command(ctx) == -1 -@responses.activate -def test_getRank_monkeypatched_for_2s_top_rating() -> None: - responses.get( - "https://api.brawlhalla.com/player/1/ranked?api_key=" + str(brawlKey), +@pytest.mark.asyncio +async def test_getRank_monkeypatched_for_2s_top_rating( # type: ignore + httpx_mock +) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/ranked?api_key=foo", json={ "name": "Foo", "region": "us-east-1", @@ -1904,22 +2140,40 @@ def test_getRank_monkeypatched_for_2s_top_rating() -> None: try: brawl.claimProfile(196354892208537600, 1) - emb = brawl.getRank(MockUser(id=196354892208537600), brawlKey) + emb = await brawl.getRank(MockUser(id=196354892208537600), "foo") assert emb.fields[0].name == "Ranked 2s" assert isinstance(emb.fields[0].value, str) assert emb.fields[0].value.startswith("**Foo+Bar") assert emb.fields[0].value.endswith("50.0% winrate") + assert isinstance(emb.color, nextcord.Colour) assert emb.color.value == 20916 finally: brawl.claimProfile(196354892208537600, 7032472) +@pytest.mark.asyncio +async def test_brawlApiCall_returns_None_when_never_played( # type: ignore + httpx_mock +) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/search?steamid=37&api_key=foo", json=[] + ) + assert await brawl.brawlApiCall( + "search?steamid=", "37", "foo", "&" + ) is None + + with pytest.MonkeyPatch.context() as mp: + mp.setattr("steam.steamid.SteamID.from_url", lambda x: "37") + assert await brawl.getBrawlId("foo", "foo.bar") is None + + # Tests for commands that require a Brawlhalla API key: if brawlKey: - def test_randomBrawl() -> None: - weapon = brawl.randomBrawl("weapon") - assert weapon is not None + @pytest.mark.asyncio + async def test_randomBrawl() -> None: + assert brawlKey is not None + weapon = await brawl.randomBrawl("weapon") assert weapon.title == "Random Weapon" assert weapon.thumbnail.url is not None assert weapon.description is not None @@ -1928,21 +2182,22 @@ def test_randomBrawl() -> None: in weapon.thumbnail.url.lower().replace("guantlet", "gauntlet") ) - legend = brawl.randomBrawl("legend") - assert legend is not None + legend = await brawl.randomBrawl("legend") assert legend.title == "Random Legend" assert legend.description is not None assert legend.description.startswith("Your legend is ") - legend = brawl.randomBrawl("legend", brawlKey) - assert legend is not None + legend = await brawl.randomBrawl("legend", brawlKey) assert len(legend.fields) == 2 assert legend.title is not None - assert legend.title == brawl.legendInfo( # type: ignore + legendInfo = await brawl.legendInfo( brawlKey, legend.title.split(" ")[0].lower().replace(",", "") - ).title + ) + assert legendInfo is not None + assert legend.title == legendInfo.title - assert brawl.randomBrawl("invalidrandom").title == "Brawlhalla Randomizer" + legend = await brawl.randomBrawl("invalidrandom") + assert legend.title == "Brawlhalla Randomizer" def test_fetchBrawlID() -> None: assert brawl.fetchBrawlId(196354892208537600) == 7032472 @@ -1960,6 +2215,7 @@ def test_claimProfile() -> None: brawl.claimProfile(196354892208537600, 7032472) assert brawl.fetchBrawlId(196354892208537600) == 7032472 + @pytest.mark.asyncio @pytest.mark.parametrize( "url, result", [ ("https://steamcommunity.com/id/beardless", 7032472), @@ -1967,97 +2223,117 @@ def test_claimProfile() -> None: ("https://steamcommunity.com/badurl", None) ] ) - def test_getBrawlID(url: str, result: Optional[int]) -> None: + async def test_getBrawlId(url: str, result: typing.Optional[int]) -> None: + assert brawlKey is not None sleep(2) - assert brawl.getBrawlId(brawlKey, url) == result + assert await brawl.getBrawlId(brawlKey, url) == result - def test_getRank() -> None: + @pytest.mark.asyncio + async def test_getRank() -> None: + assert brawlKey is not None sleep(5) user = MockUser(id=0) - assert brawl.getRank(user, brawlKey).description == ( - brawl.unclaimed.format(user.mention) - ) + rank = await brawl.getRank(user, brawlKey) + assert rank.description == brawl.unclaimed.format("<@0>") + user.id = 196354892208537600 - assert brawl.getRank(user, brawlKey).footer.text == "Brawl ID 7032472" - assert brawl.getRank(user, brawlKey).description == ( + rank = await brawl.getRank(user, brawlKey) + assert rank.footer.text == "Brawl ID 7032472" + + rank = await brawl.getRank(user, brawlKey) + assert rank.description == ( "You haven't played ranked yet this season." ) + try: brawl.claimProfile(196354892208537600, 37) - assert brawl.getRank(user, brawlKey).color.value == 16306282 + rank = await brawl.getRank(user, brawlKey) + assert isinstance(rank.color, nextcord.Colour) + assert rank.color.value == 16306282 finally: brawl.claimProfile(196354892208537600, 7032472) - def test_getLegends() -> None: + @pytest.mark.asyncio + async def test_getLegends() -> None: + assert brawlKey is not None sleep(5) oldLegends = brawl.fetchLegends() - brawl.getLegends(brawlKey) + await brawl.getLegends(brawlKey) assert brawl.fetchLegends() == oldLegends - def test_legendInfo() -> None: + @pytest.mark.asyncio + async def test_legendInfo() -> None: + assert brawlKey is not None sleep(5) - legend = brawl.legendInfo(brawlKey, "hugin") + legend = await brawl.legendInfo(brawlKey, "hugin") assert legend is not None assert legend.title == "Munin, The Raven" assert legend.thumbnail.url == ( - "https://cms.brawlhalla.com/c/uploads/2021/12/a_Roster_Pose_BirdBardM.png" + "https://cms.brawlhalla.com/c/uploads/" + "2021/12/a_Roster_Pose_BirdBardM.png" ) - legend = brawl.legendInfo(brawlKey, "teros") + legend = await brawl.legendInfo(brawlKey, "teros") assert legend is not None assert legend.title == "Teros, The Minotaur" assert legend.thumbnail.url == ( "https://cms.brawlhalla.com/c/uploads/2021/07/teros.png" ) - legend = brawl.legendInfo(brawlKey, "redraptor") + legend = await brawl.legendInfo(brawlKey, "redraptor") assert legend is not None assert legend.title == "Red Raptor, The Last Sentai" assert legend.thumbnail.url == ( - "https://cms.brawlhalla.com/c/uploads/2023/06/a_Roster_Pose_SentaiM.png" + "https://cms.brawlhalla.com/c/uploads/" + "2023/06/a_Roster_Pose_SentaiM.png" ) - assert not brawl.legendInfo(brawlKey, "invalidname") + assert not await brawl.legendInfo(brawlKey, "invalidname") - def test_getStats() -> None: + @pytest.mark.asyncio + async def test_getStats() -> None: + assert brawlKey is not None sleep(5) user = MockUser(id=0) - assert brawl.getStats(user, brawlKey).description == ( - brawl.unclaimed.format(user.mention) - ) + stats = await brawl.getStats(user, brawlKey) + assert stats.description == brawl.unclaimed.format("<@0>") + user.id = 196354892208537600 try: brawl.claimProfile(196354892208537600, 7032472) - emb = brawl.getStats(user, brawlKey) + emb = await brawl.getStats(user, brawlKey) assert emb.footer.text == "Brawl ID 7032472" assert len(emb.fields) in (3, 4) + brawl.claimProfile(196354892208537600, 1247373426) - emb = brawl.getStats(user, brawlKey) + emb = await brawl.getStats(user, brawlKey) assert emb.description is not None - assert emb.description.startswith("This profile doesn't have stats") + assert emb.description.startswith( + "This profile doesn't have stats" + ) finally: brawl.claimProfile(196354892208537600, 7032472) - def test_getClan() -> None: + @pytest.mark.asyncio + async def test_getClan() -> None: + assert brawlKey is not None sleep(5) user = MockUser(id=0) - assert brawl.getClan(user, brawlKey).description == ( - brawl.unclaimed.format(user.mention) - ) + clan = await brawl.getClan(user, brawlKey) + assert clan.description == brawl.unclaimed.format("<@0>") + user.id = 196354892208537600 try: brawl.claimProfile(196354892208537600, 7032472) - assert brawl.getClan( - user, brawlKey - ).title == "DinersDriveInsDives" + clan = await brawl.getClan(user, brawlKey) + assert clan.title == "DinersDriveInsDives" + brawl.claimProfile(196354892208537600, 5895238) - assert brawl.getClan( - user, brawlKey - ).description == "You are not in a clan!" + clan = await brawl.getClan(user, brawlKey) + assert clan.description == "You are not in a clan!" finally: brawl.claimProfile(196354892208537600, 7032472) def test_brawlCommands() -> None: - sleep(5) assert len(brawl.brawlCommands().fields) == 6 diff --git a/brawl.py b/brawl.py index 2f25ffe..343ae0e 100644 --- a/brawl.py +++ b/brawl.py @@ -1,14 +1,16 @@ """Beardless Bot Brawlhalla methods""" from datetime import datetime -from json import dump, load, loads +from json import dump, dumps, load, loads from random import choice -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Optional, Union +import aiofiles +import httpx import requests from bs4 import BeautifulSoup from nextcord import Colour, Embed, Member -from steam.steamid import from_url # type: ignore +from steam.steamid import SteamID # type: ignore from misc import bbEmbed, fetchAvatar @@ -69,26 +71,28 @@ ) -def getBrawlData() -> Dict[str, Dict[str, List[Dict[str, Union[str, Dict]]]]]: +def getBrawlData() -> dict[ + str, dict[str, list[dict[str, Union[str, dict[str, str]]]]] +]: # TODO: unit test - soup = BeautifulSoup( - requests.get( - "https://brawlhalla.com/legends", timeout=10 - ).content.decode("utf-8"), - "html.parser" - ) - return loads(loads(soup.findAll("script")[3].contents[0])["body"])["data"] + r = requests.get("https://brawlhalla.com/legends", timeout=10) + soup = BeautifulSoup(r.content.decode("utf-8"), "html.parser") + brawlDict = loads( + loads(soup.findAll("script")[3].contents[0])["body"] + )["data"] + assert isinstance(brawlDict, dict) + return brawlDict data = getBrawlData() -def brawlWinRate(j: Dict[str, int]) -> float: +def brawlWinRate(j: dict[str, int]) -> float: return round(j["wins"] / j["games"] * 100, 1) def pingMsg(target: str, h: int, m: int, s: int) -> str: - def plural(t: int): + def plural(t: int) -> str: return "" if t == 1 else "s" return ( @@ -98,7 +102,7 @@ def plural(t: int): ) -def randomBrawl(ranType: str, key: Optional[str] = None) -> Embed: +async def randomBrawl(ranType: str, key: Optional[str] = None) -> Embed: if ranType in ("legend", "weapon"): if ranType == "legend": legends = tuple( @@ -106,7 +110,7 @@ def randomBrawl(ranType: str, key: Optional[str] = None) -> Embed: for legend in fetchLegends() ) if key: - emb = legendInfo(key, choice(legends).lower()) + emb = await legendInfo(key, choice(legends).lower()) assert isinstance(emb, Embed) return emb return bbEmbed( @@ -122,7 +126,7 @@ def randomBrawl(ranType: str, key: Optional[str] = None) -> Embed: ) -def claimProfile(discordId: int, brawlId: int): +def claimProfile(discordId: int, brawlId: int) -> None: with open("resources/claimedProfs.json") as f: profs = load(f) profs[str(discordId)] = brawlId @@ -134,37 +138,51 @@ def fetchBrawlId(discordId: int) -> Optional[int]: with open("resources/claimedProfs.json") as f: for key, value in load(f).items(): if key == str(discordId): + assert isinstance(value, int) return value return None -def fetchLegends() -> List[Dict[str, Union[str, int]]]: +def fetchLegends() -> list[dict[str, Union[str, int]]]: with open("resources/legends.json") as f: - return load(f) + legends = load(f) + assert isinstance(legends, list) + return legends -def brawlApiCall( +async def brawlApiCall( route: str, arg: str, key: str, amp: str = "?" -) -> Dict[str, Any]: +) -> Union[dict[str, Any], list[dict[str, Union[str, int]]], None]: url = f"https://api.brawlhalla.com/{route}{arg}{amp}api_key={key}" - return requests.get(url, timeout=10).json() + async with httpx.AsyncClient() as client: + r = await client.get(url, timeout=10) + j = r.json() + if len(j) == 0: + return None + assert isinstance(j, (dict, list)) + return j -def getBrawlId(brawlKey: str, profileUrl: str) -> Optional[int]: +async def getBrawlId(brawlKey: str, profileUrl: str) -> Optional[int]: if ( not isinstance(profileUrl, str) - or not (steamID := from_url(profileUrl)) + or not (steamId := SteamID.from_url(profileUrl)) ): return None - return brawlApiCall( - "search?steamid=", steamID, brawlKey, "&" - )["brawlhalla_id"] + response = await brawlApiCall("search?steamid=", steamId, brawlKey, "&") + if not isinstance(response, dict): + return None + brawlId = response["brawlhalla_id"] + assert isinstance(brawlId, int) + return brawlId -def getLegends(brawlKey: str) -> None: +async def getLegends(brawlKey: str) -> None: # run whenever a new legend is released - with open("resources/legends.json", "w") as f: - dump(brawlApiCall("legend/", "all/", brawlKey), f, indent=4) + async with aiofiles.open("resources/legends.json", "w") as f: + j = await brawlApiCall("legend/", "all/", brawlKey) + assert isinstance(j, list) + await f.write(dumps(j, indent=4)) def getLegendPicture(legendName: str) -> str: @@ -181,13 +199,16 @@ def getWeaponPicture(weaponName: str) -> str: return weapon[0]["weaponFields"]["icon"]["sourceUrl"] # type: ignore -def legendInfo(brawlKey: str, legendName: str) -> Optional[Embed]: +async def legendInfo(brawlKey: str, legendName: str) -> Optional[Embed]: if legendName == "hugin": legendName = "munin" for legend in fetchLegends(): assert isinstance(legend["legend_name_key"], str) if legendName in legend["legend_name_key"]: - r = brawlApiCall("legend/", str(legend["legend_id"]) + "/", brawlKey) + r = await brawlApiCall( + "legend/", str(legend["legend_id"]) + "/", brawlKey + ) + assert isinstance(r, dict) def cleanQuote(quote: str, attrib: str) -> str: return "{} *{}*".format( @@ -222,31 +243,26 @@ def cleanQuote(quote: str, attrib: str) -> str: return None -def getRank(target: Member, brawlKey: str) -> Embed: +async def getRank(target: Member, brawlKey: str) -> Embed: if not (brawlId := fetchBrawlId(target.id)): return bbEmbed( "Beardless Bot Brawlhalla Rank", unclaimed.format(target.mention) ) - if ( - len(r := brawlApiCall("player/", str(brawlId) + "/ranked", brawlKey)) < 4 - or ( - ("games" in r and r["games"] == 0) - and ("2v2" in r and len(r["2v2"]) == 0) - ) + r = await brawlApiCall("player/", str(brawlId) + "/ranked", brawlKey) + assert isinstance(r, dict) + if len(r) < 4 or ( + ("games" in r and r["games"] == 0) + and ("2v2" in r and len(r["2v2"]) == 0) ): - return ( - bbEmbed( - "Beardless Bot Brawlhalla Rank", - "You haven't played ranked yet this season." - ) - .set_footer(text=f"Brawl ID {brawlId}") - .set_author(name=target, icon_url=fetchAvatar(target)) + return bbEmbed( + "Beardless Bot Brawlhalla Rank", + "You haven't played ranked yet this season." + ).set_footer(text=f"Brawl ID {brawlId}").set_author( + name=target, icon_url=fetchAvatar(target) ) - emb = ( - bbEmbed(f"{r['name']}, {r['region']}") - .set_footer(text=f"Brawl ID {brawlId}") - .set_author(name=target, icon_url=fetchAvatar(target)) - ) + emb = bbEmbed(f"{r['name']}, {r['region']}").set_footer( + text=f"Brawl ID {brawlId}" + ).set_author(name=target, icon_url=fetchAvatar(target)) if "games" in r and r["games"] != 0: winRate = brawlWinRate(r) embVal = ( @@ -298,13 +314,13 @@ def getRank(target: Member, brawlKey: str) -> Embed: return emb -def getStats(target: Member, brawlKey: str) -> Embed: +async def getStats(target: Member, brawlKey: str) -> Embed: - def getTopDps(legend: Dict[str, Any]) -> Tuple[str, float]: + def getTopDps(legend: dict[str, Any]) -> tuple[str, float]: dps = round(int(legend["damagedealt"]) / legend["matchtime"], 1) return (legend["legend_name_key"].title(), dps) - def getTopTtk(legend: Dict[str, Any]) -> Tuple[str, float]: + def getTopTtk(legend: dict[str, Any]) -> tuple[str, float]: ttk = round(legend["matchtime"] / legend["kos"], 1) return (legend["legend_name_key"].title(), ttk) @@ -312,23 +328,23 @@ def getTopTtk(legend: Dict[str, Any]) -> Tuple[str, float]: return bbEmbed( "Beardless Bot Brawlhalla Stats", unclaimed.format(target.mention) ) - if len(r := brawlApiCall("player/", str(brawlId) + "/stats", brawlKey)) < 4: + r = await brawlApiCall("player/", str(brawlId) + "/stats", brawlKey) + if r is None or len(r) < 4: noStats = ( "This profile doesn't have stats associated with it." " Please make sure you've claimed the correct profile." ) return bbEmbed("Beardless Bot Brawlhalla Stats", noStats) - embVal = ( + assert isinstance(r, dict) + winLoss = ( f"{r['wins']} Wins / {r['games'] - r['wins']} Losses" f"\n{r['games']} Games\n{brawlWinRate(r)}% Winrate" ) - emb = ( - bbEmbed("Brawlhalla Stats for " + r["name"]) - .set_footer(text=f"Brawl ID {brawlId}") - .add_field(name="Name", value=r["name"]) - .add_field(name="Overall W/L", value=embVal) - .set_author(name=target, icon_url=fetchAvatar(target)) - ) + emb = bbEmbed("Brawlhalla Stats for " + r["name"]).set_footer( + text=f"Brawl ID {brawlId}" + ).add_field(name="Name", value=r["name"]).add_field( + name="Overall W/L", value=winLoss + ).set_author(name=target, icon_url=fetchAvatar(target)) if "legends" in r: topUsed = topWinrate = topDps = topTtk = None for legend in r["legends"]: @@ -368,7 +384,7 @@ def getTopTtk(legend: Dict[str, Any]) -> Tuple[str, float]: return emb -def getClan(target: Member, brawlKey: str) -> Embed: +async def getClan(target: Member, brawlKey: str) -> Embed: if not (brawlId := fetchBrawlId(target.id)): return bbEmbed( "Beardless Bot Brawlhalla Clan", unclaimed.format(target.mention) @@ -376,12 +392,14 @@ def getClan(target: Member, brawlKey: str) -> Embed: # Takes two API calls: one to get clan ID from player stats, # one to get clan from clan ID. As a result, this command is very slow. # TODO: Try to find a way around this. - r = brawlApiCall("player/", str(brawlId) + "/stats", brawlKey) + r = await brawlApiCall("player/", str(brawlId) + "/stats", brawlKey) + assert isinstance(r, dict) if "clan" not in r: return bbEmbed( "Beardless Bot Brawlhalla Clan", "You are not in a clan!" ) - r = brawlApiCall("clan/", str(r["clan"]["clan_id"]) + "/", brawlKey) + r = await brawlApiCall("clan/", str(r["clan"]["clan_id"]) + "/", brawlKey) + assert isinstance(r, dict) emb = bbEmbed( r["clan_name"], "**Clan Created:** {}\n**Experience:** {}\n**Members:** {}".format( @@ -393,8 +411,8 @@ def getClan(target: Member, brawlKey: str) -> Embed: for i in range(min(len(r["clan"]), 9)): member = r["clan"][i] val = ( - f"{member['rank']} ({member['xp']} xp)\n" - "Joined " + str(datetime.fromtimestamp(member["join_date"]))[:-9] + f"{member['rank']} ({member['xp']} xp)\nJoined " + + str(datetime.fromtimestamp(member["join_date"]))[:-9] ) emb.add_field(name=member["name"], value=val) return emb diff --git a/bucks.py b/bucks.py index 467a623..8ab7f9c 100644 --- a/bucks.py +++ b/bucks.py @@ -1,10 +1,10 @@ """Beardless Bot methods that modify resources/money.csv""" import csv +import random from collections import OrderedDict from operator import itemgetter -from random import choice, randint -from typing import Dict, List, Optional, Tuple, Union +from typing import Optional, Union import nextcord @@ -75,8 +75,8 @@ def __init__( ) -> None: """ Create a new BlackjackGame instance. In order to simulate the dealer - standing on a soft 17, the dealer's sum will be incremented by a random card - value until reaching 17. + standing on a soft 17, the dealer's sum will be incremented by a + random card value until reaching 17. Args: user (nextcord.User or Member): The user who is playing this game @@ -85,11 +85,11 @@ def __init__( """ self.user = user self.bet = bet - self.cards: List[int] = [] - self.dealerUp = randint(2, 11) + self.cards: list[int] = [] + self.dealerUp = random.randint(2, 11) self.dealerSum = self.dealerUp while self.dealerSum < 17: - self.dealerSum += randint(1, 10) + self.dealerSum += random.randint(1, 10) self.message = self.startingHand() def perfect(self) -> bool: @@ -111,10 +111,11 @@ def startingHand( str: the message to show the user """ - self.cards.append(choice(BlackjackGame.cardVals)) - self.cards.append(choice(BlackjackGame.cardVals)) + self.cards.append(random.choice(BlackjackGame.cardVals)) + self.cards.append(random.choice(BlackjackGame.cardVals)) message = ( - f"Your starting hand consists of {BlackjackGame.cardName(self.cards[0])}" + "Your starting hand consists of" + f" {BlackjackGame.cardName(self.cards[0])}" f" and {BlackjackGame.cardName(self.cards[1])}." f" Your total is {sum(self.cards)}. " ) @@ -150,15 +151,15 @@ def deal(self, debug: bool = False) -> str: str: the message to show the user """ - dealt = choice(BlackjackGame.cardVals) + dealt = random.choice(BlackjackGame.cardVals) self.cards.append(dealt) self.message = ( f"You were dealt {BlackjackGame.cardName(dealt)}," f" bringing your total to {sum(self.cards)}. " ) if 11 in self.cards and self.checkBust(): - for i in range(len(self.cards)): - if self.cards[i] == 11: + for i, card in enumerate(self.cards): + if card == 11: self.cards[i] = 1 self.bet *= -1 break @@ -226,7 +227,7 @@ def stay(self) -> int: def cardName(card: int) -> str: """Return the human-friendly name of a card based on int value.""" if card == 10: - return "a " + choice(("10", "Jack", "Queen", "King")) + return "a " + random.choice(("10", "Jack", "Queen", "King")) if card == 11: return "an Ace" if card == 8: @@ -239,7 +240,7 @@ def writeMoney( amount: Union[str, int], writing: bool, adding: bool -) -> Tuple[int, Union[str, int, None]]: +) -> tuple[int, Union[str, int, None]]: """ Check or modify a user's BeardlessBucks balance. @@ -267,7 +268,9 @@ def writeMoney( if str(member.id) == row[0]: # found member if isinstance(amount, str): # for people betting all amount = -int(row[1]) if amount == "-all" else int(row[1]) - newBank: Union[str, int] = str(int(row[1]) + amount if adding else amount) + newBank: Union[str, int] = str( + int(row[1]) + amount if adding else amount + ) if writing and row[1] != newBank: if int(row[1]) + amount < 0: # Don't have enough to bet that much: @@ -280,17 +283,14 @@ def writeMoney( newLine = ",".join((row[0], row[1], str(member))) newBank = int(row[1]) result = 0 - with open("resources/money.csv") as oldMoney: - newMoney = ( - "".join([i for i in oldMoney]) - .replace(",".join(row), newLine) - ) - with open("resources/money.csv", "w") as money: - money.writelines(newMoney) + with open("resources/money.csv") as f: + money = "".join(list(f)).replace(",".join(row), newLine) + with open("resources/money.csv", "w") as f: + f.writelines(money) return result, newBank - with open("resources/money.csv", "a") as money: - money.write(f"\r\n{member.id},300,{member}") + with open("resources/money.csv", "a") as f: + f.write(f"\r\n{member.id},300,{member}") return ( 2, ( @@ -312,13 +312,10 @@ def register(target: Union[nextcord.User, nextcord.Member]) -> nextcord.Embed: """ result, bonus = writeMoney(target, 300, False, False) - if result in (-1, 2): - report = bonus - else: - report = ( - "You are already in the system! Hooray! You" - f" have {bonus} BeardlessBucks, {target.mention}." - ) + report = bonus if result in (-1, 2) else ( + "You are already in the system! Hooray! You" + f" have {bonus} BeardlessBucks, {target.mention}." + ) assert isinstance(report, str) return bbEmbed("BeardlessBucks Registration", report) @@ -367,13 +364,10 @@ def reset(target: Union[nextcord.User, nextcord.Member]) -> nextcord.Embed: """ result, bonus = writeMoney(target, 200, True, False) - if result in (-1, 2): - report = bonus - else: - report = ( - "You have been reset to 200" - f" BeardlessBucks, {target.mention}." - ) + report = bonus if result in (-1, 2) else ( + "You have been reset to 200" + f" BeardlessBucks, {target.mention}." + ) assert isinstance(report, str) return bbEmbed("BeardlessBucks Reset", report) @@ -400,7 +394,7 @@ def leaderboard( reports target's position and balance. """ - lbDict: Dict[str, int] = {} + lbDict: dict[str, int] = {} emb = bbEmbed("BeardlessBucks Leaderboard") if (target and msg and not ( isinstance(target, (nextcord.User, nextcord.Member)) @@ -413,6 +407,7 @@ def leaderboard( lbDict[row[2]] = int(row[1]) # Sort by value for each key in lbDict, which is BeardlessBucks balance sortedDict = OrderedDict(sorted(lbDict.items(), key=itemgetter(1))) + pos = targetBal = None if target: users = list(sortedDict.keys()) try: @@ -422,18 +417,14 @@ def leaderboard( pos = None for i in range(min(len(sortedDict), 10)): head, body = sortedDict.popitem() - lastEntry = (i != min(len(sortedDict), 10) - 1) + lastEntry: bool = (i != min(len(sortedDict), 10) - 1) emb.add_field( name=f"{i + 1}. {head.split('#')[0]}", value=body, inline=lastEntry ) 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=targetBal - ) + emb.add_field(name=f"{target.name}'s position:", value=pos) + emb.add_field(name=f"{target.name}'s balance:", value=targetBal) return emb @@ -451,7 +442,7 @@ def flip( str: A report of the outcome and how author's balance changed. """ - heads = randint(0, 1) + heads = random.randint(0, 1) report = ( "Invalid bet. Please choose a number greater than or equal" " to 0, or enter \"all\" to bet your whole balance, {}." @@ -502,7 +493,7 @@ def flip( def blackjack( author: Union[nextcord.User, nextcord.Member], bet: Union[str, int] -) -> Tuple[str, Optional[BlackjackGame]]: +) -> tuple[str, Optional[BlackjackGame]]: """ Gambles a certain number of BeardlessBucks on blackjack. @@ -554,13 +545,13 @@ def blackjack( def activeGame( - games: List[BlackjackGame], author: Union[nextcord.User, nextcord.Member] + games: list[BlackjackGame], author: Union[nextcord.User, nextcord.Member] ) -> Optional[BlackjackGame]: """ Check if a user has an active game of Blackjack. Args: - games (List[BlackjackGame]): list of active Blackjack games + games (list[BlackjackGame]): list of active Blackjack games author (nextcord.User or Member): The user who is gambling Returns: diff --git a/logs.py b/logs.py index 08b165b..42ddd6b 100644 --- a/logs.py +++ b/logs.py @@ -1,6 +1,6 @@ """Beadless Bot event logging methods""" -from typing import List, Optional +from typing import Optional import nextcord @@ -23,10 +23,10 @@ def logDeleteMsg(msg: nextcord.Message) -> nextcord.Embed: def logPurge( - msg: nextcord.Message, msgList: List[nextcord.Message] + msg: nextcord.Message, msgList: list[nextcord.Message] ) -> nextcord.Embed: - def purgeReport(msgList: List[nextcord.Message]) -> str: + def purgeReport(msgList: list[nextcord.Message]) -> str: return "99+" if len(msgList) > 99 else str(len(msgList) - 1) assert isinstance( @@ -65,7 +65,7 @@ def logEditMsg( def logClearReacts( - msg: nextcord.Message, reactions: List[nextcord.Reaction] + msg: nextcord.Message, reactions: list[nextcord.Reaction] ) -> nextcord.Embed: assert isinstance( msg.channel, (nextcord.abc.GuildChannel, nextcord.Thread) @@ -116,7 +116,9 @@ def logMemberJoin(member: nextcord.Member) -> nextcord.Embed: def logMemberRemove(member: nextcord.Member) -> nextcord.Embed: emb = bbEmbed( "", f"Member {member.mention} left\nID: {member.id}", 0xFF0000, True - ).set_author(name=f"{member} left the server", icon_url=fetchAvatar(member)) + ).set_author( + name=f"{member} left the server", icon_url=fetchAvatar(member) + ) if len(member.roles) > 1: emb.add_field( name="Roles:", diff --git a/misc.py b/misc.py index 60c4e1f..2d419c1 100644 --- a/misc.py +++ b/misc.py @@ -5,9 +5,10 @@ from datetime import datetime from json import loads from random import choice, randint -from typing import Dict, List, Optional, Tuple, Union +from typing import Optional, Union from urllib.parse import quote_plus +import httpx import nextcord import requests from bs4 import BeautifulSoup @@ -79,17 +80,19 @@ scamDelete = "**Deleted possible nitro scam link. Alerting mods.**" joinMsg = ( - "Thanks for adding me to {}! There are a few things you can do to unlock " - "my full potential.\nIf you want event logging, make a channel named " - "#bb-log.\nIf you want a region-based sparring system, make a channel " - "named #looking-for-spar.\nIf you want special color roles, purchasable " - "with BeardlessBucks, create roles named special red/blue/orange/pink.\n" - "Don't forget to move my {} role up to the top of the role hierarchy in " - "order to allow me to moderate all users." + "Thanks for adding me to {}! There are a few things you can do to unlock" + " my full potential.\nIf you want event logging, make a channel named" + " #bb-log.\nIf you want a region-based sparring system, make a channel" + " named #looking-for-spar.\nIf you want special color roles, purchasable" + " with BeardlessBucks, create roles named special red/blue/orange/pink." + "\nDon't forget to move my {} role up to the top of the role hierarchy in" + " order to allow me to moderate all users." ) msgMaxLength = "**Message length exceeds 1024 characters.**" +botContext = commands.Context[commands.Bot] + # Wrapper for nextcord.Embed() that defaults to # commonly-used values and is easier to call @@ -115,13 +118,13 @@ def contCheck(msg: nextcord.Message) -> str: return "**Embed**" -def logException(e: Exception, ctx: commands.Context) -> None: +def logException(e: Exception, ctx: botContext) -> None: """ Act as a wrapper for logging.error to help with debugging. Args: e (Exception): The exception to log - ctx (commands.Context): The command invocation context + ctx (botContext): The command invocation context """ logging.error( @@ -186,7 +189,7 @@ def memSearch( semiMatch = member if member.nick and term == member.nick.lower() and not semiMatch: looseMatch = member - if not (semiMatch or looseMatch) and term in member.name.lower(): + elif not (semiMatch or looseMatch) and term in member.name.lower(): looseMatch = member return semiMatch if semiMatch else looseMatch @@ -229,13 +232,14 @@ def fetchAvatar( return user.default_avatar.url -def animal(animalType: str, breed: Optional[str] = None) -> str: - r: Optional[requests.Response] = None +async def animal(animalType: str, breed: Optional[str] = None) -> str: + r: Optional[httpx.Response] = None if "moose" in (animalType, breed): - r = requests.get( - "https://github.com/LevBernstein/moosePictures/", timeout=10 - ) + async with httpx.AsyncClient() as client: + r = await client.get( + "https://github.com/LevBernstein/moosePictures/", timeout=10 + ) if r.status_code == 200: soup = BeautifulSoup(r.content.decode("utf-8"), "html.parser") moose = choice( @@ -256,70 +260,96 @@ def animal(animalType: str, breed: Optional[str] = None) -> str: if i != 0: logging.error(f"Dog API trying again, call {i}") if not breed: - r = requests.get( - "https://dog.ceo/api/breeds/image/random", timeout=10 - ) + async with httpx.AsyncClient() as client: + r = await client.get( + "https://dog.ceo/api/breeds/image/random", timeout=10 + ) if r.status_code == 200: - return r.json()["message"] + message = r.json()["message"] + assert isinstance(message, str) + return message elif breed.startswith("breed"): - r = requests.get( - "https://dog.ceo/api/breeds/list/all", timeout=10 - ) + async with httpx.AsyncClient() as client: + r = await client.get( + "https://dog.ceo/api/breeds/list/all", timeout=10 + ) if r.status_code == 200: return "Dog breeds: {}.".format( ", ".join(dog for dog in r.json()["message"]) ) elif breed.isalpha(): - r = requests.get( - "https://dog.ceo/api/breed/" + breed + "/images/random", - timeout=10 + async with httpx.AsyncClient() as client: + r = await client.get( + f"https://dog.ceo/api/breed/{breed}/images/random", + timeout=10 + ) + message = ( + r.json()["message"] if "message" in r.json() else None ) if ( r.status_code == 200 - and "message" in r.json() + and isinstance(message, str) and not r.json()["message"].startswith("Breed not found") ): - return r.json()["message"] + return message return "Breed not found! Do !dog breeds to see all breeds." else: return "Breed not found! Do !dog breeds to see all breeds." elif animalType == "cat": - r = requests.get( - "https://api.thecatapi.com/v1/images/search", timeout=10 - ) + async with httpx.AsyncClient() as client: + r = await client.get( + "https://api.thecatapi.com/v1/images/search", timeout=10 + ) if r.status_code == 200: - return r.json()[0]["url"] + url = r.json()[0]["url"] + assert isinstance(url, str) + return url elif animalType in ("bunny", "rabbit"): - r = requests.get( - "https://api.bunnies.io/v2/loop/random/?media=gif", timeout=10 - ) + async with httpx.AsyncClient() as client: + r = await client.get( + "https://api.bunnies.io/v2/loop/random/?media=gif", timeout=10 + ) if r.status_code == 200: - return r.json()["media"]["gif"] + gif = r.json()["media"]["gif"] + assert isinstance(gif, str) + return gif elif animalType == "fox": - r = requests.get("https://randomfox.ca/floof/", timeout=10) + async with httpx.AsyncClient() as client: + r = await client.get("https://randomfox.ca/floof/", timeout=10) if r.status_code == 200: - return r.json()["image"] + image = r.json()["image"] + assert isinstance(image, str) + return image elif animalType in ("duck", "lizard"): - if animalType == "duck": - r = requests.get("https://random-d.uk/api/quack", timeout=10) - else: - r = requests.get( - "https://nekos.life/api/v2/img/lizard", timeout=10 + async with httpx.AsyncClient() as client: + r = await client.get( + ( + "https://random-d.uk/api/quack" + if animalType == "duck" + else "https://nekos.life/api/v2/img/lizard" + ), + timeout=10 ) if r.status_code == 200: - return r.json()["url"] + url = r.json()["url"] + assert isinstance(url, str) + return url elif animalType == "bear": - return f"https://placebear.com/{randint(200, 400)}/{randint(200, 400)}" + return ( + "https://placebear.com/" + f"{randint(200, 400)}/{randint(200, 400)}" + ) elif animalType == "frog": frog = choice(frogList)["name"] return ( - f"https://raw.githubusercontent.com/a9-i/frog/main/ImgSetOpt/{frog}" + "https://raw.githubusercontent.com/" + f"a9-i/frog/main/ImgSetOpt/{frog}" ) elif animalType == "seal": @@ -327,15 +357,13 @@ def animal(animalType: str, breed: Optional[str] = None) -> str: return f"https://focabot.github.io/random-seal/seals/{sealID}.jpg" if r is not None and r.status_code != 200: - raise requests.exceptions.RequestException( - f"Failed to call {animalType} Animal API" - ) + raise httpx.RequestError(f"Failed to call {animalType} Animal API") raise ValueError("Invalid Animal: " + animalType) # Amortize the cost of pulling the frog images by making one initial call. # Two possible layouts, one when formatting fails. -def getFrogList() -> List[Dict[str, str]]: +def getFrogList() -> list[dict[str, str]]: r = requests.get( "https://github.com/a9-i/frog/tree/main/ImgSetOpt", timeout=10 ) @@ -346,17 +374,20 @@ def getFrogList() -> List[Dict[str, str]]: j = loads( soup.findAll("script")[-2].text.replace("\\", "\\\\") )["payload"] - return j["tree"]["items"] + frogs = j["tree"]["items"] + assert isinstance(frogs, list) + return frogs frogList = getFrogList() -def define(word: str) -> nextcord.Embed: - r = requests.get( - "https://api.dictionaryapi.dev/api/v2/entries/en_US/" + word, - timeout=10 - ) +async def define(word: str) -> nextcord.Embed: + async with httpx.AsyncClient() as client: + r = await client.get( + "https://api.dictionaryapi.dev/api/v2/entries/en_US/" + word, + timeout=10 + ) if r.status_code == 200: j = r.json() desc = "" @@ -382,7 +413,7 @@ def define(word: str) -> nextcord.Embed: ) -def roll(text: str) -> Union[Tuple[None], Tuple[int, int, str, bool, int]]: +def roll(text: str) -> Union[tuple[None], tuple[int, int, str, bool, int]]: # Takes a string of the format mdn+b and rolls m # n-sided dice with a modifier of b. m and b are optional. diceNum: Union[str, int] @@ -441,21 +472,22 @@ def info( if target and not isinstance(target, str): # Discord occasionally reports people with an activity as # not having one; if so, go invisible and back online - emb = ( - bbEmbed( - value=target.activity.name if target.activity else "", # type: ignore - col=target.color - ) - .set_author(name=target, icon_url=fetchAvatar(target)) - .set_thumbnail(url=fetchAvatar(target)) - .add_field( - name="Registered for Discord on", - value=truncTime(target) + " UTC" - ) - .add_field( - name="Joined this server on", - value=str(target.joined_at)[:-10] + " UTC" - ) + activity = ( + target.activity.name + if target.activity and target.activity.name + else "" + ) + emb = bbEmbed( + value=activity, col=target.color + ).set_author( + name=target, icon_url=fetchAvatar(target) + ).set_thumbnail( + url=fetchAvatar(target) + ).add_field( + name="Registered for Discord on", value=truncTime(target) + " UTC" + ).add_field( + name="Joined this server on", + value=str(target.joined_at)[:-10] + " UTC" ) if len(target.roles) > 1: # Every user has the "@everyone" role, so check @@ -488,7 +520,7 @@ def av( return invalidTargetEmbed -def ctxCreatedThread(ctx: commands.Context) -> bool: +def ctxCreatedThread(ctx: botContext) -> bool: """ Threads created with the name set to a command (e.g., a thread named !flip) will trigger that command as the first action in that thread. This is not @@ -496,7 +528,7 @@ def ctxCreatedThread(ctx: commands.Context) -> bool: or a thread name being changed, this method will catch that. Args: - ctx (commands.Context): The context in which the command is being invoked + ctx botContext: The context in which the command is being invoked Returns: bool: Whether the event is valid to trigger a command. @@ -510,13 +542,15 @@ def ctxCreatedThread(ctx: commands.Context) -> bool: class bbHelpCommand(commands.HelpCommand): async def send_bot_help( - self, mapping: Dict[commands.Cog, List[commands.Command]] + self, + mapping: dict[commands.Cog, list[commands.Command]] # type: ignore ) -> int: if ctxCreatedThread(self.context): return -1 if not self.context.guild: commandNum = 15 elif self.context.author.guild_permissions.manage_messages: # type: ignore + # TODO: after switch from MockUser to MockMember, remove ignore commandNum = 20 else: commandNum = 17 @@ -527,7 +561,10 @@ async def send_bot_help( "Display a user's balance. Write just !av" " if you want to see your own balance." ), - ("!bucks", "Shows you an explanation for how BeardlessBucks work."), + ( + "!bucks", + "Shows you an explanation for how BeardlessBucks work." + ), ("!reset", "Resets you to 200 BeardlessBucks."), ("!fact", "Gives you a random fun fact."), ("!source", "Shows you the source of most facts used in !fact."), @@ -562,7 +599,8 @@ async def send_bot_help( ("!ping", "Checks Beardless Bot's latency."), ( "!buy red/blue/pink/orange", - "Removes 50k BeardlessBucks and grants you a special color role." + "Removes 50k BeardlessBucks and" + " grants you a special color role." ), ( "!info [user/username]", @@ -580,7 +618,7 @@ async def send_bot_help( emb = bbEmbed("Beardless Bot Commands") for commandPair in commandList[:commandNum]: emb.add_field(name=commandPair[0], value=commandPair[1]) - await self.get_destination().send(embed=emb) + await self.get_destination().send(embed=emb) # type: ignore return 1 async def send_error_message(self, error: str) -> None: @@ -600,10 +638,13 @@ def scamCheck(text: str) -> bool: """ msg = text.lower() - checkOne = re.compile(r"^.*https?://d\w\wc\wr(d|t)\.\w{2,4}.*").match(msg) - checkTwo = re.compile(r"^.*https?://d\w\wc\wr\wn\wtr\w\.\w{2,5}.*").match(msg) - checkThree = re.compile(r"^.*(nitro|gift|@everyone).*").match(msg) - checkFour = all(( + suspiciousLink = bool( + re.compile( + r"^.*https?://d\w\wc\wr(\wn\wtr\w\.\w{2,5}|(d|t)\.\w{2,4}).*" + ).match(msg) + ) + keyWords = bool(re.compile(r"^.*(nitro|gift|@everyone).*").match(msg)) + bulkKeyWords = all(( "http" in msg, "@everyone" in msg or "stym" in msg, any(("nitro" in msg, "discord" in msg, ".gift/" in msg)), @@ -615,11 +656,9 @@ def scamCheck(text: str) -> bool: "discocl" in msg )) )) - checkFive = re.compile(r"^.*https://discord.gift/.*").match(msg) + validGift = bool(re.compile(r"^.*https://discord.gift/.*").match(msg)) - return ( - ((bool(checkOne) or bool(checkTwo)) and bool(checkThree)) or checkFour - ) and not bool(checkFive) + return ((suspiciousLink and keyWords) or bulkKeyWords) and not validGift def onJoin(guild: nextcord.Guild, role: nextcord.Role) -> nextcord.Embed: @@ -639,12 +678,13 @@ def search(searchterm: str = "") -> nextcord.Embed: def tweet() -> str: with open("resources/eggtweets_clean.txt") as f: words = f.read().split() - chains: Dict[str, List[str]] = {} + chains: dict[str, list[str]] = {} keySize = randint(1, 2) for i in range(len(words) - keySize): if (key := " ".join(words[i:i + keySize])) not in chains: chains[key] = [] chains[key].append(words[i + keySize]) + key = s = choice(list(chains.keys())) for _i in range(randint(10, 35)): word = choice(chains[key]) @@ -673,7 +713,7 @@ def formattedTweet(eggTweet: str) -> str: " Without them, I can't do much, so I'm leaving. If you add me back to" " this server, please make sure to leave checked the box that grants me" " the Administrator permission.\nIf you have any questions, feel free" - " to contact my creator, Captain No-Beard#7511." + " to contact my creator, captainnobeard." ) noPerms = bbEmbed( @@ -685,17 +725,13 @@ def formattedTweet(eggTweet: str) -> str: "654133911558946837&permissions=8&scope=bot)" ) -inviteMsg = ( - bbEmbed( - "Want to add this bot to your server?", "[Click this link!]" + addUrl - ) - .set_thumbnail(url=prof) - .add_field( - name="If you like Beardless Bot...", - inline=False, - value="Please leave a review on [top.gg]" - "(https://top.gg/bot/654133911558946837)." - ) +inviteMsg = bbEmbed( + "Want to add this bot to your server?", "[Click this link!]" + addUrl +).set_thumbnail(url=prof).add_field( + name="If you like Beardless Bot...", + inline=False, + value="Please leave a review on [top.gg]" + "(https://top.gg/bot/654133911558946837)." ) sparDesc = ( @@ -707,27 +743,19 @@ def formattedTweet(eggTweet: str) -> str: "\nPlease use the roles channel to give yourself the correct roles." ) -sparPins = ( - bbEmbed("How to use this channel.") - .add_field( - name="To spar someone from your region:", - value=sparDesc, - inline=False - ) - .add_field( - name="If you don't want to get pings:", - inline=False, - value="Remove your region role. Otherwise, responding" - " 'no' to calls to spar is annoying and counterproductive," - " and will earn you a warning." - ) +sparPins = bbEmbed("How to use this channel.").add_field( + name="To spar someone from your region:", value=sparDesc, inline=False +).add_field( + name="If you don't want to get pings:", + inline=False, + value="Remove your region role. Otherwise, responding 'no' to calls to" + " spar is annoying and counterproductive, and will earn you a warning." ) redditEmb = bbEmbed( "The Official Eggsoup Subreddit", "https://www.reddit.com/r/eggsoup/" ).set_thumbnail(url=redditThumb) - animals = bbEmbed("Animal Photo Commands:").add_field( name="!dog", value=( diff --git a/resources/images/tests.svg b/resources/images/tests.svg index 1f59586..550aca1 100644 --- a/resources/images/tests.svg +++ b/resources/images/tests.svg @@ -1 +1 @@ -tests: 128tests128 \ No newline at end of file +tests: 130tests130 \ No newline at end of file diff --git a/resources/requirements.txt b/resources/requirements.txt index e5376a9..25d9d9f 100644 --- a/resources/requirements.txt +++ b/resources/requirements.txt @@ -4,14 +4,17 @@ beautifulsoup4==4.12.3 coverage==7.6.1 docstr-coverage==2.3.2 flake8==7.1.1 +flake8-bugbear==24.8.19 flake8-isort==6.1.1 genbadge[all]==1.1.1 -mypy[reports]==1.11.1 +httpx==0.27.2 +mypy[reports]==1.11.2 nextcord==2.6.0 pytest==8.3.2 -pytest-asyncio==0.23.8 +pytest-asyncio==0.24.0 python-dotenv==1.0.1 pytest-github-actions-annotate-failures==0.2.0 +pytest_httpx==0.30.0 requests==2.32.3 responses==0.25.3 steam==1.4.4 diff --git a/unitTests.sh b/unitTests.sh index 69a3139..7fa944d 100755 --- a/unitTests.sh +++ b/unitTests.sh @@ -28,6 +28,7 @@ docstr-coverage ./ -e ".*env/*" -v 0 --badge \ #there was an arg for significant figures. python3 -c ' import re -with open("resources/images/coverage.svg", "r") as f: svg = f.read() +with open("resources/images/coverage.svg") as f: + svg = f.read() with open("resources/images/coverage.svg", "w") as g: g.write(re.sub(r"((\.\d{2})%+)", ".00%", svg))'