def _cache_get(key, default=None): if local: return _cache.get(key, default) data = database.get(f"cache.{func.__name__}:{key}") if data is None: return default return pickle.loads(data)
def __call__(self, ctx: commands.Context): if (ctx.command.name in ( "bird", "song", "goatsucker", "check", "skip", ) and database.exists("cooldown:global") and int(database.get("cooldown:global")) > 1): bucket = self.rate_limit_mapping.get_bucket(ctx.message) elif not self.disable and ctx.guild is None: bucket = self.dm_mapping.get_bucket(ctx.message) elif ctx.channel.name.startswith( "racing") and ctx.command.name.startswith("check"): bucket = self.race_mapping.get_bucket(ctx.message) else: bucket = self.default_mapping.get_bucket(ctx.message) retry_after = bucket.update_rate_limit() if retry_after: raise commands.CommandOnCooldown(bucket, retry_after) return True
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)
async def get_voice_client( ctx, connect: bool = False, silent: bool = False) -> Optional[discord.VoiceClient]: logger.info("fetching voice client") voice = None if ctx.author: voice = ctx.author.voice if voice is None or voice.channel is None or voice.channel.guild != ctx.guild: await _send(ctx, silent, "**Please join a voice channel to connect the bot!**") return None 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") bound_channel = ctx.guild.get_channel(int(current_voice)) await ctx.send("**The voice channel is currently in use!**" + (f"\n*Use {bound_channel.mention} for vc settings!*" if bound_channel else "")) return None client: discord.VoiceClient = discord.utils.find( lambda x: x.guild == ctx.guild, ctx.bot.voice_clients) if client is None: if connect and voice: try: client = await voice.channel.connect() await _send(ctx, silent, f"Connected to {voice.channel.mention}") return client except asyncio.TimeoutError: await _send( ctx, silent, "**Could not connect to voice channel in time.**\n*Please try again.*", ) except discord.ClientException: await _send( ctx, silent, "**I'm already connected to another voice channel!**") else: await _send(ctx, silent, "**The bot isn't in a voice channel!**") return None if voice and client.channel != voice.channel: await _send( ctx, silent, "**You need to be in the same voice channel as the bot!**") return None return client
async def cleanup(bot): logger.info("cleaning up empty channels") for client in bot.voice_clients: if len(client.channel.voice_states) == 1: logger.info("found empty") current_voice = database.get(f"voice.server:{client.guild.id}") if current_voice is not None: logger.info("vc race") bound_channel = client.guild.get_channel(int(current_voice)) race = bot.get_cog("Race") await race.stop_race_(FauxContext(bound_channel, bot)) else: await client.disconnect() logger.info("done cleaning!")
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 )
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!*", )
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 _get_urls( session: aiohttp.ClientSession, bird: str, media_type: str, filters: Filter, retries: int = 0, ): """Returns a list of urls to Macaulay Library media. The amount of urls returned is specified in `COUNT`. Media URLs are fetched using Macaulay Library's internal JSON API, with `CATALOG_URL`. Raises a `GenericError` if fails.\n Some urls may return an error code of 476 (because it is still being processed), if so, ignore that url. `session` (aiohttp ClientSession)\n `bird` (str) - can be either common name or scientific name\n `media_type` (str) - either `p` for pictures, `a` for audio, or `v` for video\n `filters` (bot.filters Filter) """ logger.info(f"getting file urls for {bird}") taxon_code = (await get_taxon(bird, session))[0] database_key = f"{media_type}/{bird}{filters.to_int()}" cursor = (database.get(f"media.cursor:{database_key}") or b"").decode() catalog_url = filters.url(taxon_code, media_type, COUNT, cursor) async with session.get(catalog_url) as catalog_response: if catalog_response.status != 200: if retries >= 3: logger.info("Retried more than 3 times. Aborting...") raise GenericError( f"An http error code of {catalog_response.status} occurred " + f"while fetching {catalog_url} for a {'image' if media_type=='p' else 'song'} for {bird}", code=201, ) retries += 1 logger.info( f"An HTTP error occurred; Retries: {retries}; Sleeping: {1.5**retries}" ) await asyncio.sleep(1.5**retries) urls = await _get_urls(session, bird, media_type, filters, retries) return urls catalog_data = await catalog_response.json() database.set( f"media.cursor:{database_key}", catalog_data["results"]["nextCursorMark"] or b"", ) content = catalog_data["results"]["content"] urls = ([data["mediaUrl"] for data in content] if filters.large or media_type == "a" else [data["previewUrl"] for data in content]) if not urls: if retries >= 1: raise GenericError("No urls found.", code=100) logger.info("retrying without cursor") retries += 1 urls = await _get_urls(session, bird, media_type, filters, retries) return urls return urls