aboutsummaryrefslogtreecommitdiffstats
path: root/bot
diff options
context:
space:
mode:
authorGravatar Matteo Bertucci <[email protected]>2020-09-22 11:51:02 +0200
committerGravatar GitHub <[email protected]>2020-09-22 11:51:02 +0200
commit6d38e0a61924cae69c544c07955567d9a2c1fc18 (patch)
tree218dcc50c86dcfa9ece7e39116ba1d77ed33ae82 /bot
parentcorrected value error msg (diff)
parentMerge pull request #448 from python-discord/clean_uwu (diff)
Merge branch 'master' into master
Diffstat (limited to 'bot')
-rw-r--r--bot/constants.py23
-rw-r--r--bot/exts/easter/conversationstarters.py28
-rw-r--r--bot/exts/evergreen/conversationstarters.py71
-rw-r--r--bot/exts/evergreen/fun.py139
-rw-r--r--bot/exts/evergreen/issues.py113
-rw-r--r--bot/exts/evergreen/reddit.py6
-rw-r--r--bot/exts/evergreen/wolfram.py278
-rw-r--r--bot/resources/easter/starter.json24
-rw-r--r--bot/resources/evergreen/caesar_info.json4
-rw-r--r--bot/resources/evergreen/py_topics.yaml89
-rw-r--r--bot/resources/evergreen/starter.yaml22
-rw-r--r--bot/resources/evergreen/trivia_quiz.json30
-rw-r--r--bot/utils/randomization.py23
13 files changed, 726 insertions, 124 deletions
diff --git a/bot/constants.py b/bot/constants.py
index 133db56c..7c8f72cb 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -17,6 +17,7 @@ __all__ = (
"Month",
"Roles",
"Tokens",
+ "Wolfram",
"MODERATION_ROLES",
"STAFF_ROLES",
"WHITELISTED_CHANNELS",
@@ -51,6 +52,7 @@ class Channels(NamedTuple):
devalerts = 460181980097675264
devlog = int(environ.get("CHANNEL_DEVLOG", 622895325144940554))
dev_contrib = 635950537262759947
+ dev_branding = 753252897059373066
help_0 = 303906576991780866
help_1 = 303906556754395136
help_2 = 303906514266226689
@@ -92,10 +94,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
class Emojis:
@@ -106,12 +109,12 @@ class Emojis:
trashcan = "<:trashcan:637136429717389331>"
ok_hand = ":ok_hand:"
- terning1 = "<:terning1:431249668983488527>"
- terning2 = "<:terning2:462339216987127808>"
- terning3 = "<:terning3:431249694467948544>"
- terning4 = "<:terning4:579980271475228682>"
- terning5 = "<:terning5:431249716328792064>"
- terning6 = "<:terning6:431249726705369098>"
+ dice_1 = "<:dice_1:755891608859443290>"
+ dice_2 = "<:dice_2:755891608741740635>"
+ dice_3 = "<:dice_3:755891608251138158>"
+ dice_4 = "<:dice_4:755891607882039327>"
+ dice_5 = "<:dice_5:755891608091885627>"
+ dice_6 = "<:dice_6:755891607680843838>"
issue = "<:IssueOpen:629695470327037963>"
issue_closed = "<:IssueClosed:629695470570307614>"
@@ -187,6 +190,12 @@ class Tokens(NamedTuple):
github = environ.get("GITHUB_TOKEN")
+class Wolfram(NamedTuple):
+ 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")
+
+
# Default role combinations
MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner
STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
diff --git a/bot/exts/easter/conversationstarters.py b/bot/exts/easter/conversationstarters.py
deleted file mode 100644
index a5f40445..00000000
--- a/bot/exts/easter/conversationstarters.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import json
-import logging
-import random
-from pathlib import Path
-
-from discord.ext import commands
-
-log = logging.getLogger(__name__)
-
-with open(Path("bot/resources/easter/starter.json"), "r", encoding="utf8") as f:
- starters = json.load(f)
-
-
-class ConvoStarters(commands.Cog):
- """Easter conversation topics."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @commands.command()
- async def topic(self, ctx: commands.Context) -> None:
- """Responds with a random topic to start a conversation."""
- await ctx.send(random.choice(starters['starters']))
-
-
-def setup(bot: commands.Bot) -> None:
- """Conversation starters Cog load."""
- bot.add_cog(ConvoStarters(bot))
diff --git a/bot/exts/evergreen/conversationstarters.py b/bot/exts/evergreen/conversationstarters.py
new file mode 100644
index 00000000..576b8d76
--- /dev/null
+++ b/bot/exts/evergreen/conversationstarters.py
@@ -0,0 +1,71 @@
+from pathlib import Path
+
+import yaml
+from discord import Color, Embed
+from discord.ext import commands
+
+from bot.constants import WHITELISTED_CHANNELS
+from bot.utils.decorators import override_in_channel
+from bot.utils.randomization import RandomCycle
+
+SUGGESTION_FORM = 'https://forms.gle/zw6kkJqv8U43Nfjg9'
+
+with Path("bot/resources/evergreen/starter.yaml").open("r", encoding="utf8") as f:
+ STARTERS = yaml.load(f, Loader=yaml.FullLoader)
+
+with Path("bot/resources/evergreen/py_topics.yaml").open("r", encoding="utf8") as f:
+ # First ID is #python-general and the rest are top to bottom categories of Topical Chat/Help.
+ PY_TOPICS = yaml.load(f, Loader=yaml.FullLoader)
+
+ # Removing `None` from lists of topics, if not a list, it is changed to an empty one.
+ PY_TOPICS = {k: [i for i in v if i] if isinstance(v, list) else [] for k, v in PY_TOPICS.items()}
+
+ # All the allowed channels that the ".topic" command is allowed to be executed in.
+ ALL_ALLOWED_CHANNELS = list(PY_TOPICS.keys()) + list(WHITELISTED_CHANNELS)
+
+# Putting all topics into one dictionary and shuffling lists to reduce same-topic repetitions.
+ALL_TOPICS = {'default': STARTERS, **PY_TOPICS}
+TOPICS = {
+ channel: RandomCycle(topics or ['No topics found for this channel.'])
+ for channel, topics in ALL_TOPICS.items()
+}
+
+
+class ConvoStarters(commands.Cog):
+ """Evergreen conversation topics."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command()
+ @override_in_channel(ALL_ALLOWED_CHANNELS)
+ async def topic(self, ctx: commands.Context) -> None:
+ """
+ Responds with a random topic to start a conversation.
+
+ If in a Python channel, a python-related topic will be given.
+
+ Otherwise, a random conversation topic will be received by the user.
+ """
+ # No matter what, the form will be shown.
+ embed = Embed(description=f'Suggest more topics [here]({SUGGESTION_FORM})!', color=Color.blurple())
+
+ try:
+ # Fetching topics.
+ channel_topics = TOPICS[ctx.channel.id]
+
+ # If the channel isn't Python-related.
+ except KeyError:
+ embed.title = f'**{next(TOPICS["default"])}**'
+
+ # If the channel ID doesn't have any topics.
+ else:
+ embed.title = f'**{next(channel_topics)}**'
+
+ finally:
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Conversation starters Cog load."""
+ bot.add_cog(ConvoStarters(bot))
diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py
index 67a4bae5..de6a92c6 100644
--- a/bot/exts/evergreen/fun.py
+++ b/bot/exts/evergreen/fun.py
@@ -1,14 +1,16 @@
import functools
+import json
import logging
import random
-from typing import Callable, Tuple, Union
+from pathlib import Path
+from typing import Callable, Iterable, Tuple, Union
from discord import Embed, Message
from discord.ext import commands
-from discord.ext.commands import Bot, Cog, Context, MessageConverter
+from discord.ext.commands import Bot, Cog, Context, MessageConverter, clean_content
from bot import utils
-from bot.constants import Emojis
+from bot.constants import Colours, Emojis
log = logging.getLogger(__name__)
@@ -26,12 +28,35 @@ UWU_WORDS = {
}
+def caesar_cipher(text: str, offset: int) -> Iterable[str]:
+ """
+ Implements a lazy Caesar Cipher algorithm.
+
+ Encrypts a `text` given a specific integer `offset`. The sign
+ of the `offset` dictates the direction in which it shifts to,
+ with a negative value shifting to the left, and a positive
+ value shifting to the right.
+ """
+ for char in text:
+ if not char.isascii() or not char.isalpha() or char.isspace():
+ yield char
+ continue
+
+ case_start = 65 if char.isupper() else 97
+ true_offset = (ord(char) - case_start + offset) % 26
+
+ yield chr(case_start + true_offset)
+
+
class Fun(Cog):
"""A collection of general commands for fun."""
def __init__(self, bot: Bot) -> None:
self.bot = bot
+ with Path("bot/resources/evergreen/caesar_info.json").open("r", encoding="UTF-8") as f:
+ self._caesar_cipher_embed = json.load(f)
+
@commands.command()
async def roll(self, ctx: Context, num_rolls: int = 1) -> None:
"""Outputs a number of random dice emotes (up to 6)."""
@@ -41,17 +66,13 @@ class Fun(Cog):
elif num_rolls < 1:
output = ":no_entry: You must roll at least once."
for _ in range(num_rolls):
- terning = f"terning{random.randint(1, 6)}"
- output += getattr(Emojis, terning, '')
+ dice = f"dice_{random.randint(1, 6)}"
+ output += getattr(Emojis, dice, '')
await ctx.send(output)
@commands.command(name="uwu", aliases=("uwuwize", "uwuify",))
- async def uwu_command(self, ctx: Context, *, text: str) -> None:
- """
- Converts a given `text` into it's uwu equivalent.
-
- Also accepts a valid discord Message ID or link.
- """
+ async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None:
+ """Converts a given `text` into it's uwu equivalent."""
conversion_func = functools.partial(
utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True
)
@@ -66,12 +87,8 @@ class Fun(Cog):
await ctx.send(content=converted_text, embed=embed)
@commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",))
- async def randomcase_command(self, ctx: Context, *, text: str) -> None:
- """
- Randomly converts the casing of a given `text`.
-
- Also accepts a valid discord Message ID or link.
- """
+ async def randomcase_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None:
+ """Randomly converts the casing of a given `text`."""
def conversion_func(text: str) -> str:
"""Randomly converts the casing of a given string."""
return "".join(
@@ -87,22 +104,100 @@ class Fun(Cog):
converted_text = f">>> {converted_text.lstrip('> ')}"
await ctx.send(content=converted_text, embed=embed)
+ @commands.group(name="caesarcipher", aliases=("caesar", "cc",))
+ async def caesarcipher_group(self, ctx: Context) -> None:
+ """
+ Translates a message using the Caesar Cipher.
+
+ See `decrypt`, `encrypt`, and `info` subcommands.
+ """
+ if ctx.invoked_subcommand is None:
+ await ctx.invoke(self.bot.get_command("help"), "caesarcipher")
+
+ @caesarcipher_group.command(name="info")
+ async def caesarcipher_info(self, ctx: Context) -> None:
+ """Information about the Caesar Cipher."""
+ embed = Embed.from_dict(self._caesar_cipher_embed)
+ embed.colour = Colours.dark_green
+
+ await ctx.send(embed=embed)
+
+ @staticmethod
+ async def _caesar_cipher(ctx: Context, offset: int, msg: str, left_shift: bool = False) -> None:
+ """
+ Given a positive integer `offset`, translates and sends the given `msg`.
+
+ Performs a right shift by default unless `left_shift` is specified as `True`.
+
+ Also accepts a valid Discord Message ID or link.
+ """
+ if offset < 0:
+ await ctx.send(":no_entry: Cannot use a negative offset.")
+ return
+
+ if left_shift:
+ offset = -offset
+
+ def conversion_func(text: str) -> str:
+ """Encrypts the given string using the Caesar Cipher."""
+ return "".join(caesar_cipher(text, offset))
+
+ text, embed = await Fun._get_text_and_embed(ctx, msg)
+
+ if embed is not None:
+ embed = Fun._convert_embed(conversion_func, embed)
+
+ converted_text = conversion_func(text)
+
+ if converted_text:
+ converted_text = f">>> {converted_text.lstrip('> ')}"
+
+ await ctx.send(content=converted_text, embed=embed)
+
+ @caesarcipher_group.command(name="encrypt", aliases=("rightshift", "rshift", "enc",))
+ async def caesarcipher_encrypt(self, ctx: Context, offset: int, *, msg: str) -> None:
+ """
+ Given a positive integer `offset`, encrypt the given `msg`.
+
+ Performs a right shift of the letters in the message.
+
+ Also accepts a valid Discord Message ID or link.
+ """
+ await self._caesar_cipher(ctx, offset, msg, left_shift=False)
+
+ @caesarcipher_group.command(name="decrypt", aliases=("leftshift", "lshift", "dec",))
+ async def caesarcipher_decrypt(self, ctx: Context, offset: int, *, msg: str) -> None:
+ """
+ Given a positive integer `offset`, decrypt the given `msg`.
+
+ Performs a left shift of the letters in the message.
+
+ Also accepts a valid Discord Message ID or link.
+ """
+ await self._caesar_cipher(ctx, offset, msg, left_shift=True)
+
@staticmethod
async def _get_text_and_embed(ctx: Context, text: str) -> Tuple[str, Union[Embed, None]]:
"""
Attempts to extract the text and embed from a possible link to a discord Message.
+ Does not retrieve the text and embed from the Message if it is in a channel the user does
+ not have read permissions in.
+
Returns a tuple of:
str: If `text` is a valid discord Message, the contents of the message, else `text`.
Union[Embed, None]: The embed if found in the valid Message, else None
"""
embed = None
- message = await Fun._get_discord_message(ctx, text)
- if isinstance(message, Message):
- text = message.content
+
+ msg = await Fun._get_discord_message(ctx, text)
+ # Ensure the user has read permissions for the channel the message is in
+ if isinstance(msg, Message) and ctx.author.permissions_in(msg.channel).read_messages:
+ text = msg.clean_content
# Take first embed because we can't send multiple embeds
- if message.embeds:
- embed = message.embeds[0]
+ if msg.embeds:
+ embed = msg.embeds[0]
+
return (text, embed)
@staticmethod
diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py
index 0f83731b..5a5c82e7 100644
--- a/bot/exts/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -1,9 +1,10 @@
import logging
+import random
import discord
from discord.ext import commands
-from bot.constants import Channels, Colours, Emojis, WHITELISTED_CHANNELS
+from bot.constants import Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS
from bot.utils.decorators import override_in_channel
log = logging.getLogger(__name__)
@@ -13,6 +14,12 @@ BAD_RESPONSE = {
403: "Rate limit has been hit! Please try again later!"
}
+MAX_REQUESTS = 10
+
+REQUEST_HEADERS = dict()
+if GITHUB_TOKEN := Tokens.github:
+ REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
+
class Issues(commands.Cog):
"""Cog that allows users to retrieve issues from GitHub."""
@@ -21,53 +28,79 @@ class Issues(commands.Cog):
self.bot = bot
@commands.command(aliases=("pr",))
- @override_in_channel(WHITELISTED_CHANNELS + (Channels.dev_contrib,))
+ @override_in_channel(WHITELISTED_CHANNELS + (Channels.dev_contrib, Channels.dev_branding))
async def issue(
- self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord"
+ self,
+ ctx: commands.Context,
+ numbers: commands.Greedy[int],
+ repository: str = "seasonalbot",
+ user: str = "python-discord"
) -> None:
- """Command to retrieve issues from a GitHub repository."""
- url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
- merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
-
- log.trace(f"Querying GH issues API: {url}")
- async with self.bot.http_session.get(url) as r:
- json_data = await r.json()
-
- if r.status in BAD_RESPONSE:
- log.warning(f"Received response {r.status} from: {url}")
- return await ctx.send(f"[{str(r.status)}] {BAD_RESPONSE.get(r.status)}")
-
- # The initial API request is made to the issues API endpoint, which will return information
- # if the issue or PR is present. However, the scope of information returned for PRs differs
- # from issues: if the 'issues' key is present in the response then we can pull the data we
- # need from the initial API call.
- if "issues" in json_data.get("html_url"):
- if json_data.get("state") == "open":
- icon_url = Emojis.issue
- else:
- icon_url = Emojis.issue_closed
-
- # If the 'issues' key is not contained in the API response and there is no error code, then
- # we know that a PR has been requested and a call to the pulls API endpoint is necessary
- # to get the desired information for the PR.
- else:
- log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
- async with self.bot.http_session.get(merge_url) as m:
+ """Command to retrieve issue(s) from a GitHub repository."""
+ links = []
+ numbers = set(numbers)
+
+ if not numbers:
+ await ctx.invoke(self.bot.get_command('help'), 'issue')
+ return
+
+ if len(numbers) > MAX_REQUESTS:
+ embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ color=Colours.soft_red,
+ description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})"
+ )
+ await ctx.send(embed=embed)
+ return
+
+ for number in set(numbers):
+ # Convert from list to set to remove duplicates, if any.
+ url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
+ merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
+
+ log.trace(f"Querying GH issues API: {url}")
+ async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
+ json_data = await r.json()
+
+ if r.status in BAD_RESPONSE:
+ log.warning(f"Received response {r.status} from: {url}")
+ return await ctx.send(f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}")
+
+ # The initial API request is made to the issues API endpoint, which will return information
+ # if the issue or PR is present. However, the scope of information returned for PRs differs
+ # from issues: if the 'issues' key is present in the response then we can pull the data we
+ # need from the initial API call.
+ if "issues" in json_data.get("html_url"):
if json_data.get("state") == "open":
- icon_url = Emojis.pull_request
- # When the status is 204 this means that the state of the PR is merged
- elif m.status == 204:
- icon_url = Emojis.merge
+ icon_url = Emojis.issue
else:
- icon_url = Emojis.pull_request_closed
+ icon_url = Emojis.issue_closed
+
+ # If the 'issues' key is not contained in the API response and there is no error code, then
+ # we know that a PR has been requested and a call to the pulls API endpoint is necessary
+ # to get the desired information for the PR.
+ else:
+ log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
+ async with self.bot.http_session.get(merge_url) as m:
+ if json_data.get("state") == "open":
+ icon_url = Emojis.pull_request
+ # When the status is 204 this means that the state of the PR is merged
+ elif m.status == 204:
+ icon_url = Emojis.merge
+ else:
+ icon_url = Emojis.pull_request_closed
+
+ issue_url = json_data.get("html_url")
+ links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url])
- issue_url = json_data.get("html_url")
- description_text = f"[{repository}] #{number} {json_data.get('title')}"
+ # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link.
+ description_list = ["{0} [{1}]({2})".format(*link) for link in links]
resp = discord.Embed(
colour=Colours.bright_green,
- description=f"{icon_url} [{description_text}]({issue_url})"
+ description='\n'.join(description_list)
)
- resp.set_author(name="GitHub", url=issue_url)
+
+ resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}")
await ctx.send(embed=resp)
diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py
index fe204419..49127bea 100644
--- a/bot/exts/evergreen/reddit.py
+++ b/bot/exts/evergreen/reddit.py
@@ -68,9 +68,9 @@ class Reddit(commands.Cog):
# -----------------------------------------------------------
# This code below is bound of change when the emojis are added.
- upvote_emoji = self.bot.get_emoji(638729835245731840)
- comment_emoji = self.bot.get_emoji(638729835073765387)
- user_emoji = self.bot.get_emoji(638729835442602003)
+ upvote_emoji = self.bot.get_emoji(755845219890757644)
+ comment_emoji = self.bot.get_emoji(755845255001014384)
+ user_emoji = self.bot.get_emoji(755845303822974997)
text_emoji = self.bot.get_emoji(676030265910493204)
video_emoji = self.bot.get_emoji(676030265839190047)
image_emoji = self.bot.get_emoji(676030265734201344)
diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py
new file mode 100644
index 00000000..898e8d2a
--- /dev/null
+++ b/bot/exts/evergreen/wolfram.py
@@ -0,0 +1,278 @@
+import logging
+from io import BytesIO
+from typing import Callable, List, Optional, Tuple
+from urllib import parse
+
+import arrow
+import discord
+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
+
+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
+ 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}"
+ )
+ 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))
diff --git a/bot/resources/easter/starter.json b/bot/resources/easter/starter.json
deleted file mode 100644
index 31e2cbc9..00000000
--- a/bot/resources/easter/starter.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "starters": [
- "What is your favourite Easter candy or treat?",
- "What is your earliest memory of Easter?",
- "What is the title of the last book you read?",
- "What is better: Milk, Dark or White chocolate?",
- "What is your favourite holiday?",
- "If you could have any superpower, what would it be?",
- "Name one thing you like about a person to your right.",
- "If you could be anyone else for one day, who would it be?",
- "What Easter tradition do you enjoy most?",
- "What is the best gift you've been given?",
- "Name one famous person you would like to have at your easter dinner.",
- "What was the last movie you saw in a cinema?",
- "What is your favourite food?",
- "If you could travel anywhere in the world, where would you go?",
- "Tell us 5 things you do well.",
- "What is your favourite place that you have visited?",
- "What is your favourite color?",
- "If you had $100 bill in your Easter Basket, what would you do with it?",
- "What would you do if you know you could succeed at anything you chose to do?",
- "If you could take only three things from your house, what would they be?"
- ]
-}
diff --git a/bot/resources/evergreen/caesar_info.json b/bot/resources/evergreen/caesar_info.json
new file mode 100644
index 00000000..8229c4f3
--- /dev/null
+++ b/bot/resources/evergreen/caesar_info.json
@@ -0,0 +1,4 @@
+{
+ "title": "Caesar Cipher",
+ "description": "**Information**\nThe Caesar Cipher, named after the Roman General Julius Caesar, is one of the simplest and most widely known encryption techniques. It is a type of substitution cipher in which each letter in the plaintext is replaced by a letter given a specific position offset in the alphabet, with the letters wrapping around both sides.\n\n**Examples**\n1) `Hello World` <=> `Khoor Zruog` where letters are shifted forwards by `3`.\n2) `Julius Caesar` <=> `Yjaxjh Rpthpg` where letters are shifted backwards by `11`."
+}
diff --git a/bot/resources/evergreen/py_topics.yaml b/bot/resources/evergreen/py_topics.yaml
new file mode 100644
index 00000000..1e53429a
--- /dev/null
+++ b/bot/resources/evergreen/py_topics.yaml
@@ -0,0 +1,89 @@
+# Conversation starters for Python-related channels.
+
+# python-general
+267624335836053506:
+ - What's your favorite PEP?
+ - What's your current text editor/IDE, and what functionality do you like about it the most when programming in Python?
+ - What functionality is your text editor/IDE missing for programming Python?
+ - What parts of your life has Python automated, if any?
+ - Which Python project are you the most proud of making?
+ - What made you want to learn Python?
+ - When did you start learning Python?
+ - What reasons are you learning Python for?
+ - Where's the strangest place you've seen Python?
+ - How has learning Python changed your life?
+ - Is there a package you wish existed but doesn't? What is it?
+ - What feature do you think should be added to Python?
+ - Has Python helped you in school? If so, how?
+ - What was the first thing you created with Python?
+
+# async
+630504881542791169:
+ - Are there any frameworks you wish were async?
+ - How have coroutines changed the way you write Python?
+
+# c-extensions
+728390945384431688:
+ -
+
+# computer-science
+650401909852864553:
+ -
+
+# databases
+342318764227821568:
+ - Where do you get your best data?
+
+# data-science
+366673247892275221:
+ -
+
+# discord.py
+343944376055103488:
+ - What unique features does your bot contain, if any?
+ - What commands/features are you proud of making?
+ - What feature would you be the most interested in making?
+ - What feature would you like to see added to the library? what feature in the library do you think is redundant?
+ - Do you think there's a way in which Discord could handle bots better?
+
+# esoteric-python
+470884583684964352:
+ - What's a common part of programming we can make harder?
+ - What are the pros and cons of messing with __magic__()?
+
+# game-development
+660625198390837248:
+ -
+
+# microcontrollers
+545603026732318730:
+ -
+
+# networking
+716325106619777044:
+ - If you could wish for a library involving networking, what would it be?
+
+# security
+366674035876167691:
+ - If you could wish for a library involving net-sec, what would it be?
+
+# software-testing
+463035728335732738:
+ -
+
+# tools-and-devops
+463035462760792066:
+ - What editor would you recommend to a beginner? Why?
+ - What editor would you recommend to be the most efficient? Why?
+
+# unix
+491523972836360192:
+ -
+
+# user-interfaces
+338993628049571840:
+ - What's the most impressive Desktop Application you've made with Python so far?
+
+# web-development
+366673702533988363:
+ - How has Python helped you in web development?
diff --git a/bot/resources/evergreen/starter.yaml b/bot/resources/evergreen/starter.yaml
new file mode 100644
index 00000000..53c89364
--- /dev/null
+++ b/bot/resources/evergreen/starter.yaml
@@ -0,0 +1,22 @@
+# Conversation starters for channels that are not Python-related.
+
+- What is your favourite Easter candy or treat?
+- What is your earliest memory of Easter?
+- What is the title of the last book you read?
+- "What is better: Milk, Dark or White chocolate?"
+- What is your favourite holiday?
+- If you could have any superpower, what would it be?
+- Name one thing you like about a person to your right.
+- If you could be anyone else for one day, who would it be?
+- What Easter tradition do you enjoy most?
+- What is the best gift you've been given?
+- Name one famous person you would like to have at your easter dinner.
+- What was the last movie you saw in a cinema?
+- What is your favourite food?
+- If you could travel anywhere in the world, where would you go?
+- Tell us 5 things you do well.
+- What is your favourite place that you have visited?
+- What is your favourite color?
+- If you had $100 bill in your Easter Basket, what would you do with it?
+- What would you do if you know you could succeed at anything you chose to do?
+- If you could take only three things from your house, what would they be?
diff --git a/bot/resources/evergreen/trivia_quiz.json b/bot/resources/evergreen/trivia_quiz.json
index 6100ca62..8f0a4114 100644
--- a/bot/resources/evergreen/trivia_quiz.json
+++ b/bot/resources/evergreen/trivia_quiz.json
@@ -217,6 +217,36 @@
"question": "What does the acronym GPRS stand for?",
"answer": "General Packet Radio Service",
"info": "General Packet Radio Service (GPRS) is a packet-based mobile data service on the global system for mobile communications (GSM) of 3G and 2G cellular communication systems. It is a non-voice, high-speed and useful packet-switching technology intended for GSM networks."
+ },
+ {
+ "id": 131,
+ "question": "In what country is the Ebro river located?",
+ "answer": "Spain",
+ "info": "The Ebro river is located in Spain. It is 930 kilometers long and it's the second longest river that ends on the Mediterranean Sea."
+ },
+ {
+ "id": 132,
+ "question": "What year was the IBM PC model 5150 introduced into the market?",
+ "answer": "1981",
+ "info": "The IBM PC was introduced into the market in 1981. It used the Intel 8088, with a clock speed of 4.77 MHz, along with the MDA and CGA as a video card."
+ },
+ {
+ "id": 133,
+ "question": "What's the world's largest urban area?",
+ "answer": "Tokyo",
+ "info": "Tokyo is the most populated city in the world, with a population of 37 million people. It is located in Japan."
+ },
+ {
+ "id": 134,
+ "question": "How many planets are there in the Solar system?",
+ "answer": "8",
+ "info": "In the Solar system, there are 8 planets: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus and Neptune. Pluto isn't considered a planet in the Solar System anymore."
+ },
+ {
+ "id": 135,
+ "question": "What is the capital of Iraq?",
+ "answer": "Baghdad",
+ "info": "Baghdad is the capital of Iraq. It has a population of 7 million people."
}
]
}
diff --git a/bot/utils/randomization.py b/bot/utils/randomization.py
new file mode 100644
index 00000000..8f47679a
--- /dev/null
+++ b/bot/utils/randomization.py
@@ -0,0 +1,23 @@
+import itertools
+import random
+import typing as t
+
+
+class RandomCycle:
+ """
+ Cycles through elements from a randomly shuffled iterable, repeating indefinitely.
+
+ The iterable is reshuffled after each full cycle.
+ """
+
+ def __init__(self, iterable: t.Iterable) -> None:
+ self.iterable = list(iterable)
+ self.index = itertools.cycle(range(len(iterable)))
+
+ def __next__(self) -> t.Any:
+ idx = next(self.index)
+
+ if idx == 0:
+ random.shuffle(self.iterable)
+
+ return self.iterable[idx]