async def ping(self, ctx: commands.context.Context): """ Tests the bot and Discord message response times """ self._log.debug("Pong!") # Time between when the message was sent and when we processed it now = datetime.utcnow().timestamp() response_time = round( (now - ctx.message.created_at.timestamp()) * 1000, 1) embed = discord.Embed() embed.description = lang('Misc', 'ping_response', { 'server': response_time, 'message': 'Pending...' }) # Time between when we sent the message and when it was registered by Discord now = datetime.utcnow().timestamp() message = await ctx.send(embed=embed) message_delay = round((datetime.utcnow().timestamp() - now) * 1000, 1) embed.description = lang('Misc', 'ping_response', { 'server': response_time, 'message': message_delay }) await message.edit(embed=embed)
async def sauce_error(self, ctx: commands.context.Context, error) -> None: """ Override guild cooldowns for servers with their own API keys provided Args: ctx (commands.context.Context): error (Exception): Returns: None """ if isinstance(error, commands.CommandOnCooldown): if Servers.lookup_guild(ctx.guild): self._log.info( f"[{ctx.guild.name}] Guild has an enhanced API key; ignoring triggered guild API limit" ) await ctx.reinvoke() return self._log.info( f"[{ctx.guild.name}] Guild has exceeded their available API queries for the day" ) await ctx.send(embed=basic_embed( title=lang('Global', 'generic_error'), description=lang('Sauce', 'api_limit_exceeded'))) raise error
async def info(self, ctx: commands.Context): """ Learn more about the SauceBot project and how to contribute! """ embed = discord.Embed() embed.set_thumbnail(url=bot.user.avatar_url) embed.title = lang('Misc', 'info_title') embed.url = 'https://www.patreon.com/saucebot' embed.description = lang('Misc', 'info_desc') await ctx.send(embed=embed)
async def unabn_guild(self, ctx: commands.context.Context, guild_id: int): """ Removed a specified guild from the bots banlist """ # Make sure the guild has actually been banned if not GuildBanlist.check(guild_id): await ctx.send(lang('Admin', 'gban_not_banned'), delete_after=15.0) return self._log.warning(f"Removing guild {guild_id} from the guild banlist") GuildBanlist.unban(guild_id) await ctx.send(lang('Admin', 'gban_unban_success'))
async def apikey(self, ctx: commands.context.Context, api_key: str) -> None: """ Define your own enhanced SauceNao API key for this server. This can only be used to add enhanced / upgraded API keys, not freely registered ones. Adding your own enhanced API key will remove the shared daily API query limit from your server. You can get an enhanced API key from the following page: https://saucenao.com/user.php?page=account-upgrades """ await ctx.message.delete() # Make sure the API key is formatted properly if not self._re_api_key.match(api_key): await ctx.send( embed=basic_embed(title=lang('Global', 'generic_error'), description=lang('Sauce', 'bad_api_key'))) return # Test and make sure it's a valid enhanced-level API key saucenao = SauceNao(api_key=api_key) test = await saucenao.test() # Make sure the test went through successfully if not test.success: self._log.error( f"[{ctx.guild.name}] An unknown error occurred while assigning an API key to this server", exc_info=test.error) await ctx.send( embed=basic_embed(title=lang('Global', 'generic_error'), description=lang('Sauce', 'api_offline'))) return # Make sure this is an enhanced API key if test.account_type != ACCOUNT_ENHANCED: self._log.info( f"[{ctx.guild.name}] Rejecting an attempt to register a free API key" ) await ctx.send( embed=basic_embed(title=lang('Global', 'generic_error'), description=lang('Sauce', 'api_free'))) return Servers.register(ctx.guild, api_key) await ctx.send( embed=basic_embed(title=lang('Global', 'generic_success'), description=lang('Sauce', 'registered_api_key')))
async def query_guild(self, ctx: commands.Context, guild_id: int): """ Queries basic metadata (name, member count) from a guild ID for analytics This is boilerplate testing code. In the future we may implement a restriction on how many guilds a single user can invite the bot too. This is to prevent users from exploiting the bot via self-botting scripts. """ guild = ctx.bot.get_guild(guild_id) # type: discord.Guild if not guild: await ctx.reply(lang('Admin', 'guild_404')) return owner = guild.owner # type: typing.Optional[discord.Member] embed = basic_embed(title=guild.name) embed.add_field(name=lang('Misc', 'stats_guild_id'), value=str(guild.id), inline=True) if owner: embed.add_field(name=lang('Misc', 'stats_owner_id'), value=str(owner.id), inline=True) embed.set_author(name=owner.display_name, icon_url=owner.avatar_url) await ctx.reply(embed=embed)
async def stats(self, ctx: commands.Context): """ Displays how many guilds SauceBot is in among statistics """ embed = basic_embed(title=lang('Misc', 'stats_title')) embed.add_field( name=lang('Misc', 'stats_guilds'), value=lang('Misc', 'stats_guilds_desc', {'count': f'{self.get_stat("guild_count"):,}'}), inline=True ) embed.add_field( name=lang('Misc', 'stats_users'), value=lang('Misc', 'stats_users_desc', {'count': f'{self.get_stat("user_count"):,}'}), inline=True ) embed.add_field( name=lang('Misc', 'stats_queries'), value=lang('Misc', 'stats_queries_desc', {'count': f'{self.get_stat("query_count"):,}'}), inline=False ) await ctx.reply(embed=embed)
async def _index_prompt(self, ctx: commands.context.Context, channel: discord.TextChannel, items: list): prompt = await channel.send(lang('Sauce', 'multiple_images') ) # type: discord.Message index_range = range(1, min(len(items), 10) + 1) # Add the numerical emojis. The syntax is weird for this. for index in index_range: await prompt.add_reaction(keycap_emoji(index)) try: check = reaction_check(prompt, [ctx.message.author.id], [keycap_emoji(i) for i in index_range]) reaction, user = await ctx.bot.wait_for('reaction_add', timeout=60.0, check=check) except asyncio.TimeoutError: await ctx.message.delete() await prompt.delete() return await prompt.delete() return items[keycap_to_int(reaction) - 1]
async def sauce(self, ctx: commands.context.Context, url: typing.Optional[str] = None) -> None: """ Get the source of the attached image, the image in the message you replied to, the specified image URL, or the last image uploaded to the channel if none of these are supplied """ # No URL specified? Check for attachments. image_in_command = bool(url) or bool( self._get_image_attachments(ctx.message)) # Next, check and see if we're replying to a message if ctx.message.reference and not image_in_command: reference = ctx.message.reference.resolved self._log.debug(f"Message reference in command: {reference}") if isinstance(reference, discord.Message): image_attachments = self._get_image_attachments(reference) if image_attachments: if len(image_attachments) > 1: attachment = await self._index_prompt( ctx, ctx.channel, image_attachments) url = attachment.url else: url = image_attachments[0].url # If we passed a reference and found nothing, we should abort now if not url: await ctx.send( embed=basic_embed(title=lang('Global', 'generic_error'), description=lang('Sauce', 'no_images'))) return # Lastly, if all else fails, search for the last message in the channel with an image upload url = url or await self._get_last_image_post(ctx) # Still nothing? We tried everything we could, exit with an error if not url: await ctx.send( embed=basic_embed(title=lang('Global', 'generic_error'), description=lang('Sauce', 'no_images'))) return self._log.info( f"[{ctx.guild.name}] Looking up image source/sauce: {url}") # Make sure the URL is valid if not validate_url(url): await ctx.send( embed=basic_embed(title=lang('Global', 'generic_error'), description=lang('Sauce', 'bad_url'))) return # Make sure this user hasn't exceeded their API limits if self._check_member_limited(ctx): await ctx.send(embed=basic_embed( title=lang('Global', 'generic_error'), description=lang('Sauce', 'member_api_limit_exceeded'))) return # Attempt to find the source of this image try: preview = None sauce = await self._get_sauce(ctx, url) except (ShortLimitReachedException, DailyLimitReachedException): await ctx.message.delete() await ctx.send(embed=basic_embed( title=lang('Global', 'generic_error'), description=lang('Sauce', 'api_limit_exceeded')), delete_after=30.0) return except InvalidOrWrongApiKeyException: self._log.warning( f"[{ctx.guild.name}] API key was rejected by SauceNao") await ctx.message.delete() await ctx.send(embed=basic_embed( title=lang('Global', 'generic_error'), description=lang('Sauce', 'rejected_api_key')), delete_after=30.0) return except InvalidImageException: self._log.info( f"[{ctx.guild.name}] An invalid image / image link was provided" ) await ctx.message.delete() await ctx.send(embed=basic_embed( title=lang('Global', 'generic_error'), description=lang('Sauce', 'no_images')), delete_after=30.0) return except SauceNaoException: self._log.exception( f"[{ctx.guild.name}] An unknown error occurred while looking up this image" ) await ctx.message.delete() await ctx.send(embed=basic_embed( title=lang('Global', 'generic_error'), description=lang('Sauce', 'api_offline')), delete_after=30.0) return # If it's an anime, see if we can find a preview clip if isinstance(sauce, AnimeSource): preview_file, nsfw = await self._video_preview(sauce, url, True) if preview_file: if nsfw and not ctx.channel.is_nsfw(): self._log.info( f"Channel #{ctx.channel.name} is not NSFW; not uploading an NSFW video here" ) else: preview = discord.File( BytesIO(preview_file), filename=f"{sauce.title}_preview.mp4".lower().replace( ' ', '_')) # We didn't find anything, provide some suggestions for manual investigation if not sauce: self._log.info(f"[{ctx.guild.name}] No image sources found") embed = basic_embed(title=lang('Sauce', 'not_found', member=ctx.author), description=lang('Sauce', 'not_found_advice')) google_url = f"https://www.google.com/searchbyimage?image_url={url}&safe=off" ascii_url = f"https://ascii2d.net/search/url/{url}" yandex_url = f"https://yandex.com/images/search?url={url}&rpt=imageview" urls = [(lang('Sauce', 'google'), google_url), (lang('Sauce', 'ascii2d'), ascii_url), (lang('Sauce', 'yandex'), yandex_url)] urls = ' • '.join([f"[{t}]({u})" for t, u in urls]) embed.add_field(name=lang('Sauce', 'search_engines'), value=urls) await ctx.send(embed=embed, delete_after=60.0) return await ctx.send(embed=await self._build_sauce_embed(ctx, sauce), file=preview) # Only delete the command message if it doesn't contain the image we just looked up if not image_in_command and not ctx.message.reference: await ctx.message.delete()
async def _build_sauce_embed(self, ctx: commands.context.Context, sauce: GenericSource) -> discord.Embed: """ Builds a Discord embed for the provided SauceNao lookup Args: ctx (commands.context.Context) sauce (GenericSource): Returns: discord.Embed """ embed = basic_embed() embed.set_footer(text=lang('Sauce', 'found', member=ctx.author), icon_url='https://i.imgur.com/Mw109wP.png') embed.title = sauce.title or sauce.author_name or "Untitled" embed.url = sauce.url embed.description = lang('Sauce', 'match_title', { 'index': sauce.index, 'similarity': sauce.similarity }) if sauce.author_name and sauce.title: embed.set_author(name=sauce.author_name, url=sauce.author_url or EmptyEmbed) embed.set_thumbnail(url=sauce.thumbnail) if isinstance(sauce, VideoSource): embed.add_field(name=lang('Sauce', 'episode'), value=sauce.episode) embed.add_field(name=lang('Sauce', 'timestamp'), value=sauce.timestamp) if isinstance(sauce, AnimeSource): await sauce.load_ids() urls = [(lang('Sauce', 'anidb'), sauce.anidb_url)] if sauce.mal_url: urls.append((lang('Sauce', 'mal'), sauce.mal_url)) if sauce.anilist_url: urls.append((lang('Sauce', 'anilist'), sauce.anilist_url)) urls = ' • '.join([f"[{t}]({u})" for t, u in urls]) embed.add_field(name=lang('Sauce', 'more_info'), value=urls, inline=False) if isinstance(sauce, MangaSource): embed.add_field(name=lang('Sauce', 'chapter'), value=sauce.chapter) if isinstance(sauce, BooruSource): if sauce.characters: characters = [c.title() for c in sauce.characters] embed.add_field(name=lang('Sauce', 'characters'), value=', '.join(characters), inline=False) if sauce.material: material = [m.title() for m in sauce.material] embed.add_field(name=lang('Sauce', 'material'), value=', '.join(material), inline=False) return embed
async def ban_guild(self, ctx: commands.context.Context, guild_id: int, *, reason: typing.Optional[str] = None): """ Bans a specified guild from using SauceBot Upon issuing this command, the bot will immediately leave the guild if they are already invited. Any future invite requests will be refused. """ # Make sure the guild exists guild = ctx.bot.get_guild(guild_id) # type: discord.Guild if not guild: await ctx.send(lang('Admin', 'guild_404')) # Make sure it's not already banned if GuildBanlist.check(guild): await ctx.send(lang('Admin', 'gban_already_banned'), delete_after=15.0) return # Confirm we really want to do this confirm_message = await ctx.send( lang('Admin', 'gban_confirm', {'guild_name': guild.name})) await confirm_message.add_reaction(self.CONFIRM_EMOJI) await confirm_message.add_reaction(self.ABORT_EMOJI) def _check(_reaction, _user): return _user == ctx.message.author and str( _reaction.emoji) in [self.CONFIRM_EMOJI, self.ABORT_EMOJI] try: reaction, user = await ctx.bot.wait_for('reaction_add', timeout=60.0, check=_check) except asyncio.TimeoutError: return else: if str(reaction) != self.CONFIRM_EMOJI: return finally: await confirm_message.delete() GuildBanlist.ban(guild, reason) # Send the guild owner a ban message try: await guild.owner.send( lang('Admin', 'gban_notice', {'guild_name': guild.name})) if reason: await guild.owner.send( lang('Admin', 'gban_reason', {'reason': reason})) except (discord.Forbidden, AttributeError): self._log.warning( f"Failed to send ban notice for guild {guild.name} to user {guild.owner}" ) # Leave the guild await ctx.send(f'Leaving guild {guild.name} ({guild.id})', delete_after=15.0) await guild.leave()