diff options
Diffstat (limited to '')
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Pipfile | 1 | ||||
| -rw-r--r-- | Pipfile.lock | 108 | ||||
| -rw-r--r-- | bot/constants.py | 3 | ||||
| -rw-r--r-- | bot/exts/evergreen/issues.py | 289 | ||||
| -rw-r--r-- | bot/exts/evergreen/latex.py | 94 | ||||
| -rw-r--r-- | bot/exts/evergreen/timed.py | 44 | ||||
| -rw-r--r-- | bot/resources/easter/easter_riddle.json | 8 | 
8 files changed, 410 insertions, 139 deletions
@@ -1,7 +1,7 @@  # bot (project-specific)  log/*  data/* - +_latex_cache/* @@ -15,6 +15,7 @@ PyYAML = "~=5.4"  async-rediscache = {extras = ["fakeredis"], version = "~=0.1.4"}  emojis = "~=0.6.0"  pillow-simd = "~=7.0" +matplotlib = "~=3.4.1"  [dev-packages]  flake8 = "~=3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 875c93c5..06033465 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -150,6 +150,13 @@              ],              "version": "==3.0.4"          }, +        "cycler": { +            "hashes": [ +                "sha256:1d8a5ae1ff6c5cf9b93e8811e581232ad8920aeec647c37316ceac982b08cb2d", +                "sha256:cd7b2d1018258d7247a71425e9f26463dfb444d411c39569972f4ce586b0c9d8" +            ], +            "version": "==0.10.0" +        },          "discord.py": {              "extras": [                  "voice" @@ -239,6 +246,69 @@              "markers": "python_version >= '3.4'",              "version": "==3.1"          }, +        "kiwisolver": { +            "hashes": [ +                "sha256:0cd53f403202159b44528498de18f9285b04482bab2a6fc3f5dd8dbb9352e30d", +                "sha256:1e1bc12fb773a7b2ffdeb8380609f4f8064777877b2225dec3da711b421fda31", +                "sha256:225e2e18f271e0ed8157d7f4518ffbf99b9450fca398d561eb5c4a87d0986dd9", +                "sha256:232c9e11fd7ac3a470d65cd67e4359eee155ec57e822e5220322d7b2ac84fbf0", +                "sha256:31dfd2ac56edc0ff9ac295193eeaea1c0c923c0355bf948fbd99ed6018010b72", +                "sha256:33449715e0101e4d34f64990352bce4095c8bf13bed1b390773fc0a7295967b3", +                "sha256:401a2e9afa8588589775fe34fc22d918ae839aaaf0c0e96441c0fdbce6d8ebe6", +                "sha256:44a62e24d9b01ba94ae7a4a6c3fb215dc4af1dde817e7498d901e229aaf50e4e", +                "sha256:50af681a36b2a1dee1d3c169ade9fdc59207d3c31e522519181e12f1b3ba7000", +                "sha256:563c649cfdef27d081c84e72a03b48ea9408c16657500c312575ae9d9f7bc1c3", +                "sha256:5989db3b3b34b76c09253deeaf7fbc2707616f130e166996606c284395da3f18", +                "sha256:5a7a7dbff17e66fac9142ae2ecafb719393aaee6a3768c9de2fd425c63b53e21", +                "sha256:5c3e6455341008a054cccee8c5d24481bcfe1acdbc9add30aa95798e95c65621", +                "sha256:5f6ccd3dd0b9739edcf407514016108e2280769c73a85b9e59aa390046dbf08b", +                "sha256:72c99e39d005b793fb7d3d4e660aed6b6281b502e8c1eaf8ee8346023c8e03bc", +                "sha256:78751b33595f7f9511952e7e60ce858c6d64db2e062afb325985ddbd34b5c131", +                "sha256:834ee27348c4aefc20b479335fd422a2c69db55f7d9ab61721ac8cd83eb78882", +                "sha256:8be8d84b7d4f2ba4ffff3665bcd0211318aa632395a1a41553250484a871d454", +                "sha256:950a199911a8d94683a6b10321f9345d5a3a8433ec58b217ace979e18f16e248", +                "sha256:a357fd4f15ee49b4a98b44ec23a34a95f1e00292a139d6015c11f55774ef10de", +                "sha256:a53d27d0c2a0ebd07e395e56a1fbdf75ffedc4a05943daf472af163413ce9598", +                "sha256:acef3d59d47dd85ecf909c359d0fd2c81ed33bdff70216d3956b463e12c38a54", +                "sha256:b38694dcdac990a743aa654037ff1188c7a9801ac3ccc548d3341014bc5ca278", +                "sha256:b9edd0110a77fc321ab090aaa1cfcaba1d8499850a12848b81be2222eab648f6", +                "sha256:c08e95114951dc2090c4a630c2385bef681cacf12636fb0241accdc6b303fd81", +                "sha256:c5518d51a0735b1e6cee1fdce66359f8d2b59c3ca85dc2b0813a8aa86818a030", +                "sha256:c8fd0f1ae9d92b42854b2979024d7597685ce4ada367172ed7c09edf2cef9cb8", +                "sha256:ca3820eb7f7faf7f0aa88de0e54681bddcb46e485beb844fcecbcd1c8bd01689", +                "sha256:cf8b574c7b9aa060c62116d4181f3a1a4e821b2ec5cbfe3775809474113748d4", +                "sha256:d3155d828dec1d43283bd24d3d3e0d9c7c350cdfcc0bd06c0ad1209c1bbc36d0", +                "sha256:f8d6f8db88049a699817fd9178782867bf22283e3813064302ac59f61d95be05", +                "sha256:fd34fbbfbc40628200730bc1febe30631347103fc8d3d4fa012c21ab9c11eca9" +            ], +            "markers": "python_version >= '3.6'", +            "version": "==1.3.1" +        }, +        "matplotlib": { +            "hashes": [ +                "sha256:1f83a32e4b6045191f9d34e4dc68c0a17c870b57ef9cca518e516da591246e79", +                "sha256:2eee37340ca1b353e0a43a33da79d0cd4bcb087064a0c3c3d1329cdea8fbc6f3", +                "sha256:53ceb12ef44f8982b45adc7a0889a7e2df1d758e8b360f460e435abe8a8cd658", +                "sha256:574306171b84cd6854c83dc87bc353cacc0f60184149fb00c9ea871eca8c1ecb", +                "sha256:7561fd541477d41f3aa09457c434dd1f7604f3bd26d7858d52018f5dfe1c06d1", +                "sha256:7a54efd6fcad9cb3cd5ef2064b5a3eeb0b63c99f26c346bdcf66e7c98294d7cc", +                "sha256:7f16660edf9a8bcc0f766f51c9e1b9d2dc6ceff6bf636d2dbd8eb925d5832dfd", +                "sha256:81e6fe8b18ef5be67f40a1d4f07d5a4ed21d3878530193898449ddef7793952f", +                "sha256:84a10e462120aa7d9eb6186b50917ed5a6286ee61157bfc17c5b47987d1a9068", +                "sha256:84d4c4f650f356678a5d658a43ca21a41fca13f9b8b00169c0b76e6a6a948908", +                "sha256:86dc94e44403fa0f2b1dd76c9794d66a34e821361962fe7c4e078746362e3b14", +                "sha256:90dbc007f6389bcfd9ef4fe5d4c78c8d2efe4e0ebefd48b4f221cdfed5672be2", +                "sha256:9f374961a3996c2d1b41ba3145462c3708a89759e604112073ed6c8bdf9f622f", +                "sha256:a18cc1ab4a35b845cf33b7880c979f5c609fd26c2d6e74ddfacb73dcc60dd956", +                "sha256:a97781453ac79409ddf455fccf344860719d95142f9c334f2a8f3fff049ffec3", +                "sha256:a989022f89cda417f82dbf65e0a830832afd8af743d05d1414fb49549287ff04", +                "sha256:ac2a30a09984c2719f112a574b6543ccb82d020fd1b23b4d55bf4759ba8dd8f5", +                "sha256:be4430b33b25e127fc4ea239cc386389de420be4d63e71d5359c20b562951ce1", +                "sha256:c45e7bf89ea33a2adaef34774df4e692c7436a18a48bcb0e47a53e698a39fa39" +            ], +            "index": "pypi", +            "version": "==3.4.1" +        },          "multidict": {              "hashes": [                  "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", @@ -269,6 +339,36 @@              "index": "pypi",              "version": "==7.0.0.post3"          }, +        "numpy": { +            "hashes": [ +                "sha256:2428b109306075d89d21135bdd6b785f132a1f5a3260c371cee1fae427e12727", +                "sha256:377751954da04d4a6950191b20539066b4e19e3b559d4695399c5e8e3e683bf6", +                "sha256:4703b9e937df83f5b6b7447ca5912b5f5f297aba45f91dbbbc63ff9278c7aa98", +                "sha256:471c0571d0895c68da309dacee4e95a0811d0a9f9f532a48dc1bea5f3b7ad2b7", +                "sha256:61d5b4cf73622e4d0c6b83408a16631b670fc045afd6540679aa35591a17fe6d", +                "sha256:6c915ee7dba1071554e70a3664a839fbc033e1d6528199d4621eeaaa5487ccd2", +                "sha256:6e51e417d9ae2e7848314994e6fc3832c9d426abce9328cf7571eefceb43e6c9", +                "sha256:719656636c48be22c23641859ff2419b27b6bdf844b36a2447cb39caceb00935", +                "sha256:780ae5284cb770ade51d4b4a7dce4faa554eb1d88a56d0e8b9f35fca9b0270ff", +                "sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee", +                "sha256:924dc3f83de20437de95a73516f36e09918e9c9c18d5eac520062c49191025fb", +                "sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042", +                "sha256:9c0fab855ae790ca74b27e55240fe4f2a36a364a3f1ebcfd1fb5ac4088f1cec3", +                "sha256:9cab23439eb1ebfed1aaec9cd42b7dc50fc96d5cd3147da348d9161f0501ada5", +                "sha256:a8e6859913ec8eeef3dbe9aed3bf475347642d1cdd6217c30f28dee8903528e6", +                "sha256:aa046527c04688af680217fffac61eec2350ef3f3d7320c07fd33f5c6e7b4d5f", +                "sha256:abc81829c4039e7e4c30f7897938fa5d4916a09c2c7eb9b244b7a35ddc9656f4", +                "sha256:bad70051de2c50b1a6259a6df1daaafe8c480ca98132da98976d8591c412e737", +                "sha256:c73a7975d77f15f7f68dacfb2bca3d3f479f158313642e8ea9058eea06637931", +                "sha256:d15007f857d6995db15195217afdbddfcd203dfaa0ba6878a2f580eaf810ecd6", +                "sha256:d76061ae5cab49b83a8cf3feacefc2053fac672728802ac137dd8c4123397677", +                "sha256:e8e4fbbb7e7634f263c5b0150a629342cc19b47c5eba8d1cd4363ab3455ab576", +                "sha256:e9459f40244bb02b2f14f6af0cd0732791d72232bbb0dc4bab57ef88e75f6935", +                "sha256:edb1f041a9146dcf02cd7df7187db46ab524b9af2515f392f337c7cbbf5b52cd" +            ], +            "markers": "python_version >= '3.7'", +            "version": "==1.20.2" +        },          "pycares": {              "hashes": [                  "sha256:050f00b39ed77ea8a4e555f09417d4b1a6b5baa24bb9531a3e15d003d2319b3f", @@ -337,6 +437,14 @@              ],              "version": "==1.3.0"          }, +        "pyparsing": { +            "hashes": [ +                "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", +                "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" +            ], +            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", +            "version": "==2.4.7" +        },          "python-dateutil": {              "hashes": [                  "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", diff --git a/bot/constants.py b/bot/constants.py index 853ea340..f390d8ce 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -170,7 +170,7 @@ class Emojis:      christmas_tree = "\U0001F384"      check = "\u2611"      envelope = "\U0001F4E8" -    trashcan = "<:trashcan:637136429717389331>" +    trashcan = environ.get("TRASHCAN_EMOJI", "<:trashcan:637136429717389331>")      ok_hand = ":ok_hand:"      hand_raised = "\U0001f64b" @@ -185,6 +185,7 @@ class Emojis:      issue_closed = "<:IssueClosed:629695470570307614>"      pull_request = "<:PROpen:629695470175780875>"      pull_request_closed = "<:PRClosed:629695470519713818>" +    pull_request_draft = "<:PRDraft:829755345425399848>"      merge = "<:PRMerged:629695470570176522>"      number_emojis = { diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py index 4a73d20b..bb6273bb 100644 --- a/bot/exts/evergreen/issues.py +++ b/bot/exts/evergreen/issues.py @@ -2,10 +2,10 @@ import logging  import random  import re  import typing as t -from enum import Enum +from dataclasses import dataclass  import discord -from discord.ext import commands, tasks +from discord.ext import commands  from bot.constants import (      Categories, @@ -17,6 +17,8 @@ from bot.constants import (      Tokens,      WHITELISTED_CHANNELS  ) +from bot.utils.decorators import whitelist_override +from bot.utils.extensions import invoke_help_command  log = logging.getLogger(__name__) @@ -24,20 +26,20 @@ BAD_RESPONSE = {      404: "Issue/pull request not located! Please enter a valid number!",      403: "Rate limit has been hit! Please try again later!"  } +REQUEST_HEADERS = { +    "Accept": "application/vnd.github.v3+json" +} -MAX_REQUESTS = 10 -REQUEST_HEADERS = dict() +REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public" +ISSUE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/issues/{number}" +PR_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/pulls/{number}" -REPOS_API = "https://api.github.com/orgs/{org}/repos"  if GITHUB_TOKEN := Tokens.github:      REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"  WHITELISTED_CATEGORIES = (      Categories.development, Categories.devprojects, Categories.media, Categories.staff  ) -WHITELISTED_CHANNELS_ON_MESSAGE = ( -    Channels.organisation, Channels.mod_meta, Channels.mod_tools, Channels.staff_voice -)  CODE_BLOCK_RE = re.compile(      r"^`([^`\n]+)`"  # Inline codeblock @@ -45,12 +47,42 @@ CODE_BLOCK_RE = re.compile(      re.DOTALL | re.MULTILINE  ) +# Maximum number of issues in one message +MAXIMUM_ISSUES = 5 + +# Regex used when looking for automatic linking in messages +AUTOMATIC_REGEX = re.compile(r"((?P<org>.+?)\/)?(?P<repo>.+?)#(?P<number>.+?)") + + +@dataclass +class FoundIssue: +    """Dataclass representing an issue found by the regex.""" + +    organisation: t.Optional[str] +    repository: str +    number: str + +    def __hash__(self) -> int: +        return hash((self.organisation, self.repository, self.number)) + -class FetchIssueErrors(Enum): -    """Errors returned in fetch issues.""" +@dataclass +class FetchError: +    """Dataclass representing an error while fetching an issue.""" -    value_error = "Numbers not found." -    max_requests = "Max requests hit." +    return_code: int +    message: str + + +@dataclass +class IssueState: +    """Dataclass representing the state of an issue.""" + +    repository: str +    number: int +    url: str +    title: str +    emoji: str  class Issues(commands.Cog): @@ -59,97 +91,96 @@ class Issues(commands.Cog):      def __init__(self, bot: commands.Bot):          self.bot = bot          self.repos = [] -        self.get_pydis_repos.start() - -    @tasks.loop(minutes=30) -    async def get_pydis_repos(self) -> None: -        """Get all python-discord repositories on github.""" -        async with self.bot.http_session.get(REPOS_API.format(org="python-discord")) as resp: -            if resp.status == 200: -                data = await resp.json() -                for repo in data: -                    self.repos.append(repo["full_name"].split("/")[1]) -                self.repo_regex = "|".join(self.repos) -            else: -                log.debug(f"Failed to get latest Pydis repositories. Status code {resp.status}")      @staticmethod -    def check_in_block(message: discord.Message, repo_issue: str) -> bool: -        """Check whether the <repo>#<issue> is in codeblocks.""" -        block = re.findall(CODE_BLOCK_RE, message.content) - -        if not block: -            return False -        elif "#".join(repo_issue.split(" ")) in "".join([*block[0]]): -            return True -        return False +    def remove_codeblocks(message: str) -> str: +        """Remove any codeblock in a message.""" +        return re.sub(CODE_BLOCK_RE, "", message)      async def fetch_issues(              self, -            numbers: set, +            number: int,              repository: str,              user: str -    ) -> t.Union[FetchIssueErrors, str, list]: -        """Retrieve issue(s) from a GitHub repository.""" -        links = [] -        if not numbers: -            return FetchIssueErrors.value_error - -        if len(numbers) > MAX_REQUESTS: -            return FetchIssueErrors.max_requests - -        for number in numbers: -            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 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.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. +    ) -> t.Union[IssueState, FetchError]: +        """ +        Retrieve an issue from a GitHub repository. + +        Returns IssueState on success, FetchError on failure. +        """ +        url = ISSUE_ENDPOINT.format(user=user, repository=repository, number=number) +        pulls_url = PR_ENDPOINT.format(user=user, repository=repository, number=number) +        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 == 403: +            if r.headers.get("X-RateLimit-Remaining") == "0": +                log.info(f"Ratelimit reached while fetching {url}") +                return FetchError(403, "Ratelimit reached, please retry in a few minutes.") +            return FetchError(403, "Cannot access issue.") +        elif r.status in (404, 410): +            return FetchError(r.status, "Issue not found.") +        elif r.status != 200: +            return FetchError(r.status, "Error while fetching issue.") + +        # 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["html_url"]: +            if json_data.get("state") == "open": +                emoji = Emojis.issue              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 +                emoji = 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: {pulls_url}") +            async with self.bot.http_session.get(pulls_url) as p: +                pull_data = await p.json() +                if pull_data["draft"]: +                    emoji = Emojis.pull_request_draft +                elif pull_data["state"] == "open": +                    emoji = Emojis.pull_request +                # When 'merged_at' is not None, this means that the state of the PR is merged +                elif pull_data["merged_at"] is not None: +                    emoji = Emojis.merge +                else: +                    emoji = 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") -        return links +        return IssueState(repository, number, issue_url, json_data.get('title', ''), emoji)      @staticmethod -    def get_embed(result: list, user: str = "python-discord", repository: str = "") -> discord.Embed: -        """Get Response Embed.""" -        description_list = ["{0} [{1}]({2})".format(*link) for link in result] +    def format_embed( +            results: t.List[t.Union[IssueState, FetchError]], +            user: str, +            repository: t.Optional[str] = None +    ) -> discord.Embed: +        """Take a list of IssueState or FetchError and format a Discord embed for them.""" +        description_list = [] + +        for result in results: +            if isinstance(result, IssueState): +                description_list.append(f"{result.emoji} [{result.title}]({result.url})") +            elif isinstance(result, FetchError): +                description_list.append(f":x: [{result.return_code}] {result.message}") +          resp = discord.Embed(              colour=Colours.bright_green,              description='\n'.join(description_list)          ) -        resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}") +        embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}" +        resp.set_author(name="GitHub", url=embed_url)          return resp +    @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)      @commands.command(aliases=("pr",))      async def issue(              self, @@ -159,79 +190,79 @@ class Issues(commands.Cog):              user: str = "python-discord"      ) -> None:          """Command to retrieve issue(s) from a GitHub repository.""" -        if not ctx.guild or not( -            ctx.channel.category.id in WHITELISTED_CATEGORIES -            or ctx.channel.id in WHITELISTED_CHANNELS -        ): -            await ctx.send( -                embed=discord.Embed( -                    title=random.choice(NEGATIVE_REPLIES), -                    description=( -                        "You can't run this command in this channel. " -                        f"Try again in <#{Channels.community_bot_commands}>" -                    ), -                    colour=discord.Colour.red() -                ) -            ) -            return - -        result = await self.fetch_issues(set(numbers), repository, user) +        # Remove duplicates +        numbers = set(numbers) -        if result == FetchIssueErrors.value_error: -            await ctx.invoke(self.bot.get_command('help'), 'issue') - -        elif result == FetchIssueErrors.max_requests: +        if len(numbers) > MAXIMUM_ISSUES:              embed = discord.Embed(                  title=random.choice(ERROR_REPLIES),                  color=Colours.soft_red, -                description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})" +                description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"              )              await ctx.send(embed=embed) +            await invoke_help_command(ctx) -        elif isinstance(result, list): -            # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link. -            resp = self.get_embed(result, user, repository) -            await ctx.send(embed=resp) - -        elif isinstance(result, str): -            await ctx.send(result) +        results = [await self.fetch_issues(number, repository, user) for number in numbers] +        await ctx.send(embed=self.format_embed(results, user, repository))      @commands.Cog.listener()      async def on_message(self, message: discord.Message) -> None: -        """Command to retrieve issue(s) from a GitHub repository using automatic linking if matching <repo>#<issue>.""" -        # Ignore messages not in whitelisted categories / channels, only when in guild. -        if message.guild and not ( -            message.channel.category.id in WHITELISTED_CATEGORIES -            or message.channel.id in WHITELISTED_CHANNELS_ON_MESSAGE -        ): +        """ +        Automatic issue linking. + +        Listener to retrieve issue(s) from a GitHub repository using automatic linking if matching <org>/<repo>#<issue>. +        """ +        # Ignore bots +        if message.author.bot:              return -        message_repo_issue_map = re.findall(fr"({self.repo_regex})#(\d+)", message.content) +        issues = [ +            FoundIssue(*match.group("org", "repo", "number")) +            for match in AUTOMATIC_REGEX.finditer(self.remove_codeblocks(message.content)) +        ]          links = [] -        if message_repo_issue_map: +        if issues: +            # Block this from working in DMs              if not message.guild:                  await message.channel.send(                      embed=discord.Embed(                          title=random.choice(NEGATIVE_REPLIES),                          description=( -                            "You can't retreive issues from DMs. " +                            "You can't retrieve issues from DMs. "                              f"Try again in <#{Channels.community_bot_commands}>"                          ), -                        colour=discord.Colour.red() +                        colour=Colours.soft_red                      )                  )                  return -            for repo_issue in message_repo_issue_map: -                if not self.check_in_block(message, " ".join(repo_issue)): -                    result = await self.fetch_issues({repo_issue[1]}, repo_issue[0], "python-discord") -                    if isinstance(result, list): -                        links.extend(result) + +            log.trace(f"Found {issues = }") +            # Remove duplicates +            issues = set(issues) + +            if len(issues) > MAXIMUM_ISSUES: +                embed = discord.Embed( +                    title=random.choice(ERROR_REPLIES), +                    color=Colours.soft_red, +                    description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" +                ) +                await message.channel.send(embed=embed, delete_after=5) +                return + +            for repo_issue in issues: +                result = await self.fetch_issues( +                    int(repo_issue.number), +                    repo_issue.repository, +                    repo_issue.organisation or "python-discord" +                ) +                if isinstance(result, IssueState): +                    links.append(result)          if not links:              return -        resp = self.get_embed(links, "python-discord") +        resp = self.format_embed(links, "python-discord")          await message.channel.send(embed=resp) diff --git a/bot/exts/evergreen/latex.py b/bot/exts/evergreen/latex.py new file mode 100644 index 00000000..c4a8597c --- /dev/null +++ b/bot/exts/evergreen/latex.py @@ -0,0 +1,94 @@ +import asyncio +import hashlib +import pathlib +import re +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO + +import discord +import matplotlib.pyplot as plt +from discord.ext import commands + +# configure fonts and colors for matplotlib +plt.rcParams.update( +    { +        "font.size": 16, +        "mathtext.fontset": "cm",  # Computer Modern font set +        "mathtext.rm": "serif", +        "figure.facecolor": "36393F",  # matches Discord's dark mode background color +        "text.color": "white", +    } +) + +FORMATTED_CODE_REGEX = re.compile( +    r"(?P<delim>(?P<block>```)|``?)"        # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block +    r"(?(block)(?:(?P<lang>[a-z]+)\n)?)"    # if we're in a block, match optional language (only letters plus newline) +    r"(?:[ \t]*\n)*"                        # any blank (empty or tabs/spaces only) lines before the code +    r"(?P<code>.*?)"                        # extract all code inside the markup +    r"\s*"                                  # any more whitespace before the end of the code markup +    r"(?P=delim)",                          # match the exact same delimiter from the start again +    re.DOTALL | re.IGNORECASE,              # "." also matches newlines, case insensitive +) + +CACHE_DIRECTORY = pathlib.Path("_latex_cache") +CACHE_DIRECTORY.mkdir(exist_ok=True) + + +class Latex(commands.Cog): +    """Renders latex.""" + +    @staticmethod +    def _render(text: str, filepath: pathlib.Path) -> BytesIO: +        """ +        Return the rendered image if latex compiles without errors, otherwise raise a BadArgument Exception. + +        Saves rendered image to cache. +        """ +        fig = plt.figure() +        rendered_image = BytesIO() +        fig.text(0, 1, text, horizontalalignment="left", verticalalignment="top") + +        try: +            plt.savefig(rendered_image, bbox_inches="tight", dpi=600) +        except ValueError as e: +            raise commands.BadArgument(str(e)) + +        rendered_image.seek(0) + +        with open(filepath, "wb") as f: +            f.write(rendered_image.getbuffer()) + +        return rendered_image + +    @staticmethod +    def _prepare_input(text: str) -> str: +        text = text.replace(r"\\", "$\n$")  # matplotlib uses \n for newlines, not \\ + +        if match := FORMATTED_CODE_REGEX.match(text): +            return match.group("code") +        else: +            return text + +    @commands.command() +    @commands.max_concurrency(1, commands.BucketType.guild, wait=True) +    async def latex(self, ctx: commands.Context, *, text: str) -> None: +        """Renders the text in latex and sends the image.""" +        text = self._prepare_input(text) +        query_hash = hashlib.md5(text.encode()).hexdigest() +        image_path = CACHE_DIRECTORY.joinpath(f"{query_hash}.png") +        async with ctx.typing(): +            if image_path.exists(): +                await ctx.send(file=discord.File(image_path)) +                return + +            with ThreadPoolExecutor() as pool: +                image = await asyncio.get_running_loop().run_in_executor( +                    pool, self._render, text, image_path +                ) + +            await ctx.send(file=discord.File(image, "latex.png")) + + +def setup(bot: commands.Bot) -> None: +    """Load the Latex Cog.""" +    bot.add_cog(Latex(bot)) diff --git a/bot/exts/evergreen/timed.py b/bot/exts/evergreen/timed.py new file mode 100644 index 00000000..635ccb32 --- /dev/null +++ b/bot/exts/evergreen/timed.py @@ -0,0 +1,44 @@ +from copy import copy +from time import perf_counter + +from discord import Message +from discord.ext import commands + + +class TimedCommands(commands.Cog): +    """Time the command execution of a command.""" + +    @staticmethod +    async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context: +        """Get a new execution context for a command.""" +        msg: Message = copy(ctx.message) +        msg.content = f"{ctx.prefix}{command}" + +        return await ctx.bot.get_context(msg) + +    @commands.command(name="timed", aliases=["time", "t"]) +    async def timed(self, ctx: commands.Context, *, command: str) -> None: +        """Time the command execution of a command.""" +        new_ctx = await self.create_execution_context(ctx, command) + +        if not new_ctx.command: +            help_command = f"{ctx.prefix}help" +            error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands." + +            await ctx.send(error) +            return + +        if new_ctx.command.qualified_name == "timed": +            await ctx.send("You are not allowed to time the execution of the `timed` command.") +            return + +        t_start = perf_counter() +        await new_ctx.command.invoke(new_ctx) +        t_end = perf_counter() + +        await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.") + + +def setup(bot: commands.Bot) -> None: +    """Cog load.""" +    bot.add_cog(TimedCommands(bot)) diff --git a/bot/resources/easter/easter_riddle.json b/bot/resources/easter/easter_riddle.json index e93f6dad..f7eb63d8 100644 --- a/bot/resources/easter/easter_riddle.json +++ b/bot/resources/easter/easter_riddle.json @@ -64,14 +64,6 @@        "correct_answer": "A chocolate one"    },    { -      "question": "Where does the Easter Bunny get his eggs?", -      "riddles": [ -            "Not a bush or tree", -            "Emoji for a body part" -        ], -      "correct_answer": "Eggplants" -  }, -  {        "question": "Why did the Easter Bunny have to fire the duck?",        "riddles": [              "Quack",  |