class Skip(commands.Cog): def __init__(self, bot): self.bot = bot # Skip command - no args @commands.command(help="- Skip the current bird to get a new one", aliases=["sk"]) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def skip(self, ctx): logger.info("command: skip") currentBird = database.hget(f"channel:{ctx.channel.id}", "bird").decode("utf-8") database.hset(f"channel:{ctx.channel.id}", "bird", "") database.hset(f"channel:{ctx.channel.id}", "answered", "1") if currentBird != "": # check if there is bird url = get_wiki_url(ctx, currentBird) await ctx.send(f"Ok, skipping {currentBird.lower()}") await ctx.send(url) # sends wiki page streak_increment(ctx, None) # reset streak if database.exists(f"race.data:{ctx.channel.id}"): if Filter.from_int( int( database.hget(f"race.data:{ctx.channel.id}", "filter"))).vc: await voice_functions.stop(ctx, silent=True) media = database.hget(f"race.data:{ctx.channel.id}", "media").decode("utf-8") limit = int( database.hget(f"race.data:{ctx.channel.id}", "limit")) first = database.zrevrange(f"race.scores:{ctx.channel.id}", 0, 0, True)[0] if int(first[1]) >= limit: logger.info("race ending") race = self.bot.get_cog("Race") await race.stop_race_(ctx) else: logger.info(f"auto sending next bird {media}") filter_int, taxon, state = database.hmget( f"race.data:{ctx.channel.id}", ["filter", "taxon", "state"]) birds = self.bot.get_cog("Birds") await birds.send_bird_( ctx, media, Filter.from_int(int(filter_int)), taxon.decode("utf-8"), state.decode("utf-8"), ) else: await ctx.send("You need to ask for a bird first!")
class Hint(commands.Cog): def __init__(self, bot): self.bot = bot # give hint @commands.command(help="- Gives first letter of current bird", aliases=["h"]) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) async def hint(self, ctx): logger.info("command: hint") currentBird = database.hget(f"channel:{ctx.channel.id}", "bird").decode("utf-8") if currentBird != "": # check if there is bird await ctx.send(f"The first letter is {currentBird[0]}") else: await ctx.send("You need to ask for a bird first!")
class COVID(commands.Cog): def __init__(self, bot): self.bot = bot self.update_covid() @staticmethod def _request(endpoint, params=None): url = "https://coronavirus-tracker-api.herokuapp.com" response = requests.get(url + endpoint, params) response.raise_for_status() return response.json() def getLocations(self, rank_by: str = None, rank_amount: int = None): data = None world = [ item for item in self._request("/v2/locations", {"source": "jhu"}) ["locations"] if not ( item["country_code"] == "US" and "," in set(item["province"])) ] usa = self._request("/v2/locations", {"source": "csbs"})["locations"] for item in usa: item["province"] = f"{item['county']} County, {item['province']}" data = world + usa ranking_criteria = ["confirmed", "deaths", "recovered"] if rank_by is not None: if rank_by not in ranking_criteria: raise ValueError( "Invalid ranking criteria. Expected one of: %s" % ranking_criteria) ranked = sorted(data, key=lambda i: i["latest"][rank_by], reverse=True) if rank_amount: data = ranked[:rank_amount] else: data = ranked return data def getLatest(self): data = self._request("/v2/latest") return data["latest"] def getCountryCode(self, country_code): if country_code == "US": data = self._request("/v2/locations", { "source": "csbs", "country_code": country_code }) else: data = self._request("/v2/locations", { "source": "jhu", "country_code": country_code }) if not data["locations"]: return None return data def getLocationById(self, country_id: int, us_county: bool = False): data = self._request( "/v2/locations/" + str(country_id), {"source": ("csbs" if us_county else "jhu")}, ) return data["location"] def update_covid(self): self.covid_location_ids = { f'{x["province"]}, {x["country_code"]}': x["id"] for x in self.getLocations() } @staticmethod def format_data(confirmed: int, died: int, recovered: int, location="Global"): embed = discord.Embed( title="COVID-19 Data:", description="Latest data on the COVID-19 pandemic.", type="rich", colour=discord.Color.blurple(), ) embed.set_author(name="Bird ID - An Ornithology Bot") data = ( f"**Confirmed Cases:** `{confirmed}`\n" + f"**Deaths:** `{died}` {f'*({round((died/confirmed)*100, 1)}%)*' if confirmed != 0 else ''}\n" + f"**Recovered:** `{recovered}` {f'*({round((recovered/confirmed)*100, 1)}%)*' if confirmed != 0 else ''}\n" ) embed.add_field(name=location, value=data, inline=False) return embed @staticmethod def format_leaderboard(data, ranked): embed = discord.Embed( title="COVID-19 Top:", description="Latest data on the COVID-19 pandemic.", type="rich", colour=discord.Color.blurple(), ) embed.set_author(name="Bird ID - An Ornithology Bot") for item in data: c, d, r = item["latest"].values() location = f'{(item["province"] + ", " if item["province"] else "")}{item["country"]}' data = (f"**Confirmed Cases:** `{c}`\n" + f"**Deaths:** `{d}`\n" + f"**Recovered:** `{r}`\n") embed.add_field(name=location, value=data, inline=False) return embed # give data @commands.group( brief="- Gives updated info on the COVID-19 pandemic.", help="- Gives updated info on the COVID-19 pandemic. " + "This fetches data from ExpDev07's Coronavirus tracker API, " + "which fetches data from Johns Hopkins, with county data from CSBS. " + "More info: (https://github.com/ExpDev07/coronavirus-tracker-api)", aliases=["corona", "coronavirus", "covid19"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.default)) async def covid(self, ctx): if ctx.invoked_subcommand is None: logger.info("command: covid") location = await commands.clean_content( fix_channel_mentions=True, use_nicknames=True, escape_markdown=True).convert( ctx, " ".join(ctx.message.content.split(" ")[1:])) if not location: c, d, r = self.getLatest().values() embed = self.format_data(c, d, r) await ctx.send(embed=embed) return if len(location) == 2: data = self.getCountryCode(location.upper()) if data: country = data["locations"][0]["country"] await ctx.send(f"Fetching data for location `{country}`.") c, d, r = data["latest"].values() embed = self.format_data(c, d, r, country) await ctx.send(embed=embed) return location_matches = difflib.get_close_matches( location, self.covid_location_ids.keys(), n=1, cutoff=0.4) if location_matches: await ctx.send( f"Fetching data for location `{location_matches[0]}`.") location_id = self.covid_location_ids[location_matches[0]] us_county = (location_matches[0].split(", ")[-1] == "US" and location_matches[0].count(",") == 2) c, d, r = self.getLocationById(location_id, us_county)["latest"].values() embed = self.format_data(c, d, r, location_matches[0]) await ctx.send(embed=embed) return await ctx.send(f"No location `{location}` found.") return # top countries @covid.command( brief="- Gets locations with the most cases", help="- Gets locations with the most cases. " + "This fetches data from ExpDev07's Coronavirus tracker API, " + "which fetches data from Johns Hopkins, with county data from CSBS. " + "More info: (https://github.com/ExpDev07/coronavirus-tracker-api)", aliases=["leaderboard"], ) async def top(self, ctx, ranking: str = "confirmed", amt: int = 3): logger.info("command: covid top") if amt > 10: await ctx.send("**Invalid amount!** Defaulting to 10.") amt = 10 if amt < 1: await ctx.send("**Invalid amount!** Defaulting to 1.") amt = 1 if ranking in ("confirmed", "confirm", "cases", "c"): ranking = "confirmed" elif ranking in ("deaths", "death", "dead", "d"): ranking = "deaths" elif ranking in ("recovered", "recover", "better", "r"): ranking = "recovered" else: await ctx.send("Invalid argument!") return data = self.getLocations(ranking, amt) embed = self.format_leaderboard(data, ranking) await ctx.send(embed=embed) # update data @covid.command( brief="- Updates data.", help="- Updates data. " + "This fetches data from ExpDev07's Coronavirus tracker API, " + "which fetches data from Johns Hopkins, with county data from CSBS. " + "More info: (https://github.com/ExpDev07/coronavirus-tracker-api)", ) @commands.check(CustomCooldown(3600.0, bucket=commands.BucketType.default)) async def update(self, ctx): logger.info("command: update_covid") self.update_covid() await ctx.send("Ok, done!")
class Check(commands.Cog): def __init__(self, bot): self.bot = bot # Check command - argument is the guess @commands.command(help="- Checks your answer.", usage="guess", aliases=["guess", "c"]) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.user)) async def check(self, ctx, *, arg): logger.info("command: check") currentBird = database.hget(f"channel:{ctx.channel.id}", "bird").decode("utf-8") if currentBird == "": # no bird await ctx.send("You must ask for a bird first!") return # if there is a bird, it checks answer sciBird = (await get_sciname(currentBird)).lower().replace("-", " ") arg = arg.lower().replace("-", " ") currentBird = currentBird.lower().replace("-", " ") alpha_code = alpha_codes.get(string.capwords(currentBird)) logger.info("currentBird: " + currentBird) logger.info("arg: " + arg) bird_setup(ctx, currentBird) race_in_session = bool(database.exists(f"race.data:{ctx.channel.id}")) if race_in_session: logger.info("race in session") if database.hget(f"race.data:{ctx.channel.id}", "strict"): logger.info("strict spelling") correct = arg in (currentBird, sciBird) else: logger.info("spelling leniency") correct = spellcheck(arg, currentBird) or spellcheck( arg, sciBird) if not correct and database.hget(f"race.data:{ctx.channel.id}", "alpha"): logger.info("checking alpha codes") correct = arg.upper() == alpha_code else: logger.info("no race") if database.hget(f"session.data:{ctx.author.id}", "strict"): logger.info("strict spelling") correct = arg in (currentBird, sciBird) else: logger.info("spelling leniency") correct = (spellcheck(arg, currentBird) or spellcheck(arg, sciBird) or arg.upper() == alpha_code) if correct: logger.info("correct") database.hset(f"channel:{ctx.channel.id}", "bird", "") database.hset(f"channel:{ctx.channel.id}", "answered", "1") session_increment(ctx, "correct", 1) streak_increment(ctx, 1) database.zincrby(f"correct.user:{ctx.author.id}", 1, string.capwords(str(currentBird))) if (race_in_session and Filter.from_int( int(database.hget(f"race.data:{ctx.channel.id}", "filter"))).vc): await voice_functions.stop(ctx, silent=True) await ctx.send( f"Correct! Good job! The bird was **{currentBird}**." if not race_in_session else f"**{ctx.author.mention}**, you are correct! The bird was **{currentBird}**." ) url = get_wiki_url(ctx, currentBird) await ctx.send(url) score_increment(ctx, 1) if int(database.zscore("users:global", str(ctx.author.id))) in achievement: number = str( int(database.zscore("users:global", str(ctx.author.id)))) await ctx.send( f"Wow! You have answered {number} birds correctly!") filename = f"bot/media/achievements/{number}.PNG" with open(filename, "rb") as img: await ctx.send(file=discord.File(img, filename="award.png") ) if race_in_session: media = database.hget(f"race.data:{ctx.channel.id}", "media").decode("utf-8") limit = int( database.hget(f"race.data:{ctx.channel.id}", "limit")) first = database.zrevrange(f"race.scores:{ctx.channel.id}", 0, 0, True)[0] if int(first[1]) >= limit: logger.info("race ending") race = self.bot.get_cog("Race") await race.stop_race_(ctx) else: logger.info(f"auto sending next bird {media}") filter_int, taxon, state = database.hmget( f"race.data:{ctx.channel.id}", ["filter", "taxon", "state"]) birds = self.bot.get_cog("Birds") await birds.send_bird_( ctx, media, Filter.from_int(int(filter_int)), taxon.decode("utf-8"), state.decode("utf-8"), ) else: logger.info("incorrect") streak_increment(ctx, None) # reset streak session_increment(ctx, "incorrect", 1) incorrect_increment(ctx, str(currentBird), 1) if race_in_session: await ctx.send("Sorry, that wasn't the right answer.") else: database.hset(f"channel:{ctx.channel.id}", "bird", "") database.hset(f"channel:{ctx.channel.id}", "answered", "1") await ctx.send("Sorry, the bird was actually **" + currentBird + "**.") url = get_wiki_url(ctx, currentBird) await ctx.send(url)
class Race(commands.Cog): def __init__(self, bot): self.bot = bot async def _get_options(self, ctx): filter_int, state, media, limit, taxon, strict, alpha = database.hmget( f"race.data:{ctx.channel.id}", ["filter", "state", "media", "limit", "taxon", "strict", "alpha"], ) filters = Filter.from_int(int(filter_int)) options = ( f"**Active Filters:** `{'`, `'.join(filters.display())}`\n" + f"**Special bird list:** {state.decode('utf-8') if state else 'None'}\n" + f"**Taxons:** {taxon.decode('utf-8') if taxon else 'None'}\n" + f"**Media Type:** {media.decode('utf-8')}\n" + f"**Amount to Win:** {limit.decode('utf-8')}\n" + f"**Strict Spelling:** {strict == b'strict'}\n" + f"**Alpha Codes:** {'Enabled' if alpha == b'alpha' else 'Disabled'}" ) return options async def _send_stats(self, ctx, preamble): placings = 5 database_key = f"race.scores:{ctx.channel.id}" if database.zcard(database_key) == 0: logger.info(f"no users in {database_key}") await ctx.send("There are no users in the database.") return if placings > database.zcard(database_key): placings = database.zcard(database_key) leaderboard_list = database.zrevrangebyscore( database_key, "+inf", "-inf", 0, placings, True ) embed = discord.Embed( type="rich", colour=discord.Color.blurple(), title=preamble ) embed.set_author(name="Bird ID - An Ornithology Bot") leaderboard = "" for i, stats in enumerate(leaderboard_list): if ctx.guild is not None: user = await fetch_get_user(int(stats[0]), ctx=ctx, member=True) else: user = None if user is None: user = await fetch_get_user(int(stats[0]), ctx=ctx, member=False) if user is None: user_info = "**Deleted**" else: user_info = f"**{esc(user.name)}#{user.discriminator}**" else: user_info = f"**{esc(user.name)}#{user.discriminator}** ({user.mention})" leaderboard += f"{i+1}. {user_info} - {int(stats[1])}\n" start = int(database.hget(f"race.data:{ctx.channel.id}", "start")) elapsed = str(datetime.timedelta(seconds=round(time.time()) - start)) embed.add_field( name="Options", value=await self._get_options(ctx), inline=False ) embed.add_field( name="Stats", value=f"**Race Duration:** `{elapsed}`", inline=False ) embed.add_field(name="Leaderboard", value=leaderboard, inline=False) if ctx.author: if database.zscore(database_key, str(ctx.author.id)) is not None: placement = int(database.zrevrank(database_key, str(ctx.author.id))) + 1 embed.add_field( name="You:", value=f"You are #{placement}.", inline=False ) else: embed.add_field( name="You:", value="You haven't answered any correctly." ) await ctx.send(embed=embed) async def stop_race_(self, ctx): if Filter.from_int( int(database.hget(f"race.data:{ctx.channel.id}", "filter")) ).vc: await voice_functions.disconnect(ctx, silent=True) database.delete(f"voice.server:{ctx.guild.id}") first = database.zrevrange(f"race.scores:{ctx.channel.id}", 0, 0, True)[0] if ctx.guild is not None: user = await fetch_get_user(int(first[0]), ctx=ctx, member=True) else: user = None if user is None: user = await fetch_get_user(int(first[0]), ctx=ctx, member=False) if user is None: user_info = "Deleted" else: user_info = f"{esc(user.name)}#{user.discriminator}" else: user_info = f"{esc(user.name)}#{user.discriminator} ({user.mention})" await ctx.send( f"**Congratulations, {user_info}!**\n" + f"You have won the race by correctly identifying `{int(first[1])}` birds. " + "*Way to go!*" ) database.hset(f"race.data:{ctx.channel.id}", "stop", round(time.time())) await self._send_stats(ctx, "**Race stopped.**") database.delete(f"race.data:{ctx.channel.id}") database.delete(f"race.scores:{ctx.channel.id}") logger.info("race end: skipping last bird") database.hset(f"channel:{ctx.channel.id}", "bird", "") database.hset(f"channel:{ctx.channel.id}", "answered", "1") @commands.group( brief="- Base race command", help="- Base race command\n" + "Races allow you to compete with others to see who can ID a bird first. " + "Starting a race will keep all cooldowns the same, but automatically run " + "'b!bird' (or 'b!song') after every check. You will still need to use 'b!check' " + "to check your answer. Races are channel-specific, and anyone in that channel can play." + "Races end when a player is the first to correctly ID a set amount of birds. (default 10)", ) @commands.guild_only() async def race(self, ctx): if ctx.invoked_subcommand is None: await ctx.send( "**Invalid subcommand passed.**\n*Valid Subcommands:* `start, view, stop`" ) @race.command( brief="- Starts race", help="""- Starts race. Arguments passed will become the default arguments to 'b!bird', but some can be manually overwritten during use. Arguments can be passed in any taxon. However, having both females and juveniles are not supported.""", aliases=["st"], usage="[state] [filters] [taxon] [amount to win (default 10)]", ) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) async def start(self, ctx, *, args_str: str = ""): logger.info("command: start race") if not str(ctx.channel.name).startswith("racing"): logger.info("not race channel") await ctx.send( "**Sorry, racing is not available in this channel.**\n" + "*Set the channel name to start with `racing` to enable it.*" ) return if database.exists(f"race.data:{ctx.channel.id}"): logger.info("already race") await ctx.send( "**There is already a race in session.** *Change settings/view stats with `b!race view`*" ) return filters = Filter.parse(args_str, use_numbers=False) if filters.vc: if database.get(f"voice.server:{ctx.guild.id}") is not None: logger.info("already vc race") await ctx.send( "**There is already a VC race in session in this server!**" ) return client = await voice_functions.get_voice_client(ctx, connect=True) if client is None: return database.set(f"voice.server:{ctx.guild.id}", str(ctx.channel.id)) args = args_str.split(" ") logger.info(f"args: {args}") taxon_args = set(taxons.keys()).intersection({arg.lower() for arg in args}) if taxon_args: taxon = " ".join(taxon_args).strip() else: taxon = "" if "strict" in args: strict = "strict" else: strict = "" if "alpha" in args: alpha = "alpha" else: alpha = "" states_args = set(states.keys()).intersection({arg.upper() for arg in args}) if states_args: if {"CUSTOM"}.issubset(states_args): if database.exists( f"custom.list:{ctx.author.id}" ) and not database.exists(f"custom.confirm:{ctx.author.id}"): states_args.discard("CUSTOM") states_args.add(f"CUSTOM:{ctx.author.id}") else: states_args.discard("CUSTOM") await ctx.send( "**You don't have a custom list set.**\n*Ignoring the argument.*" ) state = " ".join(states_args).strip() else: state = "" song = "song" in args or "songs" in args or "s" in args or filters.vc image = ( "image" in args or "images" in args or "i" in args or "picture" in args or "pictures" in args or "p" in args ) if song and image: await ctx.send( "**Songs and images are not yet supported.**\n*Please try again*" ) return if song: media = "song" elif image: media = "image" else: media = "image" ints = [] for n in args: try: ints.append(int(n)) except ValueError: continue if ints: limit = int(ints[0]) else: limit = 10 if limit > 1000000: await ctx.send("**Sorry, the maximum amount to win is 1 million.**") limit = 1000000 logger.info( f"adding filters: {filters}; state: {state}; media: {media}; limit: {limit}" ) database.hset( f"race.data:{ctx.channel.id}", mapping={ "start": round(time.time()), "stop": 0, "limit": limit, "filter": str(filters.to_int()), "state": state, "media": media, "taxon": taxon, "strict": strict, "alpha": alpha, }, ) database.zadd(f"race.scores:{ctx.channel.id}", {str(ctx.author.id): 0}) await ctx.send( f"**Race started with options:**\n{await self._get_options(ctx)}" ) media = database.hget(f"race.data:{ctx.channel.id}", "media").decode("utf-8") logger.info("clearing previous bird") database.hset(f"channel:{ctx.channel.id}", "bird", "") database.hset(f"channel:{ctx.channel.id}", "answered", "1") logger.info(f"auto sending next bird {media}") filter_int, taxon, state = database.hmget( f"race.data:{ctx.channel.id}", ["filter", "taxon", "state"] ) birds = self.bot.get_cog("Birds") await birds.send_bird_( ctx, media, Filter.from_int(int(filter_int)), # type: ignore taxon.decode("utf-8"), # type: ignore state.decode("utf-8"), # type: ignore ) @race.command( brief="- Views race", help="- Views race.\nRaces allow you to compete with your friends to ID a certain bird first.", ) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) async def view(self, ctx): logger.info("command: view race") if database.exists(f"race.data:{ctx.channel.id}"): await self._send_stats(ctx, "**Race In Progress**") else: await ctx.send( "**There is no race in session.** *You can start one with `b!race start`*" ) @race.command(help="- Stops race", aliases=["stp", "end"]) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) async def stop(self, ctx): logger.info("command: stop race") if database.exists(f"race.data:{ctx.channel.id}"): await self.stop_race_(ctx) else: await ctx.send( "**There is no race in session.** *You can start one with `b!race start`*" )
class Score(commands.Cog): def __init__(self, bot): self.bot = bot @staticmethod def _server_total(ctx): logger.info("fetching server totals") channels = map( lambda x: x.decode("utf-8").split(":")[1], database.zrangebylex( "channels:global", f"[{ctx.guild.id}", f"({ctx.guild.id}\xff" ), ) pipe = database.pipeline() # use a pipeline to get all the scores for channel in channels: pipe.zscore("score:global", channel) scores = pipe.execute() return int(sum(scores)) @staticmethod def _monthly_lb(category): logger.info("generating monthly leaderboard") if category == "scores": key = "daily.score" elif category == "missed": key = "daily.incorrect" else: raise GenericError("Invalid category", 990) today = datetime.datetime.now(datetime.timezone.utc).date() past_month = pd.date_range( # pylint: disable=no-member today - datetime.timedelta(29), today ).date pipe = database.pipeline() for day in past_month: pipe.zrevrangebyscore(f"{key}:{day}", "+inf", "-inf", withscores=True) result = pipe.execute() totals = pd.Series(dtype="int64") for daily_score in result: daily_score = pd.Series( { e[0]: e[1] for e in map( lambda x: (x[0].decode("utf-8"), int(x[1])), daily_score ) } ) totals = totals.add(daily_score, fill_value=0) totals = totals.sort_values(ascending=False) return totals async def user_lb(self, ctx, title, page, database_key=None, data=None): if database_key is None and data is None: raise GenericError("database_key and data are both NoneType", 990) if database_key is not None and data is not None: raise GenericError("database_key and data are both set", 990) if page < 1: page = 1 user_amount = ( int(database.zcard(database_key)) if database_key is not None else data.count() ) page = (page * 10) - 10 if user_amount == 0: logger.info(f"no users in {database_key}") await ctx.send("There are no users in the database.") return if page >= user_amount: page = user_amount - (user_amount % 10 if user_amount % 10 != 0 else 10) users_per_page = 10 leaderboard_list = ( database.zrevrangebyscore( database_key, "+inf", "-inf", page, users_per_page, True ) if database_key is not None else data.iloc[page : page + users_per_page - 1].items() ) embed = discord.Embed(type="rich", colour=discord.Color.blurple()) embed.set_author(name="Bird ID - An Ornithology Bot") leaderboard = "" for i, stats in enumerate(leaderboard_list): if ctx.guild is not None: user = ctx.guild.get_member(int(stats[0])) else: user = None if user is None: user = self.bot.get_user(int(stats[0])) if user is None: user = "******" else: user = f"**{user.name}#{user.discriminator}**" else: user = f"**{user.name}#{user.discriminator}** ({user.mention})" leaderboard += f"{i+1+page}. {user} - {int(stats[1])}\n" embed.add_field(name=title, value=leaderboard, inline=False) user_score = ( database.zscore(database_key, str(ctx.author.id)) if database_key is not None else data.get(str(ctx.author.id)) ) if user_score is not None: if database_key is not None: placement = int(database.zrevrank(database_key, str(ctx.author.id))) + 1 distance = int( database.zrevrange( database_key, placement - 2, placement - 2, True )[0][1] ) - int(user_score) else: placement = int(data.rank(ascending=False)[str(ctx.author.id)]) distance = int(data.iloc[placement - 2] - user_score) if placement == 1: embed.add_field( name="You:", value=f"You are #{placement} on the leaderboard.\nYou are in first place.", inline=False, ) elif distance == 0: embed.add_field( name="You:", value=f"You are #{placement} on the leaderboard.\nYou are tied with #{placement-1}", inline=False, ) else: embed.add_field( name="You:", value=f"You are #{placement} on the leaderboard.\nYou are {distance} away from #{placement-1}", inline=False, ) else: embed.add_field(name="You:", value="You haven't answered any correctly.") await ctx.send(embed=embed) # returns total number of correct answers so far @commands.command( brief="- Total correct answers in a channel or server", help="- Total correct answers in a channel or server. Defaults to channel.", usage="[server|s]", ) @commands.check(CustomCooldown(8.0, bucket=commands.BucketType.channel)) async def score(self, ctx, scope=""): logger.info("command: score") if scope in ("server", "s"): total_correct = self._server_total(ctx) await ctx.send( f"Wow, looks like a total of `{total_correct}` birds have been answered correctly in this **server**!\n" + "Good job everyone!" ) else: total_correct = int(database.zscore("score:global", str(ctx.channel.id))) await ctx.send( f"Wow, looks like a total of `{total_correct}` birds have been answered correctly in this **channel**!\n" + "Good job everyone!" ) # sends correct answers by a user @commands.command( brief="- How many correct answers given by a user", help="- Gives the amount of correct answers by a user.\n" + "Mention someone to get their score, don't mention anyone to get your score.", aliases=["us"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def userscore( self, ctx, *, user: typing.Optional[typing.Union[discord.Member, str]] = None ): logger.info("command: userscore") if user is not None: if isinstance(user, str): await ctx.send("Not a user!") return usera = user.id logger.info(usera) score = database.zscore("users:global", str(usera)) if score is not None: score = int(score) user = f"<@{usera}>" else: await ctx.send("This user does not exist on our records!") return else: user = f"<@{ctx.author.id}>" score = int(database.zscore("users:global", str(ctx.author.id))) embed = discord.Embed(type="rich", colour=discord.Color.blurple()) embed.set_author(name="Bird ID - An Ornithology Bot") embed.add_field( name="User Score:", value=f"{user} has answered correctly {score} times." ) await ctx.send(embed=embed) # gives streak of a user @commands.group( help="- Gives your current/max streak", usage="[user]", aliases=["streaks", "stk"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def streak(self, ctx): if ctx.invoked_subcommand is not None: return logger.info("command: streak") args = " ".join(ctx.message.content.split(" ")[1:]) if args: user = await commands.MemberConverter().convert(ctx, args) else: user = None if user is not None: if isinstance(user, str): await ctx.send("Not a user!") return usera = user.id logger.info(usera) streak = database.zscore("streak:global", str(usera)) max_streak = database.zscore("streak.max:global", str(usera)) if streak is not None and max_streak is not None: streak = int(streak) max_streak = int(max_streak) user = f"<@{usera}>" else: await ctx.send("This user does not exist on our records!") return else: user = f"<@{ctx.author.id}>" streak = int(database.zscore("streak:global", str(ctx.author.id))) max_streak = int(database.zscore("streak.max:global", str(ctx.author.id))) embed = discord.Embed( type="rich", colour=discord.Color.blurple(), title="**User Streaks**" ) embed.set_author(name="Bird ID - An Ornithology Bot") current_streak = f"{user} has answered `{streak}` in a row!" max_streak = f"{user}'s max was `{max_streak}` in a row!" embed.add_field(name="**Current Streak**", value=current_streak, inline=False) embed.add_field(name="**Max Streak**", value=max_streak, inline=False) await ctx.send(embed=embed) # streak leaderboard - returns top streaks @streak.command( brief="- Top streaks", help="- Top streaks, either current (default) or max.", usage="[max|m] [page]", name="leaderboard", aliases=["lb"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def streak_leaderboard(self, ctx, scope="", page=1): logger.info("command: leaderboard") try: page = int(scope) except ValueError: if scope == "": scope = "current" scope = scope.lower() else: scope = "current" logger.info(f"scope: {scope}") logger.info(f"page: {page}") if not scope in ("current", "now", "c", "max", "m"): logger.info("invalid scope") await ctx.send(f"**{scope} is not a valid scope!**\n*Valid Scopes:* `max`") return if scope in ("max", "m"): database_key = "streak.max:global" scope = "max" else: database_key = "streak:global" scope = "current" await self.user_lb( ctx, f"Streak Leaderboard ({scope})", page, database_key, None ) # leaderboard - returns top 1-10 users @commands.command( brief="- Top scores", help="- Top scores, either global, server, or monthly.", usage="[global|g server|s month|monthly|m] [page]", aliases=["lb"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def leaderboard(self, ctx, scope="", page=1): logger.info("command: leaderboard") try: page = int(scope) except ValueError: if scope == "": scope = "global" scope = scope.lower() else: scope = "global" logger.info(f"scope: {scope}") logger.info(f"page: {page}") if not scope in ("global", "server", "month", "monthly", "m", "g", "s"): logger.info("invalid scope") await ctx.send( f"**{scope} is not a valid scope!**\n*Valid Scopes:* `global, server, month`" ) return if scope in ("server", "s"): if ctx.guild is not None: database_key = f"users.server:{ctx.guild.id}" scope = "server" else: logger.info("dm context") await ctx.send( "**Server scopes are not available in DMs.**\n*Showing global leaderboard instead.*" ) scope = "global" database_key = "users:global" data = None elif scope in ("month", "monthly", "m"): database_key = None scope = "Last 30 Days" data = self._monthly_lb("scores") else: database_key = "users:global" scope = "global" data = None await self.user_lb(ctx, f"Leaderboard ({scope})", page, database_key, data) # missed - returns top 1-10 missed birds @commands.command( brief="- Top incorrect birds", help="- Top incorrect birds, either global, server, personal, or monthly.", usage="[global|g server|s me|m month|monthly|mo] [page]", aliases=["m"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def missed(self, ctx, scope="", page=1): logger.info("command: missed") try: page = int(scope) except ValueError: if scope == "": scope = "global" scope = scope.lower() else: scope = "global" logger.info(f"scope: {scope}") logger.info(f"page: {page}") if not scope in ( "global", "server", "me", "month", "monthly", "mo", "g", "s", "m", ): logger.info("invalid scope") await ctx.send( f"**{scope} is not a valid scope!**\n*Valid Scopes:* `global, server, me, month`" ) return if scope in ("server", "s"): data = None if ctx.guild is not None: database_key = f"incorrect.server:{ctx.guild.id}" scope = "server" else: logger.info("dm context") await ctx.send( "**Server scopes are not available in DMs.**\n*Showing global leaderboard instead.*" ) scope = "global" database_key = "incorrect:global" elif scope in ("me", "m"): data = None database_key = f"incorrect.user:{ctx.author.id}" scope = "me" elif scope in ("month", "monthly", "mo"): data = self._monthly_lb("missed") database_key = None scope = "Last 30 days" else: data = None database_key = "incorrect:global" scope = "global" await send_leaderboard( ctx, f"Top Missed Birds ({scope})", page, database_key, data )
class Birds(commands.Cog): def __init__(self, bot): self.bot = bot async def _send_next_race_media(self, ctx): if database.exists(f"race.data:{ctx.channel.id}"): if Filter.from_int( int(database.hget(f"race.data:{ctx.channel.id}", "filter"))).vc: await voice_functions.stop(ctx, silent=True) media = database.hget(f"race.data:{ctx.channel.id}", "media").decode("utf-8") logger.info(f"auto sending next bird {media}") filter_int, taxon, state = database.hmget( f"race.data:{ctx.channel.id}", ["filter", "taxon", "state"]) await self.send_bird_( ctx, media, Filter.from_int(int(filter_int)), taxon.decode("utf-8"), state.decode("utf-8"), ) def error_handle( self, ctx, media_type: str, filters: Filter, taxon_str, role_str, retries, ): """Return a function to pass to send_bird() as on_error.""" # pylint: disable=unused-argument async def inner(error): nonlocal retries # skip current bird database.hset(f"channel:{ctx.channel.id}", "bird", "") database.hset(f"channel:{ctx.channel.id}", "answered", "1") if retries >= 2: # only retry twice await ctx.send("**Too many retries.**\n*Please try again.*") await self._send_next_race_media(ctx) return if isinstance(error, GenericError) and error.code == 100: retries += 1 await ctx.send("**Retrying...**") await self.send_bird_(ctx, media_type, filters, taxon_str, role_str, retries) else: await ctx.send("*Please try again.*") await self._send_next_race_media(ctx) return inner @staticmethod def error_skip(ctx): async def inner(error): # pylint: disable=unused-argument # skip current bird database.hset(f"channel:{ctx.channel.id}", "bird", "") database.hset(f"channel:{ctx.channel.id}", "answered", "1") await ctx.send("*Please try again.*") return inner @staticmethod def increment_bird_frequency(ctx, bird): bird_setup(ctx, bird) database.zincrby("frequency.bird:global", 1, string.capwords(bird)) async def send_bird_( self, ctx, media_type: Optional[str], filters: Filter, taxon_str: str = "", role_str: str = "", retries=0, ): media_type = ("images" if media_type in ("images", "image", "i", "p") else ("songs" if media_type in ("songs", "song", "s", "a") else None)) if not media_type: raise GenericError("Invalid media type", code=990) if media_type == "songs" and filters.vc: current_voice = database.get(f"voice.server:{ctx.guild.id}") if current_voice is not None and current_voice.decode( "utf-8") != str(ctx.channel.id): logger.info("already vc race") await ctx.send("**The voice channel is currently in use!**") return if taxon_str: taxon = taxon_str.split(" ") else: taxon = [] if role_str: roles = role_str.split(" ") else: roles = [] logger.info( "bird: " + database.hget(f"channel:{ctx.channel.id}", "bird").decode("utf-8")) currently_in_race = bool( database.exists(f"race.data:{ctx.channel.id}")) answered = int(database.hget(f"channel:{ctx.channel.id}", "answered")) logger.info(f"answered: {answered}") # check to see if previous bird was answered if answered: # if yes, give a new bird session_increment(ctx, "total", 1) logger.info(f"filters: {filters}; taxon: {taxon}; roles: {roles}") if not currently_in_race and retries == 0: await ctx.send( "**Recognized arguments:** " + f"*Active Filters*: `{'`, `'.join(filters.display())}`, " + f"*Taxons*: `{'None' if taxon_str == '' else taxon_str}`, " + f"*Detected State*: `{'None' if role_str == '' else role_str}`" ) find_custom_role = { i if i.startswith("CUSTOM:") else "" for i in roles } find_custom_role.discard("") if (database.exists(f"race.data:{ctx.channel.id}") and len(find_custom_role) == 1): custom_role = find_custom_role.pop() roles.remove(custom_role) roles.append("CUSTOM") user_id = custom_role.split(":")[1] birds = build_id_list(user_id=user_id, taxon=taxon, state=roles, media=media_type) else: birds = build_id_list(user_id=ctx.author.id, taxon=taxon, state=roles, media=media_type) if not birds: logger.info("no birds for taxon/state") await ctx.send( "**Sorry, no birds could be found for the taxon/state combo.**\n*Please try again*" ) return currentBird = random.choice(birds) self.increment_bird_frequency(ctx, currentBird) prevB = database.hget(f"channel:{ctx.channel.id}", "prevB").decode("utf-8") while currentBird == prevB and len(birds) > 1: currentBird = random.choice(birds) database.hset(f"channel:{ctx.channel.id}", "prevB", str(currentBird)) database.hset(f"channel:{ctx.channel.id}", "bird", str(currentBird)) logger.info("currentBird: " + str(currentBird)) database.hset(f"channel:{ctx.channel.id}", "answered", "0") await send_bird( ctx, currentBird, media_type, filters, on_error=self.error_handle(ctx, media_type, filters, taxon_str, role_str, retries), message=( SONG_MESSAGE if media_type == "songs" else BIRD_MESSAGE) if not currently_in_race else "*Here you go!*", ) else: # if no, give the same bird await ctx.send( f"**Active Filters**: `{'`, `'.join(filters.display())}`") await send_bird( ctx, database.hget(f"channel:{ctx.channel.id}", "bird").decode("utf-8"), media_type, filters, on_error=self.error_handle(ctx, media_type, filters, taxon_str, role_str, retries), message=( SONG_MESSAGE if media_type == "songs" else BIRD_MESSAGE) if not currently_in_race else "*Here you go!*", ) @staticmethod async def parse(ctx, args_str: str): """Parse arguments for options.""" args = args_str.split(" ") logger.info(f"args: {args}") if not database.exists(f"race.data:{ctx.channel.id}"): roles = check_state_role(ctx) taxon_args = set(taxons.keys()).intersection( {arg.lower() for arg in args}) if taxon_args: taxon = " ".join(taxon_args).strip() else: taxon = "" state_args = set(states.keys()).intersection( {arg.upper() for arg in args}) if state_args: state = " ".join(state_args).strip() else: state = "" if database.exists(f"session.data:{ctx.author.id}"): logger.info("session parameters") if taxon_args: current_taxons = set( database.hget(f"session.data:{ctx.author.id}", "taxon").decode("utf-8").split(" ")) logger.info(f"toggle taxons: {taxon_args}") logger.info(f"current taxons: {current_taxons}") taxon_args.symmetric_difference_update(current_taxons) taxon_args.discard("") logger.info(f"new taxons: {taxon_args}") taxon = " ".join(taxon_args).strip() else: taxon = database.hget(f"session.data:{ctx.author.id}", "taxon").decode("utf-8") roles = (database.hget(f"session.data:{ctx.author.id}", "state").decode("utf-8").split(" ")) if roles[0] == "": roles = [] if not roles: logger.info("no session lists") roles = check_state_role(ctx) session_filter = int( database.hget(f"session.data:{ctx.author.id}", "filter")) filters = Filter.parse(args_str, defaults=False) if filters.vc: filters.vc = False await ctx.send("**The VC filter is not allowed inline!**") default_quality = Filter().quality if (Filter.from_int(session_filter).quality == default_quality and filters.quality and filters.quality != default_quality): filters ^= Filter() # clear defaults filters ^= session_filter else: filters = Filter.parse(args_str) if filters.vc: filters.vc = False await ctx.send("**The VC filter is not allowed inline!**") if state_args: logger.info(f"toggle states: {state_args}") logger.info(f"current states: {roles}") state_args.symmetric_difference_update(set(roles)) state_args.discard("") logger.info(f"new states: {state_args}") state = " ".join(state_args).strip() else: state = " ".join(roles).strip() if "CUSTOM" in state.upper().split(" "): if not database.exists(f"custom.list:{ctx.author.id}"): await ctx.send("**You don't have a custom list set!**") state_list = state.split(" ") state_list.remove("CUSTOM") state = " ".join(state_list) elif database.exists(f"custom.confirm:{ctx.author.id}"): await ctx.send( "**Please verify or confirm your custom list before using!**" ) state_list = state.split(" ") state_list.remove("CUSTOM") state = " ".join(state_list) else: logger.info("race parameters") race_filter = int( database.hget(f"race.data:{ctx.channel.id}", "filter")) filters = Filter.parse(args_str, defaults=False) if filters.vc: filters.vc = False await ctx.send("**The VC filter is not allowed inline!**") default_quality = Filter().quality if (Filter.from_int(race_filter).quality == default_quality and filters.quality and filters.quality != default_quality): filters ^= Filter() # clear defaults filters ^= race_filter taxon = database.hget(f"race.data:{ctx.channel.id}", "taxon").decode("utf-8") state = database.hget(f"race.data:{ctx.channel.id}", "state").decode("utf-8") logger.info( f"args: filters: {filters}; taxon: {taxon}; state: {state}") return (filters, taxon, state) # Bird command - no args # help text @commands.command( help="- Sends a random bird image for you to ID", aliases=["b"], usage="[filters] [order/family] [state]", ) # 5 second cooldown @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def bird(self, ctx, *, args_str: str = ""): logger.info("command: bird") filters, taxon, state = await self.parse(ctx, args_str) media = "images" if database.exists(f"race.data:{ctx.channel.id}"): media = database.hget(f"race.data:{ctx.channel.id}", "media").decode("utf-8") await self.send_bird_(ctx, media, filters, taxon, state) # picks a random bird call to send @commands.command( help="- Sends a random bird song for you to ID", aliases=["s"], usage="[filters] [order/family] [state]", ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def song(self, ctx, *, args_str: str = ""): logger.info("command: song") filters, taxon, state = await self.parse(ctx, args_str) media = "songs" if database.exists(f"race.data:{ctx.channel.id}"): media = database.hget(f"race.data:{ctx.channel.id}", "media").decode("utf-8") await self.send_bird_(ctx, media, filters, taxon, state) # goatsucker command - no args # just for fun, no real purpose @commands.command(help="- Sends a random goatsucker to ID", aliases=["gs"]) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def goatsucker(self, ctx): logger.info("command: goatsucker") if database.exists(f"race.data:{ctx.channel.id}"): await ctx.send("This command is disabled during races.") return answered = int(database.hget(f"channel:{ctx.channel.id}", "answered")) # check to see if previous bird was answered if answered: # if yes, give a new bird session_increment(ctx, "total", 1) database.hset(f"channel:{ctx.channel.id}", "answered", "0") currentBird = random.choice(goatsuckers) self.increment_bird_frequency(ctx, currentBird) database.hset(f"channel:{ctx.channel.id}", "bird", str(currentBird)) logger.info("currentBird: " + str(currentBird)) await send_bird( ctx, currentBird, "images", Filter(), on_error=self.error_skip(ctx), message=GS_MESSAGE, ) else: # if no, give the same bird await send_bird( ctx, database.hget(f"channel:{ctx.channel.id}", "bird").decode("utf-8"), "images", Filter(), on_error=self.error_skip(ctx), message=GS_MESSAGE, )
class States(commands.Cog): def __init__(self, bot): self.bot = bot async def broken_send(self, ctx, message: str, between: str = ""): pages: list[str] = [] temp_lines: list[str] = [] temp_len = 0 for line in message.splitlines(keepends=True): temp_lines.append(line) temp_len += len(line) if temp_len > 1700: temp_out = f"{between}{''.join(temp_lines)}{between}" pages.append(temp_out) temp_lines.clear() if temp_lines: temp_out = f"{between}{''.join(temp_lines)}{between}" pages.append(temp_out) for page in pages: await ctx.send(page) # set state role @commands.command(help="- Sets your state", name="set", aliases=["state"]) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) @commands.guild_only() @commands.bot_has_permissions(manage_roles=True) async def state(self, ctx, *, args): logger.info("command: state set") raw_roles = ctx.author.roles role_ids = [role.id for role in raw_roles] role_names = [role.name.lower() for role in ctx.author.roles] args = args.upper().split(" ") if "CUSTOM" in args and ( not database.exists(f"custom.list:{ctx.author.id}") or database.exists(f"custom.confirm:{ctx.author.id}")): await ctx.send( "Sorry, you don't have a custom list! Use `b!custom` to set your custom list." ) return added = [] removed = [] invalid = [] for arg in args: if arg not in states: logger.info("invalid state") invalid.append(arg) # gets similarities elif not set(role_names).intersection(set(states[arg]["aliases"])): # need to add role (does not have role) logger.info("add roles") raw_roles = ctx.guild.roles guild_role_names = [role.name.lower() for role in raw_roles] guild_role_ids = [role.id for role in raw_roles] if states[arg]["aliases"][0].lower() in guild_role_names: # guild has role index = guild_role_names.index( states[arg]["aliases"][0].lower()) role = ctx.guild.get_role(guild_role_ids[index]) else: # create role logger.info("creating role") role = await ctx.guild.create_role( name=string.capwords(states[arg]["aliases"][0]), permissions=discord.Permissions.none(), hoist=False, mentionable=False, reason="Create state role for bird list", ) await ctx.author.add_roles( role, reason="Set state role for bird list") added.append(role.name) else: # have roles already (there were similarities) logger.info("already has role, removing") index = role_names.index(states[arg]["aliases"][0].lower()) role = ctx.guild.get_role(role_ids[index]) await ctx.author.remove_roles( role, reason="Remove state role for bird list") removed.append(role.name) await ctx.send(( f"**Sorry,** `{'`, `'.join(invalid)}` **{'are' if len(invalid) > 1 else 'is'} not a valid state.**\n" + f"*Valid States:* `{'`, `'.join(states.keys())}`\n" if invalid else "" ) + ( f"**Added the** `{'`, `'.join(added)}` **role{'s' if len(added) > 1 else ''}**\n" if added else "" ) + ( f"**Removed the** `{'`, `'.join(removed)}` **role{'s' if len(removed) > 1 else ''}**\n" if removed else "")) # set custom bird list @commands.command( brief="- Sets your custom bird list", help="- Sets your custom bird list. " + "This command only works in DMs. Lists have a max size of 200 birds. " + "When verifying, the bot may incorrectly say it didn't find any images. " + "If this is the case and you have verified yourself by going to https://macaulaylibrary.org, " + "just try again later. You can use your custom list anywhere you would use " + "a state with the `CUSTOM` 'state'.", ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) @commands.dm_only() async def custom(self, ctx, *, args=""): logger.info("command: custom list set") args = args.lower().strip().split(" ") logger.info(f"parsed args: {args}") if ("replace" not in args and ctx.message.attachments and database.exists(f"custom.list:{ctx.author.id}")): await ctx.send( "Woah there. You already have a custom list. " + "To view its contents, use `b!custom view`. " + "If you want to replace your list, upload the file with `b!custom replace`." ) return if "delete" in args and database.exists( f"custom.list:{ctx.author.id}"): if (database.exists(f"custom.confirm:{ctx.author.id}") and database.get(f"custom.confirm:{ctx.author.id}").decode( "utf-8") == "delete"): database.delete(f"custom.list:{ctx.author.id}", f"custom.confirm:{ctx.author.id}") await ctx.send("Ok, your list was deleted.") return database.set(f"custom.confirm:{ctx.author.id}", "delete", ex=86400) await ctx.send( "Are you sure you want to permanently delete your list? " + "Use `b!custom delete` again within 24 hours to clear your custom list." ) return if ("confirm" in args and database.exists(f"custom.confirm:{ctx.author.id}") and database.get(f"custom.confirm:{ctx.author.id}").decode("utf-8") == "confirm"): # list was validated by server and user, making permanent logger.info("user confirmed") database.persist(f"custom.list:{ctx.author.id}") database.delete(f"custom.confirm:{ctx.author.id}") database.set(f"custom.cooldown:{ctx.author.id}", 0, ex=86400) await ctx.send( "Ok, your custom bird list is now available. Use `b!custom view` " + "to view your list. You can change your list again in 24 hours." ) return if ("validate" in args and database.exists(f"custom.confirm:{ctx.author.id}") and database.get(f"custom.confirm:{ctx.author.id}").decode("utf-8") == "valid"): # list was validated, now for user confirm logger.info("valid list, user needs to confirm") database.expire(f"custom.list:{ctx.author.id}", 86400) database.set(f"custom.confirm:{ctx.author.id}", "confirm", ex=86400) birdlist = "\n".join( bird.decode("utf-8") for bird in database.smembers(f"custom.list:{ctx.author.id}")) await ctx.send( f"**Please confirm the following list.** ({int(database.scard(f'custom.list:{ctx.author.id}'))} items)" ) await self.broken_send(ctx, birdlist, between="```\n") await ctx.send( "Once you have looked over the list and are sure you want to add it, " + "please use `b!custom confirm` to have this list added as a custom list. " + "You have another 24 hours to confirm. " + "To start over, upload a new list with the message `b!custom replace`." ) return if "view" in args: if not database.exists(f"custom.list:{ctx.author.id}"): await ctx.send( "You don't have a custom list. To add a custom list, " + "upload a txt file with a bird's name on each line to this DM " + "and put `b!custom` in the **Add a Comment** section.") return birdlist = "\n".join( bird.decode("utf-8") for bird in database.smembers(f"custom.list:{ctx.author.id}")) birdlist = f"{birdlist}" await ctx.send( f"**Your Custom Bird List** ({int(database.scard(f'custom.list:{ctx.author.id}'))} items)" ) await self.broken_send(ctx, birdlist, between="```\n") return if not database.exists( f"custom.list:{ctx.author.id}") or "replace" in args: # user inputted bird list, now validating start = time.perf_counter() if database.exists(f"custom.cooldown:{ctx.author.id}"): await ctx.send( "Sorry, you'll have to wait 24 hours between changing lists." ) return logger.info("reading received bird list") if not ctx.message.attachments: logger.info("no file detected") await ctx.send( "Sorry, no file was detected. Upload your txt file and put `b!custom` in the **Add a Comment** section." ) return decoded = await auto_decode(await ctx.message.attachments[0].read()) if not decoded: logger.info("invalid character encoding") await ctx.send( "Sorry, something went wrong. Are you sure this is a text file?" ) return parsed_birdlist = set( map(lambda x: x.strip(), decoded.strip().split("\n"))) parsed_birdlist.discard("") parsed_birdlist = list(parsed_birdlist) if len(parsed_birdlist) > 200: logger.info("parsed birdlist too long") await ctx.send( "Sorry, we're not supporting custom lists larger than 200 birds. Make sure there are no empty lines." ) return logger.info("checking for invalid characters") char = re.compile(r"[^A-Za-z '\-\xC0-\xD6\xD8-\xF6\xF8-\xFF]") for item in parsed_birdlist: if len(item) > 1000: logger.info("item too long") await ctx.send( f"Line starting with `{item[:100]}` exceeds 1000 characters." ) return search = char.search(item) if search: logger.info("invalid character detected") await ctx.send( f"An invalid character `{search.group()}` was detected. Only letters, spaces, hyphens, and apostrophes are allowed." ) await ctx.send( f"Error on line starting with `{item[:100]}`, position {search.span()[0]}" ) return database.delete(f"custom.list:{ctx.author.id}", f"custom.confirm:{ctx.author.id}") await self.validate(ctx, parsed_birdlist) elapsed = time.perf_counter() - start await ctx.send( f"**Finished validation in {round(elapsed//60)} minutes {round(elapsed%60, 4)} seconds.** {ctx.author.mention}" ) logger.info( f"Finished validation in {round(elapsed//60)} minutes {round(elapsed%60, 4)} seconds." ) return if database.exists(f"custom.confirm:{ctx.author.id}"): next_step = database.get(f"custom.confirm:{ctx.author.id}").decode( "utf-8") if next_step == "valid": await ctx.send( "You need to validate your list. Use `b!custom validate` to do so. " + "You can also delete or replace your list with `b!custom [delete|replace]`" ) return if next_step == "confirm": await ctx.send( "You need to confirm your list. Use `b!custom confirm` to do so. " + "You can also delete or replace your list with `b!custom [delete|replace]`" ) return if next_step == "delete": await ctx.send( "You're in the process of deleting your list. Use `b!custom delete` to do so. " + "You can also replace your list with `b!custom replace`") return capture_message( f"custom.confirm database invalid with {next_step}") await ctx.send( "Whoops, something went wrong. Please report this incident " + "in the support server below.\nhttps://discord.gg/2HbshwGjnm") return await ctx.send( "Use `b!custom view` to view your bird list or `b!custom replace` to replace your bird list." ) async def validate(self, ctx, parsed_birdlist): validated_birdlist = [] async with aiohttp.ClientSession() as session: logger.info("starting validation") await ctx.send( "**Validating bird list...**\n*This may take a while.*") invalid_output = [] valid_output = [] validity = [] for x in range(0, len(parsed_birdlist), 10): validity += await asyncio.gather( *(valid_bird(bird, session) for bird in parsed_birdlist[x:x + 10])) logger.info("sleeping during validation...") await asyncio.sleep(5) logger.info("checking validation") for item in validity: if item[1]: validated_birdlist.append( string.capwords( item[3].split(" - ")[0].strip().replace("-", " "))) valid_output.append( f"Item `{item[0]}`: Detected as **{item[3]}**\n") else: invalid_output.append( f"Item `{item[0]}`: **{item[2]}** {f'(Detected as *{item[3]}*)' if item[3] else ''}\n" ) logger.info("done validating") if valid_output: logger.info("sending validation success") valid_output = ( "**Succeeded Items:** Please verify items were detected correctly.\n" + "".join(valid_output)) await self.broken_send(ctx, valid_output) if invalid_output: logger.info("sending validation failure") invalid_output = "**FAILED ITEMS:** Please fix and resubmit.\n" + "".join( invalid_output) await self.broken_send(ctx, invalid_output) return False await ctx.send("**Saving bird list...**") database.sadd(f"custom.list:{ctx.author.id}", *validated_birdlist) database.expire(f"custom.list:{ctx.author.id}", 86400) database.set(f"custom.confirm:{ctx.author.id}", "valid", ex=86400) await ctx.send( "**Ok!** Your bird list has been temporarily saved. " + "Please use `b!custom validate` to view and confirm your bird list. " + "To start over, upload a new list with the message `b!custom replace`. " + "You have 24 hours to confirm before your bird list will automatically be deleted." ) return True @state.error async def set_error(self, ctx, error): logger.info("state set error") if isinstance(error, commands.MissingRequiredArgument): await ctx.send( f"**Please enter your state.**\n*Valid States:* `{'`, `'.join(states.keys())}`" ) else: await handle_error(ctx, error)
class Sessions(commands.Cog): def __init__(self, bot): self.bot = bot async def _get_options(self, ctx): filter_int, state, taxon, wiki, strict = database.hmget( f"session.data:{ctx.author.id}", ["filter", "state", "taxon", "wiki", "strict"], ) filters = Filter.from_int(int(filter_int)) options = textwrap.dedent(f"""\ **Active Filters:** `{'`, `'.join(filters.display())}` **State bird list:** {state.decode('utf-8') if state else 'None'} **Bird taxon:** {taxon.decode('utf-8') if taxon else 'None'} **Wiki Embeds**: {wiki==b'wiki'} **Strict Spelling**: {strict==b'strict'} """) return options async def _get_stats(self, ctx): start, correct, incorrect, total = map( int, database.hmget( f"session.data:{ctx.author.id}", ["start", "correct", "incorrect", "total"], ), ) elapsed = datetime.timedelta(seconds=round(time.time()) - start) try: accuracy = round(100 * (correct / (correct + incorrect)), 2) except ZeroDivisionError: accuracy = 0 stats = textwrap.dedent(f"""\ **Duration:** `{elapsed}` **# Correct:** {correct} **# Incorrect:** {incorrect} **Total Birds:** {total} **Accuracy:** {accuracy}% """) return stats async def _send_stats(self, ctx, preamble): database_key = f"session.incorrect:{ctx.author.id}" embed = discord.Embed(type="rich", colour=discord.Color.blurple(), title=preamble) embed.set_author(name="Bird ID - An Ornithology Bot") if database.zcard(database_key) != 0: leaderboard_list = database.zrevrangebyscore( database_key, "+inf", "-inf", 0, 5, True) leaderboard = "" for i, stats in enumerate(leaderboard_list): leaderboard += ( f"{i+1}. **{stats[0].decode('utf-8')}** - {int(stats[1])}\n" ) else: logger.info(f"no birds in {database_key}") leaderboard = "**There are no missed birds.**" embed.add_field(name="Options", value=await self._get_options(ctx), inline=False) embed.add_field(name="Stats", value=await self._get_stats(ctx), inline=False) embed.add_field(name="Top Missed Birds", value=leaderboard, inline=False) await ctx.send(embed=embed) @commands.group( brief="- Base session command", help="- Base session command\n" + "Sessions will record your activity for an amount of time and " + "will give you stats on how your performance and " + "also set global variables such as black and white, " + "state specific bird lists, specific bird taxons, or bird age/sex. ", aliases=["ses", "sesh"], ) async def session(self, ctx): if ctx.invoked_subcommand is None: await ctx.send( "**Invalid subcommand passed.**\n*Valid Subcommands:* `start, view, stop`" ) # starts session @session.command( brief="- Starts session", help="""- Starts session. Arguments passed will become the default arguments to 'b!bird', but can be manually overwritten during use. These settings can be changed at any time with 'b!session edit', and arguments can be passed in any order. However, having both females and juveniles are not supported.""", aliases=["st"], usage="[state] [taxons] [filters]", ) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.user)) async def start(self, ctx, *, args_str: str = ""): logger.info("command: start session") if database.exists(f"session.data:{ctx.author.id}"): logger.info("already session") await ctx.send( "**There is already a session running.** *Change settings/view stats with `b!session edit`*" ) return filters = Filter.parse(args_str) args = args_str.lower().split(" ") logger.info(f"args: {args}") if "wiki" in args: wiki = "" else: wiki = "wiki" if "strict" in args: strict = "strict" else: strict = "" states_args = set(states.keys()).intersection( {arg.upper() for arg in args}) if states_args: state = " ".join(states_args).strip() else: state = " ".join(check_state_role(ctx)) taxon_args = set(taxons.keys()).intersection( {arg.lower() for arg in args}) if taxon_args: taxon = " ".join(taxon_args).strip() else: taxon = "" logger.info( f"adding filters: {filters}; state: {state}; wiki: {wiki}; strict: {strict}" ) database.hset( f"session.data:{ctx.author.id}", mapping={ "start": round(time.time()), "stop": 0, "correct": 0, "incorrect": 0, "total": 0, "filter": str(filters.to_int()), "state": state, "taxon": taxon, "wiki": wiki, "strict": strict, }, ) await ctx.send( f"**Session started with options:**\n{await self._get_options(ctx)}" ) # views session @session.command( brief="- Views session", help= "- Views session\nSessions will record your activity for an amount of time and " + "will give you stats on how your performance and also set global variables such as black and white, " + "state specific bird lists, specific bird taxons, or bird age/sex. ", aliases=["view"], usage="[state] [taxons] [filters]", ) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.user)) async def edit(self, ctx, *, args_str: str = ""): logger.info("command: view session") if database.exists(f"session.data:{ctx.author.id}"): new_filter = Filter.parse(args_str, defaults=False) args = args_str.lower().split(" ") logger.info(f"args: {args}") new_filter ^= int( database.hget(f"session.data:{ctx.author.id}", "filter")) database.hset(f"session.data:{ctx.author.id}", "filter", str(new_filter.to_int())) if "wiki" in args: if database.hget(f"session.data:{ctx.author.id}", "wiki"): logger.info("enabling wiki embeds") database.hset(f"session.data:{ctx.author.id}", "wiki", "") else: logger.info("disabling wiki embeds") database.hset(f"session.data:{ctx.author.id}", "wiki", "wiki") if "strict" in args: if database.hget(f"session.data:{ctx.author.id}", "strict"): logger.info("disabling strict spelling") database.hset(f"session.data:{ctx.author.id}", "strict", "") else: logger.info("enabling strict spelling") database.hset(f"session.data:{ctx.author.id}", "strict", "strict") states_args = set(states.keys()).intersection( {arg.upper() for arg in args}) if states_args: current_states = set( database.hget(f"session.data:{ctx.author.id}", "state").decode("utf-8").split(" ")) logger.info(f"toggle states: {states_args}") logger.info(f"current states: {current_states}") states_args.symmetric_difference_update(current_states) logger.info(f"new states: {states_args}") database.hset( f"session.data:{ctx.author.id}", "state", " ".join(states_args).strip(), ) taxon_args = set(taxons.keys()).intersection( {arg.lower() for arg in args}) if taxon_args: current_taxons = set( database.hget(f"session.data:{ctx.author.id}", "taxon").decode("utf-8").split(" ")) logger.info(f"toggle taxons: {taxon_args}") logger.info(f"current taxons: {current_taxons}") taxon_args.symmetric_difference_update(current_taxons) logger.info(f"new taxons: {taxon_args}") database.hset( f"session.data:{ctx.author.id}", "taxon", " ".join(taxon_args).strip(), ) await self._send_stats(ctx, "**Session started previously.**\n") else: await ctx.send( "**There is no session running.** *You can start one with `b!session start`*" ) # stops session @session.command(help="- Stops session", aliases=["stp", "end"]) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.user)) async def stop(self, ctx): logger.info("command: stop session") if database.exists(f"session.data:{ctx.author.id}"): database.hset(f"session.data:{ctx.author.id}", "stop", round(time.time())) await self._send_stats(ctx, "**Session stopped.**\n") database.delete(f"session.data:{ctx.author.id}") database.delete(f"session.incorrect:{ctx.author.id}") else: await ctx.send( "**There is no session running.** *You can start one with `b!session start`*" )
class Meta(commands.Cog): def __init__(self, bot): self.bot = bot # bot info command - gives info on bot @commands.command( help="- Gives info on bot, support server invite, stats", aliases=["bot_info", "support"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def botinfo(self, ctx): logger.info("command: botinfo") embed = discord.Embed(type="rich", colour=discord.Color.blurple()) embed.set_author(name="Bird ID - An Ornithology Bot") embed.add_field( name="Bot Info", value="This bot was created by EraserBird and person_v1.32 " + "for helping people practice bird identification for Science Olympiad.\n" + "**By adding this bot to a server, you are agreeing to our " + "[Privacy Policy](<https://github.com/tctree333/Bird-ID/blob/master/PRIVACY.md>) and " + "[Terms of Service](<https://github.com/tctree333/Bird-ID/blob/master/TERMS.md>)**.\n" + "Bird-ID is licensed under the [GNU GPL v3.0](<https://github.com/tctree333/Bird-ID/blob/master/LICENSE>).", inline=False, ) embed.add_field( name="Support", value="If you are experiencing any issues, have feature requests, " + "or want to get updates on bot status, join our support server below.", inline=False, ) embed.add_field( name="Stats", value= f"This bot can see {len(self.bot.users)} users and is in {len(self.bot.guilds)} servers. " + f"The WebSocket latency is {round((self.bot.latency*1000))} ms.", inline=False, ) await ctx.send(embed=embed) await ctx.send("https://discord.gg/fXxYyDJ") # ping command - gives bot latency @commands.command( help="- Pings the bot and displays latency", ) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) async def ping(self, ctx): logger.info("command: ping") lat = round(self.bot.latency * 1000) logger.info(f"latency: {lat}") await ctx.send(f"**Pong!** The WebSocket latency is `{lat}` ms.") # invite command - sends invite link @commands.command(help="- Get the invite link for this bot") @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def invite(self, ctx): logger.info("command: invite") embed = discord.Embed(type="rich", colour=discord.Color.blurple()) embed.set_author(name="Bird ID - An Ornithology Bot") embed.add_field( name="Invite", value= "To invite this bot to your own server, use the following invite links.\n" + "**Bird-ID:** https://discord.com/api/oauth2/authorize?client_id=601917808137338900&permissions=268486656&scope=bot\n" + "**Orni-Bot:** https://discord.com/api/oauth2/authorize?client_id=601755752410906644&permissions=268486656&scope=bot\n\n" + "**By adding this bot to a server, you are agreeing to our `Privacy Policy` and `Terms of Service`**.\n" + "<https://github.com/tctree333/Bird-ID/blob/master/PRIVACY.md>, <https://github.com/tctree333/Bird-ID/blob/master/TERMS.md>\n\n" + "Unfortunately, Orni-Bot is currently unavailable. For more information, visit our support server below.", inline=False, ) await ctx.send(embed=embed) await ctx.send("https://discord.gg/fXxYyDJ") # ignore command - ignores a given channel @commands.command( brief="- Ignore all commands in a channel", help= "- Ignore all commands in a channel. The 'manage guild' permission is needed to use this command.", ) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) @commands.guild_only() @commands.has_guild_permissions(manage_guild=True) async def ignore(self, ctx, channels: commands.Greedy[discord.TextChannel] = None): logger.info("command: invite") added = "" removed = "" if channels is not None: logger.info(f"ignored channels: {[c.name for c in channels]}") for channel in channels: if database.zscore("ignore:global", str(channel.id)) is None: added += f"`#{channel.name}` (`{channel.category.name if channel.category else 'No Category'}`)\n" database.zadd("ignore:global", {str(channel.id): ctx.guild.id}) else: removed += f"`#{channel.name}` (`{channel.category.name if channel.category else 'No Category'}`)\n" database.zrem("ignore:global", str(channel.id)) else: await ctx.send("**No valid channels were passed.**") ignored = "".join([ f"`#{channel.name}` (`{channel.category.name if channel.category else 'No Category'}`)\n" for channel in map( lambda c: ctx.guild.get_channel(int(c)), database.zrangebyscore("ignore:global", ctx.guild.id - 0.1, ctx.guild.id + 0.1), ) ]) await ctx.send( (f"**Ignoring:**\n{added}" if added else "") + (f"**Stopped ignoring:**\n{removed}" if removed else "") + (f"**Ignored Channels:**\n{ignored}" if ignored else "**No channels in this server are currently ignored.**")) # leave command - removes itself from guild @commands.command( brief="- Remove the bot from the guild", help= "- Remove the bot from the guild. The 'manage guild' permission is needed to use this command.", aliases=["kick"], ) @commands.check(CustomCooldown(2.0, bucket=commands.BucketType.channel)) @commands.guild_only() @commands.has_guild_permissions(manage_guild=True) async def leave(self, ctx, confirm: typing.Optional[bool] = False): logger.info("command: leave") if database.exists(f"leave:{ctx.guild.id}"): logger.info("confirming") if confirm: logger.info(f"confirmed. Leaving {ctx.guild}") database.delete(f"leave:{ctx.guild.id}") await ctx.send("**Ok, bye!**") await ctx.guild.leave() return logger.info("confirm failed. leave canceled") database.delete(f"leave:{ctx.guild.id}") await ctx.send("**Leave canceled.**") return logger.info("not confirmed") database.set(f"leave:{ctx.guild.id}", 0, ex=60) await ctx.send( "**Are you sure you want to remove me from the guild?**\n" + "Use `b!leave yes` to confirm, `b!leave no` to cancel. " + "You have 60 seconds to confirm before it will automatically cancel." ) # ban command - prevents certain users from using the bot @commands.command(help="- ban command", hidden=True) @commands.is_owner() async def ban(self, ctx, *, user: typing.Optional[discord.Member] = None): logger.info("command: ban") if user is None: logger.info("no args") await ctx.send("Invalid User!") return logger.info(f"user-id: {user.id}") database.zadd("banned:global", {str(user.id): 0}) await ctx.send(f"Ok, {user.name} cannot use the bot anymore!") # unban command - prevents certain users from using the bot @commands.command(help="- unban command", hidden=True) @commands.is_owner() async def unban(self, ctx, *, user: typing.Optional[discord.Member] = None): logger.info("command: unban") if user is None: logger.info("no args") await ctx.send("Invalid User!") return logger.info(f"user-id: {user.id}") database.zrem("banned:global", str(user.id)) await ctx.send(f"Ok, {user.name} can use the bot!")
class Other(commands.Cog): def __init__(self, bot): self.bot = bot @staticmethod def broken_join(input_list: list[str], max_size: int = MAX_MESSAGE) -> list[str]: pages: list[str] = [] lines: list[str] = [] block_length = 0 for line in input_list: lines.append(line) block_length += len(line) if block_length > max_size: page = "\n".join(lines) pages.append(page) lines.clear() block_length = 0 if lines: page = "\n".join(lines) pages.append(page) return pages # Info - Gives call+image of 1 bird @commands.command( brief="- Gives an image and call of a bird", help= "- Gives an image and call of a bird. The bird name must come before any options.", usage="[bird] [options]", aliases=["i"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def info(self, ctx, *, arg): logger.info("command: info") arg = arg.lower().strip() filters = Filter.parse(arg) if filters.vc: filters.vc = False await ctx.send("**The VC filter is not allowed here!**") options = filters.display() arg = arg.split(" ") bird = None if len(arg[0]) == 4: bird = alpha_codes.get(arg[0].upper()) if not bird: for i in reversed(range(1, 6)): # try the first 5 words, then first 4, etc. looking for a match matches = get_close_matches( string.capwords(" ".join(arg[:i]).replace("-", " ")), birdListMaster + sciListMaster, n=1, cutoff=0.8, ) if matches: bird = matches[0] break if not bird: await ctx.send("Bird not found. Are you sure it's on the list?") return delete = await ctx.send("Please wait a moment.") if options: await ctx.send(f"**Detected filters**: `{'`, `'.join(options)}`") an = "an" if bird.lower()[0] in ("a", "e", "i", "o", "u") else "a" await send_bird(ctx, bird, "images", filters, message=f"Here's {an} *{bird.lower()}* image!") await send_bird(ctx, bird, "songs", filters, message=f"Here's {an} *{bird.lower()}* song!") await delete.delete() return # Filter command - lists available Macaulay Library filters and aliases @commands.command(help="- Lists available Macaulay Library filters.", aliases=["filter"]) @commands.check(CustomCooldown(8.0, bucket=commands.BucketType.user)) async def filters(self, ctx): logger.info("command: filters") filters = Filter.aliases() embed = discord.Embed( title="Media Filters", type="rich", description="Filters can be space-separated or comma-separated. " + "You can use any alias to set filters. " + "Please note media will only be shown if it " + "matches all the filters, so using filters can " + "greatly reduce the number of media returned.", color=discord.Color.green(), ) embed.set_author(name="Bird ID - An Ornithology Bot") for title, subdict in filters.items(): value = "".join((f"**{name.title()}**: `{'`, `'.join(aliases)}`\n" for name, aliases in subdict.items())) embed.add_field(name=title.title(), value=value, inline=False) await ctx.send(embed=embed) # List command - argument is state/bird list @commands.command(help="- DMs the user with the appropriate bird list.", name="list") @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def list_of_birds(self, ctx, state: str = "blank"): logger.info("command: list") state = state.upper() if state not in states: logger.info("invalid state") await ctx.send( f"**Sorry, `{state}` is not a valid state.**\n*Valid States:* `{', '.join(map(str, list(states.keys())))}`" ) return state_birdlist = sorted( build_id_list(user_id=ctx.author.id, state=state, media="images")) state_songlist = sorted( build_id_list(user_id=ctx.author.id, state=state, media="songs")) birdLists = self.broken_join(state_birdlist) songLists = self.broken_join(state_songlist) if ctx.author.dm_channel is None: await ctx.author.create_dm() await ctx.author.dm_channel.send(f"**The {state} bird list:**") for birds in birdLists: await ctx.author.dm_channel.send(f"```\n{birds}```") await ctx.author.dm_channel.send(f"**The {state} bird songs:**") for birds in songLists: await ctx.author.dm_channel.send(f"```\n{birds}```") await ctx.send( f"The `{state}` bird list has **{len(state_birdlist)}** birds.\n" + f"The `{state}` bird list has **{len(state_songlist)}** songs.\n" + "*A full list of birds has been sent to you via DMs.*") # taxons command - argument is state/bird list @commands.command( help="- DMs the user with the appropriate bird list.", name="taxon", aliases=["taxons", "orders", "families", "order", "family"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def bird_taxons(self, ctx, taxon: str = "blank", state: str = "NATS"): logger.info("command: taxons") taxon = taxon.lower() state = state.upper() if taxon not in taxons: logger.info("invalid taxon") await ctx.send( f"**Sorry, `{taxon}` is not a valid taxon.**\n*Valid taxons:* `{', '.join(map(str, list(taxons.keys())))}`" ) return if state not in states: logger.info("invalid state") await ctx.send( f"**Sorry, `{state}` is not a valid state.**\n*Valid States:* `{', '.join(map(str, list(states.keys())))}`" ) return bird_list = sorted( build_id_list(user_id=ctx.author.id, taxon=taxon, state=state, media="images")) song_bird_list = sorted( build_id_list(user_id=ctx.author.id, taxon=taxon, state=state, media="songs")) if not bird_list and not song_bird_list: logger.info("no birds for taxon/state") await ctx.send( "**Sorry, no birds could be found for the taxon/state combo.**\n*Please try again*" ) return birdLists = self.broken_join(bird_list) songLists = self.broken_join(song_bird_list) if ctx.author.dm_channel is None: await ctx.author.create_dm() await ctx.author.dm_channel.send( f"**The `{taxon}` in the `{state}` bird list:**") for birds in birdLists: await ctx.author.dm_channel.send(f"```\n{birds}```") await ctx.author.dm_channel.send( f"**The `{taxon}` in the `{state}` bird songs:**") for birds in songLists: await ctx.author.dm_channel.send(f"```\n{birds}```") await ctx.send( f"The `{taxon}` in the `{state}` bird list has **{len(bird_list)}** birds.\n" + f"The `{taxon}` in the `{state}` bird list has **{len(song_bird_list)}** songs.\n" + "*A full list of birds has been sent to you via DMs.*") # Wiki command - argument is the wiki page @commands.command(help="- Fetch the wikipedia page for any given argument", aliases=["wiki"]) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.user)) async def wikipedia(self, ctx, *, arg): logger.info("command: wiki") arg = arg.capitalize() try: page = wikipedia.page(arg, auto_suggest=False) except (wikipedia.exceptions.DisambiguationError, wikipedia.exceptions.PageError): try: page = wikipedia.page(f"{arg} (bird)", auto_suggest=False) except (wikipedia.exceptions.DisambiguationError, wikipedia.exceptions.PageError): # fall back to suggestion try: page = wikipedia.page(arg) except wikipedia.exceptions.DisambiguationError: await ctx.send( "Sorry, that page was not found. Try being more specific." ) return except wikipedia.exceptions.PageError: await ctx.send("Sorry, that page was not found.") return await ctx.send(page.url) # meme command - sends a random bird video/gif @commands.command(help="- Sends a funny bird video!") @commands.check( CustomCooldown(180.0, disable=True, bucket=commands.BucketType.user)) async def meme(self, ctx): logger.info("command: meme") await ctx.send(random.choice(memeList)) # Send command - for testing purposes only @commands.command(help="- send command", hidden=True, aliases=["sendas"]) @commands.is_owner() async def send_as_bot(self, ctx, *, args): logger.info("command: send") logger.info(f"args: {args}") channel_id = int(args.split(" ")[0]) message = args.strip(str(channel_id)) channel = self.bot.get_channel(channel_id) await channel.send(message) await ctx.send("Ok, sent!") # Test command - for testing purposes only @commands.command(help="- test command", hidden=True) @commands.is_owner() async def cache(self, ctx): logger.info("command: cache stats") items = [] with contextlib.suppress(FileNotFoundError): items += os.listdir("bot_files/cache/images/") with contextlib.suppress(FileNotFoundError): items += os.listdir("bot_files/cache/songs/") stats = { "sciname_cache": get_sciname.cache_info(), "taxon_cache": get_taxon.cache_info(), "num_downloaded_birds": len(items), } await ctx.send(f"```python\n{stats}```") # Test command - for testing purposes only @commands.command(help="- test command", hidden=True) @commands.is_owner() async def error(self, ctx): logger.info("command: error") await ctx.send(1 / 0) # Test command - for testing purposes only @commands.command(help="- test command", hidden=True) @commands.is_owner() async def test( self, ctx, *, user: typing.Optional[typing.Union[discord.Member, discord.User, str]] = None, ): logger.info("command: test") await ctx.send(f"```\nMembers Intent: {self.bot.intents.members}\n" + f"Message Mentions: {ctx.message.mentions}\n" + f"User: {user}\nType: {type(user)}```")
class Stats(commands.Cog): def __init__(self, bot): self.bot = bot @staticmethod def generate_series(database_key): """Generates a pandas.Series from a Redis sorted set.""" logger.info("generating series") data = database.zrevrangebyscore(database_key, "+inf", "-inf", withscores=True) return pd.Series({ e[0]: e[1] for e in map(lambda x: (x[0].decode("utf-8"), int(x[1])), data) }) @staticmethod def generate_dataframe(database_keys, titles, index=None): """Generates a pandas.DataFrame from multiple Redis sorted sets.""" pipe = database.pipeline() for key in database_keys: pipe.zrevrangebyscore(key, "+inf", "-inf", withscores=True) result = pipe.execute() index = {v[0].decode("utf-8") for r in result for v in r}.union(set(index if index else {})) df = pd.DataFrame(index=index) for title, item in zip(titles, result): df.insert( len(df.columns), title, pd.Series({ e[0]: e[1] for e in map(lambda x: (x[0].decode("utf-8"), int(x[1])), item) }), ) df = df.fillna(value=0).astype(int) return df async def convert_users(self, df): """Converts discord user ids in DataFrames or Series indexes to usernames.""" current_ids = df.index new_index = [] for user_id in current_ids: user = await fetch_get_user(int(user_id), bot=self.bot, member=False) if user is None: new_index.append("User Unavailable") else: new_index.append(f"{user.name}#{user.discriminator}") df.index = new_index return df # give frequency stats @commands.command( help="- Gives info on command/bird frequencies", usage="[command|commands|c bird|birds|b] [page]", aliases=["freq"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def frequency(self, ctx, scope="", page=1): logger.info("command: frequency") if scope in ("command", "commands", "c"): database_key = "frequency.command:global" title = "Most Frequently Used Commands" elif scope in ("bird", "birds", "b"): database_key = "frequency.bird:global" title = "Most Frequent Birds" else: await ctx.send( "**Invalid Scope!**\n*Valid Scopes:* `commands, birds`") return await send_leaderboard(ctx, title, page, database_key) # give bot stats @commands.command( help="- Gives statistics on different topics", usage="[topic]", aliases=["stat"], ) @commands.check(CustomCooldown(5.0, bucket=commands.BucketType.channel)) async def stats(self, ctx, topic="help"): logger.info("command: stats") if topic in ("scores", "score", "s"): topic = "scores" elif topic in ("usage", "u"): topic = "usage" elif topic in ("web", "w"): topic = "web" elif topic in ("help", ""): topic = "help" else: valid_topics = ("help", "scores", "usage", "web") await ctx.send( f"**`{topic}` is not a valid topic!**\nValid Topics: `{'`, `'.join(valid_topics)}`" ) return embed = discord.Embed( title="Bot Stats", type="rich", color=discord.Color.blue(), ) if topic == "help": embed.description = ( "**Available statistic topics.**\n" + "This command is in progress and more stats may be added. " + "If there is a statistic you would like to see here, " + "please let us know in the support server.") embed.add_field( name="Scores", value= "`b!stats [scores|score|s]`\n*Displays stats about scores.*", ).add_field( name="Usage", value="`b!stats [usage|u]`\n*Displays stats about usage.*", ).add_field( name="Web", value="`b!stats [web|w]`\n*Displays stats about web usage.*", ) elif topic == "scores": embed.description = "**Score Statistics**" scores = self.generate_series("users:global") scores = scores[scores > 0] c, d = np.histogram(scores, bins=range(0, 1100, 100), range=(0, 1000)) c = (c / len(scores) * 100).round(1) embed.add_field( name="Totals", inline=False, value="**Sum of top 10 user scores:** `{:,}`\n".format( scores.nlargest(n=10).sum()) + "**Sum of all positive user scores:** `{:,}`\n".format( scores.sum()), ).add_field( name="Computations", inline=False, value="**Mean of all positive user scores:** `{:,.2f}`\n". format(scores.mean()) + "**Median of all positive user scores:** `{:,.1f}`\n".format( scores.median()), ).add_field( name="Distributions", inline=False, value= f"**Number of users with scores over mean:** `{len(scores[scores > scores.mean()])}`\n" + "**Percentage of users with scores over mean:** `{:.1%}`". format(len(scores[scores > scores.mean()]) / len(scores)) + "\n**Percentage of users with scores between:**\n" + "".join( f"\u2192 *{d[i]}-{d[i+1]-1}*: `{c[i]}%`\n" # \u2192 is the "Rightwards Arrow" for i in range(len(c))), ) elif topic == "usage": embed.description = "**Usage Statistics**" today = datetime.datetime.now(datetime.timezone.utc).date() past_month = pd.date_range( # pylint: disable=no-member today - datetime.timedelta(29), today).date keys = list(f"daily.score:{str(date)}" for date in past_month) keys = ["users:global"] + keys titles = reversed(range( 1, 32)) # label columns by # days ago, today is 1 day ago month = self.generate_dataframe(keys, titles) total = month.loc[:, 31] month = month.loc[:, 30:1] # remove totals column month = month.loc[(month != 0).any(1)] # remove users with all 0s week = month.loc[:, 7:1] # generate week from month week = week.loc[(week != 0).any(1)] today = week.loc[:, 1] # generate today from week today = today.loc[today != 0] channels_see = len(list(self.bot.get_all_channels())) channels_used = int(database.zcard("score:global")) embed.add_field( name="Today (Since midnight UTC)", inline=False, value="**Accounts that answered at least 1 correctly:** `{:,}`\n" .format(len(today)) + "**Total birds answered correctly:** `{:,}`\n".format( today.sum()), ).add_field( name="Last 7 Days", inline=False, value="**Accounts that answered at least 1 correctly:** `{:,}`\n" .format(len(week)) + "**Total birds answered correctly:** `{:,}`\n".format( week.sum().sum()), ).add_field( name="Last 30 Days", inline=False, value="**Accounts that answered at least 1 correctly:** `{:,}`\n" .format(len(month)) + "**Total birds answered correctly:** `{:,}`\n".format( month.sum().sum()), ).add_field( name="Total", inline=False, value="**Channels the bot can see:** `{:,}`\n".format( channels_see) + "**Channels that have used the bot at least once:** `{:,} ({:,.1%})`\n" .format(channels_used, channels_used / channels_see) + "*(Note: Deleted channels or channels that the bot can't see anymore are still counted).*\n" + "**Accounts that have used any command at least once:** `{:,}`\n" .format(len(total)) + "**Accounts that answered at least 1 correctly:** `{:,} ({:,.1%})`\n" .format(len(total[total > 0]), len(total[total > 0]) / len(total)), ) elif topic == "web": embed.description = "**Web Usage Statistics**" today = datetime.datetime.now(datetime.timezone.utc).date() past_month = pd.date_range( # pylint: disable=no-member today - datetime.timedelta(29), today).date web_score = (f"daily.webscore:{str(date)}" for date in past_month) web_usage = (f"daily.web:{str(date)}" for date in past_month) titles = tuple(reversed(range( 1, 31))) # label columns by # days ago, today is 1 day ago web_score_month = self.generate_dataframe(web_score, titles) web_score_week = web_score_month.loc[:, 7: 1] # generate week from month web_score_week = web_score_week.loc[( web_score_week != 0).any(1)] # remove users with no correct answers web_score_today = web_score_week.loc[:, 1] # generate today from week web_score_today = web_score_today.loc[ web_score_today != 0] # remove users with no correct answers web_usage_month = self.generate_dataframe(web_usage, titles, index=("check", "skip", "hint")) web_usage_week = web_usage_month.loc[:, 7:1] web_usage_today = web_usage_week.loc[:, 1] score_totals_keys = sorted( map( lambda x: x.decode("utf-8"), database.scan_iter(match="daily.webscore:????-??-??", count=5000), )) score_totals_titles = map(lambda x: x.split(":")[1], score_totals_keys) web_score_total = self.generate_dataframe(score_totals_keys, score_totals_titles) usage_totals_keys = sorted( map( lambda x: x.decode("utf-8"), database.scan_iter(match="daily.web:????-??-??", count=5000), )) usage_totals_titles = map(lambda x: x.split(":")[1], usage_totals_keys) web_usage_total = self.generate_dataframe(usage_totals_keys, usage_totals_titles, index=("check", "skip", "hint")) embed.add_field( name="Today (Since midnight UTC)", inline=False, value="**Accounts that answered at least 1 correctly:** `{:,}`\n" .format(len(web_score_today)) + "**Total birds answered correctly:** `{:,}`\n".format( web_score_today.sum()) + "**Check command usage:** `{:,}`\n".format( web_usage_today.loc["check"]) + "**Skip command usage:** `{:,}`\n".format( web_usage_today.loc["skip"]) + "**Hint command usage:** `{:,}`\n".format( web_usage_today.loc["hint"]), ).add_field( name="Last 7 Days", inline=False, value="**Accounts that answered at least 1 correctly:** `{:,}`\n" .format(len(web_score_week)) + "**Total birds answered correctly:** `{:,}`\n".format( web_score_week.sum().sum()) + "**Check command usage:** `{:,}`\n".format( web_usage_week.loc["check"].sum()) + "**Skip command usage:** `{:,}`\n".format( web_usage_week.loc["skip"].sum()) + "**Hint command usage:** `{:,}`\n".format( web_usage_week.loc["hint"].sum()), ).add_field( name="Last 30 Days", inline=False, value="**Accounts that answered at least 1 correctly:** `{:,}`\n" .format(len(web_score_month)) + "**Total birds answered correctly:** `{:,}`\n".format( web_score_month.sum().sum()) + "**Check command usage:** `{:,}`\n".format( web_usage_month.loc["check"].sum()) + "**Skip command usage:** `{:,}`\n".format( web_usage_month.loc["skip"].sum()) + "**Hint command usage:** `{:,}`\n".format( web_usage_month.loc["hint"].sum()), ).add_field( name="Total", inline=False, value="**Accounts that answered at least 1 correctly:** `{:,}`\n" .format(len(web_score_total)) + "**Total birds answered correctly:** `{:,}`\n".format( web_score_total.sum().sum()) + "**Check command usage:** `{:,}`\n".format( web_usage_total.loc["check"].sum()) + "**Skip command usage:** `{:,}`\n".format( web_usage_total.loc["skip"].sum()) + "**Hint command usage:** `{:,}`\n".format( web_usage_total.loc["hint"].sum()), ) await ctx.send(embed=embed) return # export data as csv @commands.command(help="- Exports bot data as a csv") @commands.check(CustomCooldown(60.0, bucket=commands.BucketType.channel)) async def export(self, ctx): logger.info("command: export") files = [] async def _export_helper(database_keys, header: str, filename: str, users=False): if not isinstance(database_keys, str) and len(database_keys) > 1: data = self.generate_dataframe(database_keys, header.strip().split(",")[1:]) else: key = (database_keys if isinstance(database_keys, str) else database_keys[0]) data = self.generate_series(key) if users: data = await self.convert_users(data) with StringIO() as f: f.write(header) data.to_csv(f, header=False) with BytesIO(f.getvalue().encode("utf-8")) as b: files.append(discord.File(b, filename)) logger.info("exporting freq command") await _export_helper( "frequency.command:global", "command,amount used\n", "command_frequency.csv", users=False, ) logger.info("exporting freq bird") await _export_helper( "frequency.bird:global", "bird,amount seen\n", "bird_frequency.csv", users=False, ) logger.info("exporting streaks") await _export_helper( ["streak:global", "streak.max:global"], "username#discrim,current streak,max streak\n", "streaks.csv", True, ) logger.info("exporting missed") keys = sorted( map( lambda x: x.decode("utf-8"), database.scan_iter(match="daily.incorrect:????-??-??", count=5000), )) titles = ",".join(map(lambda x: x.split(":")[1], keys)) keys = ["incorrect:global"] + keys await _export_helper(keys, f"bird name,total missed,{titles}\n", "missed.csv", users=False) logger.info("exporting scores") keys = sorted( map( lambda x: x.decode("utf-8"), database.scan_iter(match="daily.score:????-??-??", count=5000), )) titles = ",".join(map(lambda x: x.split(":")[1], keys)) keys = ["users:global"] + keys await _export_helper(keys, f"username#discrim,total score,{titles}\n", "scores.csv", users=True) logger.info("exporting web scores") keys = sorted( map( lambda x: x.decode("utf-8"), database.scan_iter(match="daily.webscore:????-??-??", count=5000), )) titles = ",".join(map(lambda x: x.split(":")[1], keys)) await _export_helper(keys, f"username#discrim,{titles}\n", "web_scores.csv", users=True) logger.info("exporting web usage") keys = sorted( map( lambda x: x.decode("utf-8"), database.scan_iter(match="daily.web:????-??-??", count=5000), )) titles = ",".join(map(lambda x: x.split(":")[1], keys)) await _export_helper(keys, f"command,{titles}\n", "web_usage.csv", users=False) await ctx.send(files=files)
class Voice(commands.Cog): def __init__(self, bot): self.bot = bot self.cleanup.start() def cog_unload(self): self.cleanup.cancel() @commands.command(help="- Play a sound") @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) @commands.guild_only() async def play(self, ctx): logger.info("command: play") await voice_functions.play(ctx, None) @commands.command(help="- Pause playing") @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) @commands.guild_only() async def pause(self, ctx): logger.info("command: pause") await voice_functions.pause(ctx) @commands.command(help="- Stop playing") @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) @commands.guild_only() async def stop(self, ctx): logger.info("command: stop") await voice_functions.stop(ctx) @commands.command(help="- Skip forward 5 seconds", aliases=["fw", "forwards"]) @commands.check(CustomCooldown(2.0, bucket=commands.BucketType.channel)) @commands.guild_only() async def forward(self, ctx, seconds: int = 5): logger.info("command: forward") if seconds < 1: await ctx.send("Invalid number of seconds!") return await voice_functions.rel_seek(ctx, seconds) @commands.command(help="- Skip back 5 seconds", aliases=["bk", "backward", "backwards"]) @commands.check(CustomCooldown(2.0, bucket=commands.BucketType.channel)) @commands.guild_only() async def back(self, ctx, seconds: int = 5): logger.info("command: back") if seconds < 1: await ctx.send("Invalid number of seconds!") return await voice_functions.rel_seek(ctx, seconds * -1) @commands.command(help="- Replay", aliases=["rp", "re"]) @commands.check(CustomCooldown(2.0, bucket=commands.BucketType.channel)) @commands.guild_only() async def replay(self, ctx): logger.info("command: replay") await voice_functions.rel_seek(ctx, None) @commands.command(help="- Disconnect from voice", aliases=["dc"]) @commands.check(CustomCooldown(3.0, bucket=commands.BucketType.channel)) @commands.guild_only() async def disconnect(self, ctx): logger.info("command: disconnect") current_voice = database.get(f"voice.server:{ctx.guild.id}") if current_voice is not None: race = ctx.bot.get_cog("Race") await race.stop_race_(ctx) else: await voice_functions.disconnect(ctx) @tasks.loop(minutes=10) async def cleanup(self): logger.info("running cleanup task") await voice_functions.cleanup(self.bot)