From c373d4c8cfd1105ea590553a02c208511fc16bb8 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 29 Aug 2020 00:51:00 -0700 Subject: Migrated Wolfram cog from the Python bot in Python-Discord/bot. --- bot/exts/evergreen/wolfram.py | 280 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 bot/exts/evergreen/wolfram.py (limited to 'bot/exts/evergreen/wolfram.py') diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py new file mode 100644 index 00000000..18ccc4e5 --- /dev/null +++ b/bot/exts/evergreen/wolfram.py @@ -0,0 +1,280 @@ +import logging +from io import BytesIO +from typing import Callable, List, Optional, Tuple +from urllib import parse + +import discord +from dateutil.relativedelta import relativedelta +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Cog, Context, check, group + +from bot.constants import Colours, STAFF_ROLES, Wolfram +from bot.utils.pagination import ImagePaginator +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +APPID = Wolfram.key +DEFAULT_OUTPUT_FORMAT = "JSON" +QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" +WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" + +MAX_PODS = 20 + +# Allows for 10 wolfram calls pr user pr day +usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60 * 60 * 24, BucketType.user) + +# Allows for max api requests / days in month per day for the entire guild (Temporary) +guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60 * 60 * 24, BucketType.guild) + + +async def send_embed( + ctx: Context, + message_txt: str, + colour: int = Colours.soft_red, + footer: str = None, + img_url: str = None, + f: discord.File = None +) -> None: + """Generate & send a response embed with Wolfram as the author.""" + embed = Embed(colour=colour) + embed.description = message_txt + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + if footer: + embed.set_footer(text=footer) + + if img_url: + embed.set_image(url=img_url) + + await ctx.send(embed=embed, file=f) + + +def custom_cooldown(*ignore: List[int]) -> Callable: + """ + Implement per-user and per-guild cooldowns for requests to the Wolfram API. + + A list of roles may be provided to ignore the per-user cooldown + """ + async def predicate(ctx: Context) -> bool: + if ctx.invoked_with == 'help': + # if the invoked command is help we don't want to increase the ratelimits since it's not actually + # invoking the command/making a request, so instead just check if the user/guild are on cooldown. + guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored + return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown + + user_bucket = usercd.get_bucket(ctx.message) + + if all(role.id not in ignore for role in ctx.author.roles): + user_rate = user_bucket.update_rate_limit() + + if user_rate: + # Can't use api; cause: member limit + delta = relativedelta(seconds=int(user_rate)) + cooldown = humanize_delta(delta) + message = ( + "You've used up your limit for Wolfram|Alpha requests.\n" + f"Cooldown: {cooldown}" + ) + await send_embed(ctx, message) + return False + + guild_bucket = guildcd.get_bucket(ctx.message) + guild_rate = guild_bucket.update_rate_limit() + + # Repr has a token attribute to read requests left + log.debug(guild_bucket) + + if guild_rate: + # Can't use api; cause: guild limit + message = ( + "The max limit of requests for the server has been reached for today.\n" + f"Cooldown: {int(guild_rate)}" + ) + await send_embed(ctx, message) + return False + + return True + + return check(predicate) + + +async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional[List[Tuple]]: + """Get the Wolfram API pod pages for the provided query.""" + async with ctx.channel.typing(): + url_str = parse.urlencode({ + "input": query, + "appid": APPID, + "output": DEFAULT_OUTPUT_FORMAT, + "format": "image,plaintext" + }) + request_url = QUERY.format(request="query", data=url_str) + + async with bot.http_session.get(request_url) as response: + json = await response.json(content_type='text/plain') + + result = json["queryresult"] + + if result["error"]: + # API key not set up correctly + if result["error"]["msg"] == "Invalid appid": + message = "Wolfram API key is invalid or missing." + log.warning( + "API key seems to be missing, or invalid when " + f"processing a wolfram request: {url_str}, Response: {json}" + ) + await send_embed(ctx, message) + return + + message = "Something went wrong internally with your request, please notify staff!" + log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") + await send_embed(ctx, message) + return + + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return + + if not result["numpods"]: + message = "Could not find any results." + await send_embed(ctx, message) + return + + pods = result["pods"] + pages = [] + for pod in pods[:MAX_PODS]: + subs = pod.get("subpods") + + for sub in subs: + title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") + img = sub["img"]["src"] + pages.append((title, img)) + return pages + + +class Wolfram(Cog): + """Commands for interacting with the Wolfram|Alpha API.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_command(self, ctx: Context, *, query: str) -> None: + """Requests all answers on a single image, sends an image of all related pods.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="simple", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + image_bytes = await response.read() + + f = discord.File(BytesIO(image_bytes), filename="image.png") + image_url = "attachment://image.png" + + if status == 501: + message = "Failed to get response" + footer = "" + color = Colours.soft_red + elif status == 400: + message = "No input found" + footer = "" + color = Colours.soft_red + elif status == 403: + message = "Wolfram API key is invalid or missing." + footer = "" + color = Colours.soft_red + else: + message = "" + footer = "View original for a bigger picture." + color = Colours.soft_orange + + # Sends a "blank" embed if no request is received, unsure how to fix + await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) + + @wolfram_command.command(name="page", aliases=("pa", "p")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + embed = Embed() + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + embed.colour = Colours.soft_orange + + await ImagePaginator.paginate(pages, ctx, embed) + + @wolfram_command.command(name="cut", aliases=("c",)) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + if len(pages) >= 2: + page = pages[1] + else: + page = pages[0] + + await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) + + @wolfram_command.command(name="short", aliases=("sh", "s")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: + """Requests an answer to a simple question.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="result", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + response_text = await response.text() + + if status == 501: + message = "Failed to get response" + color = Colours.soft_red + elif status == 400: + message = "No input found" + color = Colours.soft_red + elif response_text == "Error 1: Invalid appid": + message = "Wolfram API key is invalid or missing." + color = Colours.soft_red + else: + message = response_text + color = Colours.soft_orange + + await send_embed(ctx, message, color) + + +def setup(bot: commands.Bot) -> None: + """Load the Wolfram cog.""" + bot.add_cog(Wolfram(bot)) -- cgit v1.2.3 From a3941cb820803c9408a392c3207fd0483fcf6a0e Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Sat, 29 Aug 2020 15:56:41 -0700 Subject: Update bot/exts/evergreen/wolfram.py Co-authored-by: Dennis Pham --- bot/exts/evergreen/wolfram.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'bot/exts/evergreen/wolfram.py') diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py index 18ccc4e5..4e1d284b 100644 --- a/bot/exts/evergreen/wolfram.py +++ b/bot/exts/evergreen/wolfram.py @@ -74,8 +74,7 @@ def custom_cooldown(*ignore: List[int]) -> Callable: if user_rate: # Can't use api; cause: member limit - delta = relativedelta(seconds=int(user_rate)) - cooldown = humanize_delta(delta) + cooldown = arrow.utcnow().shift(seconds=int(user_rate)).humanize(only_distance=True) message = ( "You've used up your limit for Wolfram|Alpha requests.\n" f"Cooldown: {cooldown}" -- cgit v1.2.3 From d472389b564ed5e343d40750b092680fcdf1e9fc Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 29 Aug 2020 16:35:45 -0700 Subject: Removed the time utility and replaced it with the arrow package. Alphabetized the "Colours" NamedTuple in the constants file. --- bot/constants.py | 10 +++---- bot/exts/evergreen/wolfram.py | 3 +- bot/utils/time.py | 66 ------------------------------------------- docker-compose.yml | 3 +- 4 files changed, 8 insertions(+), 74 deletions(-) delete mode 100644 bot/utils/time.py (limited to 'bot/exts/evergreen/wolfram.py') diff --git a/bot/constants.py b/bot/constants.py index 4a97b9e0..f841193a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -67,7 +67,7 @@ class Channels(NamedTuple): off_topic_2 = 463035268514185226 python = 267624335836053506 reddit = 458224812528238616 - seasonalbot_commands = int(environ.get("CHANNEL_SEASONALBOT_COMMANDS", 607247579608121354)) + seasonalbot_commands = int(environ.get("CHANNEL_SEASONALBOT_COMMANDS", 704362727778418798)) seasonalbot_voice = int(environ.get("CHANNEL_SEASONALBOT_VOICE", 606259004230074378)) staff_lounge = 464905259261755392 verification = 352442727016693763 @@ -93,11 +93,11 @@ class Colours: dark_green = 0x1f8b4c orange = 0xe67e22 pink = 0xcf84e0 + purple = 0xb734eb soft_green = 0x68c290 + soft_orange = 0xf9cb54 soft_red = 0xcd6d6d yellow = 0xf9f586 - purple = 0xb734eb - soft_orange = 0xf9cb54 class Emojis: @@ -190,8 +190,8 @@ class Tokens(NamedTuple): class Wolfram(NamedTuple): - user_limit_day = environ.get("WOLFRAM_USER_LIMIT_DAY", 10) - guild_limit_day = environ.get("WOLFRAM_GUILD_LIMIT_DAY", 67) + user_limit_day = int(environ.get("WOLFRAM_USER_LIMIT_DAY", 10)) + guild_limit_day = int(environ.get("WOLFRAM_GUILD_LIMIT_DAY", 67)) key = environ.get("WOLFRAM_API_KEY", None) diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py index 4e1d284b..898e8d2a 100644 --- a/bot/exts/evergreen/wolfram.py +++ b/bot/exts/evergreen/wolfram.py @@ -3,15 +3,14 @@ from io import BytesIO from typing import Callable, List, Optional, Tuple from urllib import parse +import arrow import discord -from dateutil.relativedelta import relativedelta from discord import Embed from discord.ext import commands from discord.ext.commands import BucketType, Cog, Context, check, group from bot.constants import Colours, STAFF_ROLES, Wolfram from bot.utils.pagination import ImagePaginator -from bot.utils.time import humanize_delta log = logging.getLogger(__name__) diff --git a/bot/utils/time.py b/bot/utils/time.py deleted file mode 100644 index f37a025c..00000000 --- a/bot/utils/time.py +++ /dev/null @@ -1,66 +0,0 @@ -from dateutil.relativedelta import relativedelta - - -def _stringify_time_unit(value: int, unit: str) -> str: - """ - Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. - - >>> _stringify_time_unit(1, "seconds") - "1 second" - >>> _stringify_time_unit(24, "hours") - "24 hours" - >>> _stringify_time_unit(0, "minutes") - "less than a minute" - """ - if unit == "seconds" and value == 0: - return "0 seconds" - elif value == 1: - return f"{value} {unit[:-1]}" - elif value == 0: - return f"less than a {unit[:-1]}" - else: - return f"{value} {unit}" - - -def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: - """ - Returns a human-readable version of the relativedelta. - - precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). - max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - """ - if max_units <= 0: - raise ValueError("max_units must be positive") - - units = ( - ("years", delta.years), - ("months", delta.months), - ("days", delta.days), - ("hours", delta.hours), - ("minutes", delta.minutes), - ("seconds", delta.seconds), - ) - - # Add the time units that are >0, but stop at accuracy or max_units. - time_strings = [] - unit_count = 0 - for unit, value in units: - if value: - time_strings.append(_stringify_time_unit(value, unit)) - unit_count += 1 - - if unit == precision or unit_count >= max_units: - break - - # Add the 'and' between the last two units, if necessary - if len(time_strings) > 1: - time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}" - del time_strings[-2] - - # If nothing has been found, just make the value 0 precision, e.g. `0 days`. - if not time_strings: - humanized = _stringify_time_unit(0, precision) - else: - humanized = ", ".join(time_strings) - - return humanized diff --git a/docker-compose.yml b/docker-compose.yml index 6cf5e9bd..8e5a9d53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,12 +10,13 @@ services: restart: always environment: - - SEASONALBOT_TOKEN=yourtokenhere + - SEASONALBOT_TOKEN=NzQyMTI3NjUwMjM5NDE0MzUy.XzBmOw.F4UiLeZ1wH0yALV4_aov1Ol_kpg - SEASONALBOT_DEBUG=true # - SEASONALBOT_GUILD= # - SEASONALBOT_ADMIN_ROLE_ID= # - CHANNEL_ANNOUNCEMENTS= # - CHANNEL_DEVLOG= + - WOLFRAM_API_KEY=5WKXAG-W9X9TUG3J5 volumes: - /opt/pythondiscord/seasonalbot/log:/bot/bot/log -- cgit v1.2.3