diff --git a/.env.sample b/.env.sample deleted file mode 100644 index a9d555e..0000000 --- a/.env.sample +++ /dev/null @@ -1,8 +0,0 @@ -BOT_TOKEN= -BOT_MICROSOFT_TENANT_ID= -BOT_MICROSOFT_CLIENT_ID= -BOT_MICROSOFT_CLIENT_SECRET= -PREFIXES=$ -dragonfly_recipient="example@example.com" -dragonfly_bcc='["example@example.com", "example@example.com"]' -BOT_DB_URL= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6d449d5..2faac5f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ -# CI -.github @letsbuilda/devops +# Require DevOps signoff on CI changes +.github @vipyrsec/devops diff --git a/.github/workflows/dependency-review.yaml b/.github/workflows/dependency-review.yaml new file mode 100644 index 0000000..25558a2 --- /dev/null +++ b/.github/workflows/dependency-review.yaml @@ -0,0 +1,20 @@ +name: "Dependency Review" + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + + steps: + - name: "Checkout Repository" + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + + - name: "Dependency Review" + uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3.1.0 + with: + config-file: darbiadev/.github/.github/dependency-review-config.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 diff --git a/.github/workflows/docker-build-push.yaml b/.github/workflows/docker-build-push.yaml index f2b32f6..c7b86c1 100644 --- a/.github/workflows/docker-build-push.yaml +++ b/.github/workflows/docker-build-push.yaml @@ -16,4 +16,4 @@ permissions: jobs: build-push: - uses: darbiadev/.github/.github/workflows/docker-build-push.yaml@068870f051676db9e2651013f7c7196ffdaeadaa # v2.0.0 + uses: darbiadev/.github/.github/workflows/docker-build-push.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 87e8307..a73b23b 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -8,11 +8,11 @@ on: jobs: pre-commit: - uses: darbiadev/.github/.github/workflows/generic-precommit.yaml@068870f051676db9e2651013f7c7196ffdaeadaa # v2.0.0 + uses: darbiadev/.github/.github/workflows/generic-precommit.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 lint: needs: pre-commit - uses: darbiadev/.github/.github/workflows/python-lint.yaml@068870f051676db9e2651013f7c7196ffdaeadaa # v2.0.0 + uses: darbiadev/.github/.github/workflows/python-lint.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 test: needs: lint @@ -21,7 +21,16 @@ jobs: os: [ ubuntu-latest ] python-version: [ "3.11" ] - uses: darbiadev/.github/.github/workflows/python-test.yaml@068870f051676db9e2651013f7c7196ffdaeadaa # v2.0.0 + uses: darbiadev/.github/.github/workflows/python-test.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} + + docs: + # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages + permissions: + contents: read + pages: write + id-token: write + + uses: darbiadev/.github/.github/workflows/github-pages-python-sphinx.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 diff --git a/.github/workflows/sentry-release.yaml b/.github/workflows/sentry-release.yaml index d45c0ec..a45e778 100644 --- a/.github/workflows/sentry-release.yaml +++ b/.github/workflows/sentry-release.yaml @@ -10,9 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - name: "Checkout repository" + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Create Sentry release + - name: "Create Sentry release" uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7 # v1.4.1 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f44459..001d90b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,11 +13,11 @@ repos: args: [ --fix=lf ] - id: end-of-file-fixer - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black language_version: python3.11 - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.280" + rev: "v0.0.292" hooks: - id: ruff diff --git a/Dockerfile b/Containerfile similarity index 63% rename from Dockerfile rename to Containerfile index e3d723a..e43bbf6 100644 --- a/Dockerfile +++ b/Containerfile @@ -1,18 +1,17 @@ FROM python:3.11-slim@sha256:edaf703dce209d774af3ff768fc92b1e3b60261e7602126276f9ceb0e3a96874 -RUN adduser --disabled-password bot -USER bot - -# Define Git SHA build argument for sentry +# Define Git SHA build argument for Sentry ARG git_sha="development" ENV GIT_SHA=$git_sha -WORKDIR /home/bot - -COPY requirements.txt . +COPY requirements/requirements.txt . RUN python -m pip install --requirement requirements.txt -COPY --chown=bot:bot . . +COPY pyproject.toml pyproject.toml +COPY src/ src/ RUN python -m pip install . -CMD ["python", "-m", "bot"] +RUN adduser --disabled-password bot +USER bot + +CMD [ "python", "-m", "bot" ] diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e9c6fa1..cab648a 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1,9 +1,12 @@ -.. See docs for details on formatting your entries - https://releases.readthedocs.io/en/latest/concepts.html - Changelog ========= +- :release:`4.0.0 <4th October 2023>` +- :feature:`-` Reinitialize versioning +- :feature:`-` Reinitialize CI +- :feature:`-` Reinitialize dependencies +- :feature:`-` Don't copy in all files to Containerfile + - :release:`2.3.2 <15th May 2023>` - :feature:`62` Add slash command to bring up package modal on demand diff --git a/docs/source/conf.py b/docs/source/conf.py index d422588..5e2a052 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,7 +6,7 @@ from importlib.metadata import metadata -project_metadata = metadata("dragonfly-bot") +project_metadata = metadata("bot") project: str = project_metadata["Name"] release: str = project_metadata["Version"] REPO_LINK: str = project_metadata["Project-URL"].replace("repository, ", "") @@ -53,16 +53,16 @@ releases_release_uri = f"{REPO_LINK}/releases/tag/v%s" -def linkcode_resolve(domain, info): - """linkcode_resolve""" +def linkcode_resolve(domain: str, info: dict) -> str: + """linkcode_resolve.""" if domain != "py": return None if not info["module"]: return None - import importlib # pylint: disable=import-outside-toplevel - import inspect # pylint: disable=import-outside-toplevel - import types # pylint: disable=import-outside-toplevel + import importlib + import inspect + import types mod = importlib.import_module(info["module"]) @@ -76,14 +76,12 @@ def linkcode_resolve(domain, info): if isinstance( val, - ( - types.ModuleType, - types.MethodType, - types.FunctionType, - types.TracebackType, - types.FrameType, - types.CodeType, - ), + types.ModuleType + | types.MethodType + | types.FunctionType + | types.TracebackType + | types.FrameType + | types.CodeType, ): try: lines, first = inspect.getsourcelines(val) diff --git a/make.ps1 b/make.ps1 index 03f11dc..0ac4b79 100644 --- a/make.ps1 +++ b/make.ps1 @@ -11,8 +11,7 @@ COMMANDS install-dev install local package in editable mode update-deps update the dependencies upgrade-deps upgrade the dependencies - lint run `isort` and `black` - pylint run `pylint` + lint run `pre-commit` and `black` and `ruff` test run `pytest` build-dist run `python -m build` clean delete generated content @@ -20,7 +19,7 @@ COMMANDS #> param( [Parameter(Position = 0)] - [ValidateSet("init", "install-dev", "update-deps", "upgrade-deps", "lint", "pylint", "test", "build-dist", "clean", "help")] + [ValidateSet("init", "install-dev", "update-deps", "upgrade-deps", "lint", "test", "build-dist", "clean", "help")] [string]$Command ) @@ -41,25 +40,28 @@ function Invoke-Install-Dev function Invoke-Update-Deps { - pip-compile --output-file requirements.txt requirements.in + python -m pip install --upgrade pip-tools + pip-compile --resolver=backtracking requirements/requirements.in --output-file requirements/requirements.txt + pip-compile --resolver=backtracking requirements/requirements-dev.in --output-file requirements/requirements-dev.txt + pip-compile --resolver=backtracking requirements/requirements-tests.in --output-file requirements/requirements-tests.txt + pip-compile --resolver=backtracking requirements/requirements-docs.in --output-file requirements/requirements-docs.txt } function Invoke-Upgrade-Deps { + python -m pip install --upgrade pip-tools pre-commit pre-commit autoupdate - pip-compile --output-file requirements.txt --upgrade requirements.in + pip-compile --resolver=backtracking --upgrade requirements/requirements.in --output-file requirements/requirements.txt + pip-compile --resolver=backtracking --upgrade requirements/requirements-dev.in --output-file requirements/requirements-dev.txt + pip-compile --resolver=backtracking --upgrade requirements/requirements-tests.in --output-file requirements/requirements-tests.txt + pip-compile --resolver=backtracking --upgrade requirements/requirements-docs.in --output-file requirements/requirements-docs.txt } function Invoke-Lint { pre-commit run --all-files - python -m isort src/ - python -m black src/ -} - -function Invoke-Pylint -{ - python -m pylint src/ + python -m black . + python -m ruff --fix . } function Invoke-Test @@ -104,9 +106,6 @@ switch ($Command) "upgrade-deps" { Invoke-Upgrade-Deps } - "pylint" { - Invoke-Pylint - } "test" { Invoke-Test } diff --git a/pyproject.toml b/pyproject.toml index 48bf12c..65689f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,42 +1,45 @@ [project] name = "bot" -version = "3.0.0" -dynamic = ["dependencies"] +version = "4.0.0" +dynamic = ["dependencies", "optional-dependencies"] [project.urls] repository = "https://github.com/vipyrsec/bot/" documentation = "https://docs.vipyrsec.com/bot/" -[project.optional-dependencies] -dev = [ - "pip-tools", - "pre-commit", - "black", - "ruff", -] -tests = [ - "pytest", -] -docs = [ - "sphinx", - "furo", - "sphinx-autoapi", - "toml", - "releases", -] - [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools.dynamic.dependencies] -file = ["requirements.txt"] +file = ["requirements/requirements.txt"] + +[tool.setuptools.dynamic.optional-dependencies] +dev = { file = ["requirements/requirements-dev.txt"] } +tests = { file = ["requirements/requirements-tests.txt"] } +docs = { file = ["requirements/requirements-docs.txt"] } [tool.black] +target-version = ["py311"] line-length = 120 [tool.ruff] +target-version = "py311" line-length = 120 +select = ["ALL"] +ignore = [ + "G004", # (Logging statement uses f-string) - Developer UX + "S101", # (Use of `assert` detected) - This should probably be changed +] + +[tool.ruff.extend-per-file-ignores] +"docs/*" = [ + "INP001", # (File `tests/*.py` is part of an implicit namespace package. Add an `__init__.py`.) - Docs are not modules +] +"tests/*" = [ + "INP001", # (File `tests/*.py` is part of an implicit namespace package. Add an `__init__.py`.) - Tests are not modules + "S101", # (Use of `assert` detected) - Yes, that's the point +] -[tool.pytest.ini_options] -addopts = "tests -r a -v --doctest-modules src" +[tool.ruff.pydocstyle] +convention = "numpy" diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in new file mode 100644 index 0000000..a30e571 --- /dev/null +++ b/requirements/requirements-dev.in @@ -0,0 +1,7 @@ +# Constrain versions installed to be compatible with core dependencies +--constraint requirements.txt + +pip-tools +pre-commit +black +ruff diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt new file mode 100644 index 0000000..6ddb430 --- /dev/null +++ b/requirements/requirements-dev.txt @@ -0,0 +1,56 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in +# +black==23.9.1 + # via -r requirements/requirements-dev.in +build==1.0.3 + # via pip-tools +cfgv==3.4.0 + # via pre-commit +click==8.1.7 + # via + # black + # pip-tools +distlib==0.3.7 + # via virtualenv +filelock==3.12.4 + # via + # -c requirements/requirements.txt + # virtualenv +identify==2.5.30 + # via pre-commit +mypy-extensions==1.0.0 + # via black +nodeenv==1.8.0 + # via pre-commit +packaging==23.2 + # via + # black + # build +pathspec==0.11.2 + # via black +pip-tools==7.3.0 + # via -r requirements/requirements-dev.in +platformdirs==3.11.0 + # via + # black + # virtualenv +pre-commit==3.4.0 + # via -r requirements/requirements-dev.in +pyproject-hooks==1.0.0 + # via build +pyyaml==6.0.1 + # via pre-commit +ruff==0.0.292 + # via -r requirements/requirements-dev.in +virtualenv==20.24.5 + # via pre-commit +wheel==0.41.2 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/requirements-docs.in b/requirements/requirements-docs.in new file mode 100644 index 0000000..fc51bbd --- /dev/null +++ b/requirements/requirements-docs.in @@ -0,0 +1,7 @@ +# Constrain versions installed to be compatible with core dependencies +--constraint requirements.txt + +sphinx +furo +sphinx-autoapi +releases diff --git a/requirements/requirements-docs.txt b/requirements/requirements-docs.txt new file mode 100644 index 0000000..4441f91 --- /dev/null +++ b/requirements/requirements-docs.txt @@ -0,0 +1,92 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --output-file=requirements/requirements-docs.txt requirements/requirements-docs.in +# +alabaster==0.7.13 + # via sphinx +anyascii==0.3.2 + # via sphinx-autoapi +astroid==3.0.0 + # via sphinx-autoapi +babel==2.13.0 + # via sphinx +beautifulsoup4==4.12.2 + # via furo +certifi==2023.7.22 + # via + # -c requirements/requirements.txt + # requests +charset-normalizer==3.3.0 + # via + # -c requirements/requirements.txt + # requests +docutils==0.20.1 + # via sphinx +furo==2023.9.10 + # via -r requirements/requirements-docs.in +idna==3.4 + # via + # -c requirements/requirements.txt + # requests +imagesize==1.4.1 + # via sphinx +jinja2==3.1.2 + # via + # sphinx + # sphinx-autoapi +markupsafe==2.1.3 + # via jinja2 +packaging==23.2 + # via sphinx +pygments==2.16.1 + # via + # furo + # sphinx +pyyaml==6.0.1 + # via sphinx-autoapi +releases==2.1.1 + # via -r requirements/requirements-docs.in +requests==2.31.0 + # via + # -c requirements/requirements.txt + # sphinx +semantic-version==2.6.0 + # via releases +snowballstemmer==2.2.0 + # via sphinx +soupsieve==2.5 + # via beautifulsoup4 +sphinx==7.2.6 + # via + # -r requirements/requirements-docs.in + # furo + # releases + # sphinx-autoapi + # sphinx-basic-ng + # sphinxcontrib-applehelp + # sphinxcontrib-devhelp + # sphinxcontrib-htmlhelp + # sphinxcontrib-qthelp + # sphinxcontrib-serializinghtml +sphinx-autoapi==3.0.0 + # via -r requirements/requirements-docs.in +sphinx-basic-ng==1.0.0b2 + # via furo +sphinxcontrib-applehelp==1.0.7 + # via sphinx +sphinxcontrib-devhelp==1.0.5 + # via sphinx +sphinxcontrib-htmlhelp==2.0.4 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.6 + # via sphinx +sphinxcontrib-serializinghtml==1.1.9 + # via sphinx +urllib3==2.0.6 + # via + # -c requirements/requirements.txt + # requests diff --git a/requirements/requirements-tests.in b/requirements/requirements-tests.in new file mode 100644 index 0000000..ef23890 --- /dev/null +++ b/requirements/requirements-tests.in @@ -0,0 +1,5 @@ +# Constrain versions installed to be compatible with core dependencies +--constraint requirements.txt + +pytest +pytest-randomly diff --git a/requirements/requirements-tests.txt b/requirements/requirements-tests.txt new file mode 100644 index 0000000..85587b0 --- /dev/null +++ b/requirements/requirements-tests.txt @@ -0,0 +1,18 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --output-file=requirements/requirements-tests.txt requirements/requirements-tests.in +# +iniconfig==2.0.0 + # via pytest +packaging==23.2 + # via pytest +pluggy==1.3.0 + # via pytest +pytest==7.4.2 + # via + # -r requirements/requirements-tests.in + # pytest-randomly +pytest-randomly==3.15.0 + # via -r requirements/requirements-tests.in diff --git a/requirements.in b/requirements/requirements.in similarity index 86% rename from requirements.in rename to requirements/requirements.in index de23f1d..d2ae04d 100644 --- a/requirements.in +++ b/requirements/requirements.in @@ -26,6 +26,3 @@ tldextract arrow # exts/utilities/snekbox regex -# exts/dragonfly -# exts/pypi -letsbuilda-pypi==4.0.0 diff --git a/requirements.txt b/requirements/requirements.txt similarity index 60% rename from requirements.txt rename to requirements/requirements.txt index 762bd31..d7b920a 100644 --- a/requirements.txt +++ b/requirements/requirements.txt @@ -2,22 +2,21 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file=requirements.txt requirements.in +# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in # aiodns==3.0.0 # via pydis-core aiohttp==3.8.5 # via - # -r requirements.in + # -r requirements/requirements.in # discord-py - # letsbuilda-pypi aiosignal==1.3.1 # via aiohttp annotated-types==0.5.0 # via pydantic -arrow==1.2.3 - # via -r requirements.in -async-timeout==4.0.2 +arrow==1.3.0 + # via -r requirements/requirements.in +async-timeout==4.0.3 # via aiohttp attrs==23.1.0 # via aiohttp @@ -25,25 +24,25 @@ certifi==2023.7.22 # via # requests # sentry-sdk -cffi==1.15.1 +cffi==1.16.0 # via pycares -charset-normalizer==3.2.0 +charset-normalizer==3.3.0 # via # aiohttp # requests coloredlogs==15.0.1 - # via -r requirements.in -discord-py==2.3.1 + # via -r requirements/requirements.in +discord-py==2.3.2 # via - # -r requirements.in + # -r requirements/requirements.in # pydis-core -filelock==3.12.2 +filelock==3.12.4 # via tldextract frozenlist==1.4.0 # via # aiohttp # aiosignal -greenlet==2.0.2 +greenlet==3.0.0 # via sqlalchemy humanfriendly==10.0 # via coloredlogs @@ -52,44 +51,36 @@ idna==3.4 # requests # tldextract # yarl -letsbuilda-pypi==4.0.0 - # via -r requirements.in multidict==6.0.4 # via # aiohttp # yarl -pendulum==2.1.2 - # via letsbuilda-pypi psycopg[binary]==3.1.12 - # via -r requirements.in + # via -r requirements/requirements.in psycopg-binary==3.1.12 # via psycopg pycares==4.3.0 # via aiodns pycparser==2.21 # via cffi -pydantic==2.1.1 +pydantic==2.4.2 # via # pydantic-settings # pydis-core -pydantic-core==2.4.0 +pydantic-core==2.10.1 # via pydantic -pydantic-settings==2.0.2 - # via -r requirements.in -pydis-core==10.1.0 - # via -r requirements.in +pydantic-settings==2.0.3 + # via -r requirements/requirements.in +pydis-core==10.3.0 + # via -r requirements/requirements.in python-dateutil==2.8.2 - # via - # arrow - # pendulum + # via arrow python-dotenv==1.0.0 # via pydantic-settings -pytzdata==2020.1 - # via pendulum rapidfuzz==3.3.1 - # via -r requirements.in -regex==2023.6.3 - # via -r requirements.in + # via -r requirements/requirements.in +regex==2023.10.3 + # via -r requirements/requirements.in requests==2.31.0 # via # requests-file @@ -97,28 +88,28 @@ requests==2.31.0 requests-file==1.5.1 # via tldextract sentry-sdk==1.31.0 - # via -r requirements.in + # via -r requirements/requirements.in six==1.16.0 # via # python-dateutil # requests-file sqlalchemy==2.0.21 - # via -r requirements.in + # via -r requirements/requirements.in statsd==4.0.1 # via pydis-core -tldextract==3.4.4 - # via -r requirements.in -typing-extensions==4.7.1 +tldextract==3.6.0 + # via -r requirements/requirements.in +types-python-dateutil==2.8.19.14 + # via arrow +typing-extensions==4.8.0 # via # psycopg # pydantic # pydantic-core # sqlalchemy -urllib3==2.0.4 +urllib3==2.0.6 # via # requests # sentry-sdk -xmltodict==0.13.0 - # via letsbuilda-pypi yarl==1.9.2 # via aiohttp diff --git a/src/bot/__init__.py b/src/bot/__init__.py index 2ab6ade..8a7bfd3 100644 --- a/src/bot/__init__.py +++ b/src/bot/__init__.py @@ -1,4 +1,4 @@ -"""Bot""" +"""Bot.""" from bot import log diff --git a/src/bot/__main__.py b/src/bot/__main__.py index cd061b1..19c66b4 100644 --- a/src/bot/__main__.py +++ b/src/bot/__main__.py @@ -1,33 +1,52 @@ -"""Main runner""" +"""Main runner.""" import asyncio +from collections.abc import Callable -import aiohttp import discord +from aiohttp import ClientSession, ClientTimeout from discord.ext import commands from bot import constants from bot.bot import Bot from bot.log import setup_sentry +from .dragonfly_services import DragonflyServices + setup_sentry() intents = discord.Intents.default() intents.message_content = True +def get_prefix(bot_: Bot, message_: discord.Message) -> Callable[[Bot, discord.Message], list[str]]: + """Return a callable to check for the bot's prefix.""" + extras = constants.Bot.prefix.split(",") + return commands.when_mentioned_or(*extras)(bot_, message_) + + async def main() -> None: """Run the bot.""" + async with ClientSession(headers={"Content-Type": "application/json"}, timeout=ClientTimeout(total=10)) as session: + bot = Bot( + guild_id=constants.Guild.id, + http_session=session, + allowed_roles=list({discord.Object(id_) for id_ in constants.MODERATION_ROLES}), + command_prefix=get_prefix, + intents=intents, + ) + + bot.dragonfly_services = DragonflyServices( + session=session, + base_url=constants.Dragonfly.base_url, + auth_url=constants.Dragonfly.auth_url, + audience=constants.Dragonfly.audience, + client_id=constants.Dragonfly.client_id, + client_secret=constants.Dragonfly.client_secret, + username=constants.Dragonfly.username, + password=constants.Dragonfly.password, + ) - bot = Bot( - guild_id=constants.Guild.id, - http_session=aiohttp.ClientSession(), - allowed_roles=list({discord.Object(id_) for id_ in constants.MODERATION_ROLES}), - command_prefix=commands.when_mentioned, - intents=intents, - ) - - async with bot: await bot.start(constants.Bot.token) diff --git a/src/bot/bot.py b/src/bot/bot.py index d0a4daa..f7c585e 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -1,28 +1,33 @@ -"""Bot subclass""" +"""Bot subclass.""" import logging +from typing import Self import discord from discord.ext import commands -from letsbuilda.pypi import PyPIServices from pydis_core import BotBase from pydis_core.utils import scheduling from sentry_sdk import push_scope from bot import exts -from bot.constants import DragonflyAuthentication -from bot.exts import pypi log = logging.getLogger(__name__) class CommandTree(discord.app_commands.CommandTree): - def __init__(self, bot: commands.Bot): + """Custom command tree that handles errors raised by commands.""" + + def __init__(self: Self, bot: commands.Bot) -> None: super().__init__(bot) - async def on_error(self, interaction: discord.Interaction, error: discord.app_commands.AppCommandError) -> None: + async def on_error( + self: Self, + interaction: discord.Interaction, + error: discord.app_commands.AppCommandError, + ) -> None: + """Override the default error handler to handle custom errors.""" if isinstance(error, discord.app_commands.MissingRole): - log.warn( + log.warning( "User '%s' attempted to run command '%s', which requires the '%s' role which the user is missing.", interaction.user, interaction.command.name if interaction.command else "None", @@ -30,10 +35,11 @@ async def on_error(self, interaction: discord.Interaction, error: discord.app_co ) await interaction.response.send_message( - f"The '{error.missing_role}' role is required to run this command.", ephemeral=True + f"The '{error.missing_role}' role is required to run this command.", + ephemeral=True, ) elif isinstance(error, discord.app_commands.NoPrivateMessage): - log.warn( + log.warning( "User '%s' attempted to run command '%s', which cannot be invoked from DMs", interaction.user, interaction.command, @@ -48,12 +54,13 @@ class Bot(BotBase): """Bot implementation.""" def __init__( - self, - *args, - **kwargs, - ): + self: Self, + *args: tuple, + **kwargs: dict, + ) -> None: """ Initialise the base bot instance. + Args: allowed_roles: A list of role IDs that the bot is allowed to mention. http_session (aiohttp.ClientSession): The session to use for the bot. @@ -66,40 +73,17 @@ def __init__( self.all_extensions: frozenset[str] | None = None - async def authorize(self) -> None: - log.info("Authenticating") - url = f"https://{DragonflyAuthentication.domain}/oauth/token" - json = dict( - client_id=DragonflyAuthentication.client_id, - client_secret=DragonflyAuthentication.client_secret, - username=DragonflyAuthentication.username, - password=DragonflyAuthentication.password, - grant_type="password", - audience=DragonflyAuthentication.audience, - ) - async with self.http_session.post(url, json=json) as res: - res.raise_for_status() - json = await res.json() - self.access_token: str = json["access_token"] - - async def setup_hook(self) -> None: - """Default async initialisation method for discord.py.""" + async def setup_hook(self: Self) -> None: + """Default async initialisation method for discord.py.""" # noqa: D401 log.debug("setup_hook") await super().setup_hook() - log.info("Performing initial authentication") - await self.authorize() - # This is not awaited to avoid a deadlock with any cogs that have # wait_until_guild_available in their cog_load method. log.debug("load_extensions") scheduling.create_task(self.load_extensions(exts)) - client = PyPIServices(self.http_session) - self.package_view = pypi.PackageViewer(packages=(await client.get_rss_feed(client.NEWEST_PACKAGES_FEED_URL))) - self.add_view(self.package_view) - - async def on_error(self, event: str, *args, **kwargs) -> None: + async def on_error(self: Self, event: str, *args: tuple, **kwargs: dict) -> None: """Log errors raised in event listeners rather than printing them to stderr.""" with push_scope() as scope: scope.set_tag("event", event) diff --git a/src/bot/constants.py b/src/bot/constants.py index 4764639..335101f 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -3,11 +3,11 @@ By default, the values defined in the classes are used, these can be overridden by an env var with the same name. -`.env` and `.env.server` files are used to populate env vars, if present. +An `.env` file is used to populate env vars, if present. """ from os import getenv -from typing import Self +from typing import ClassVar, Self from pydantic import root_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -37,22 +37,23 @@ class _Miscellaneous(EnvConfig): DEBUG_MODE = Miscellaneous.debug -class _DragonflyAuthentication(EnvConfig, env_prefix="auth0_"): - """Settings related to authenticating with Dragonfly API""" +class _Dragonfly(EnvConfig, env_prefix="auth0_"): + """Configuration for the Dragonfly API.""" + base_url: str = "https://dragonfly.vipyrsec.com" + auth_url: str = "https://vipyrsec.us.auth0.com/oauth/token" + audience: str = "https://dragonfly.vipyrsec.com" client_id: str = "" client_secret: str = "" username: str = "" password: str = "" - domain: str = "vipyrsec.us.auth0.com" - audience: str = "https://dragonfly.vipyrsec.com" -DragonflyAuthentication = _DragonflyAuthentication() +Dragonfly = _Dragonfly() class _DragonflyConfig(EnvConfig, env_prefix="dragonfly_"): - """Dragonfly Cog Configuration""" + """Dragonfly Cog Configuration.""" alerts_channel_id: int = 1121462652342910986 logs_channel_id: int = 1121462677131251752 @@ -67,7 +68,7 @@ class _DragonflyConfig(EnvConfig, env_prefix="dragonfly_"): class _PyPi(EnvConfig, env_prefix="pypi_"): - """PyPI Cog Configuration""" + """PyPI Cog Configuration.""" show_author_in_embed: bool = False @@ -76,10 +77,11 @@ class _PyPi(EnvConfig, env_prefix="pypi_"): class _Bot(EnvConfig, env_prefix="bot_"): - """Bot data""" + """Bot data.""" token: str = "" trace_loggers: str = "*" + prefix: str = "$" Bot = _Bot() @@ -97,7 +99,7 @@ class _Sentry(BaseSettings, env_prefix="sentry_"): class _Channels(EnvConfig, env_prefix="channels_"): - """Channel constants""" + """Channel constants.""" mod_alerts: int = 1121492582686539788 mod_log: int = 1121492613070082118 @@ -108,7 +110,7 @@ class _Channels(EnvConfig, env_prefix="channels_"): class _Roles(EnvConfig, env_prefix="roles_"): - """Channel constants""" + """Channel constants.""" administrators: int = 1121450967360098486 @@ -123,26 +125,15 @@ class _Roles(EnvConfig, env_prefix="roles_"): class _Guild(EnvConfig, env_prefix="guild_"): - id: int = 1121450543462760448 + id: int = 1121450543462760448 # noqa: A003 - inside a class, this is fine - moderation_roles: list[int] = [Roles.moderators] + moderation_roles: ClassVar[list[int]] = [Roles.moderators] Guild = _Guild() class _BaseURLs(EnvConfig, env_prefix="urls_"): - # Snekbox endpoints - snekbox_eval_api: str = "http://localhost:8060/eval" - - # Discord API - discord_api: str = "https://discordapp.com/api/v7/" - - # Misc endpoints - bot_avatar: str = "https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_circle/logo_circle.png" - - github_bot_repo: str = "https://github.com/vipyrsec/bot" - paste: str = "https://paste.pythondiscord.com" @@ -150,166 +141,16 @@ class _BaseURLs(EnvConfig, env_prefix="urls_"): class _URLs(_BaseURLs): - # Discord API endpoints - discord_invite_api: str = "".join([BaseURLs.discord_api, "invites"]) - # Base site vars connect_max_retries: int = 3 connect_cooldown: int = 5 - paste_service: str = "".join([BaseURLs.paste, "/{key}"]) - site_logs_view: str = "https://pythondiscord.com/staff/bot/logs" + paste_service: str = f"{BaseURLs.paste}/{{key}}" URLs = _URLs() -class _Tokens(EnvConfig, env_prefix="tokens_"): - """Authentication tokens for external services""" - - github: str = "" - - -Tokens = _Tokens() - - -class _Emojis(EnvConfig, env_prefix="emojis_"): - """Named emoji constants.""" - - cross_mark: str = "\u274C" - star: str = "\u2B50" - christmas_tree: str = "\U0001F384" - check: str = "\u2611" - envelope: str = "\U0001F4E8" - trashcan: str = "<:trashcan:637136429717389331>" - ok_hand: str = ":ok_hand:" - hand_raised: str = "\U0001F64B" - - dice_1: str = "<:dice_1:755891608859443290>" - dice_2: str = "<:dice_2:755891608741740635>" - dice_3: str = "<:dice_3:755891608251138158>" - dice_4: str = "<:dice_4:755891607882039327>" - dice_5: str = "<:dice_5:755891608091885627>" - dice_6: str = "<:dice_6:755891607680843838>" - - # These icons are from GitHub's repo https://github.com/primer/octicons/ - issue_open: str = "<:IssueOpen:852596024777506817>" - issue_closed: str = "<:IssueClosed:927326162861039626>" - # Not currently used by GitHub, but here for future. - issue_draft: str = "<:IssueDraft:852596025147523102>" - pull_request_open: str = "<:PROpen:852596471505223781>" - pull_request_closed: str = "<:PRClosed:852596024732286976>" - pull_request_draft: str = "<:PRDraft:852596025045680218>" - pull_request_merged: str = "<:PRMerged:852596100301193227>" - - number_emojis: dict[int, str] = { # noqa: RUF012 - uh... - 1: "\u0031\ufe0f\u20e3", - 2: "\u0032\ufe0f\u20e3", - 3: "\u0033\ufe0f\u20e3", - 4: "\u0034\ufe0f\u20e3", - 5: "\u0035\ufe0f\u20e3", - 6: "\u0036\ufe0f\u20e3", - 7: "\u0037\ufe0f\u20e3", - 8: "\u0038\ufe0f\u20e3", - 9: "\u0039\ufe0f\u20e3", - } - - confirmation: str = "\u2705" - decline: str = "\u274c" - incident_unactioned: str = "<:incident_unactioned:719645583245180960>" - - x: str = "\U0001f1fd" - o: str = "\U0001f1f4" - - x_square: str = "<:x_square:632278427260682281>" - o_square: str = "<:o_square:632278452413661214>" - - status_online: str = "<:status_online:470326272351010816>" - status_idle: str = "<:status_idle:470326266625785866>" - status_dnd: str = "<:status_dnd:470326272082313216>" - status_offline: str = "<:status_offline:470326266537705472>" - - stackoverflow_tag: str = "<:stack_tag:870926975307501570>" - stackoverflow_views: str = "<:stack_eye:870926992692879371>" - - # Reddit emojis - reddit: str = "<:reddit:676030265734332427>" - reddit_post_text: str = "<:reddit_post_text:676030265910493204>" - reddit_post_video: str = "<:reddit_post_video:676030265839190047>" - reddit_post_photo: str = "<:reddit_post_photo:676030265734201344>" - reddit_upvote: str = "<:reddit_upvote:755845219890757644>" - reddit_comments: str = "<:reddit_comments:755845255001014384>" - reddit_users: str = "<:reddit_users:755845303822974997>" - - lemon_hyperpleased: str = "<:lemon_hyperpleased:754441879822663811>" - lemon_pensive: str = "<:lemon_pensive:754441880246419486>" - - failed_file: str = "<:failed_file:1073298441968562226>" - - -Emojis = _Emojis() - - -class _Icons(EnvConfig, env_prefix="icons_"): - crown_blurple: str = "https://cdn.discordapp.com/emojis/469964153289965568.png" - crown_green: str = "https://cdn.discordapp.com/emojis/469964154719961088.png" - crown_red: str = "https://cdn.discordapp.com/emojis/469964154879344640.png" - - defcon_denied: str = "https://cdn.discordapp.com/emojis/472475292078964738.png" - defcon_shutdown: str = "https://cdn.discordapp.com/emojis/470326273952972810.png" - defcon_unshutdown: str = "https://cdn.discordapp.com/emojis/470326274213150730.png" - defcon_update: str = "https://cdn.discordapp.com/emojis/472472638342561793.png" - - filtering: str = "https://cdn.discordapp.com/emojis/472472638594482195.png" - - green_checkmark: str = ( - "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png" - ) - green_questionmark: str = ( - "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png" - ) - guild_update: str = "https://cdn.discordapp.com/emojis/469954765141442561.png" - - hash_blurple: str = "https://cdn.discordapp.com/emojis/469950142942806017.png" - hash_green: str = "https://cdn.discordapp.com/emojis/469950144918585344.png" - hash_red: str = "https://cdn.discordapp.com/emojis/469950145413251072.png" - - message_bulk_delete: str = "https://cdn.discordapp.com/emojis/469952898994929668.png" - message_delete: str = "https://cdn.discordapp.com/emojis/472472641320648704.png" - message_edit: str = "https://cdn.discordapp.com/emojis/472472638976163870.png" - - pencil: str = "https://cdn.discordapp.com/emojis/470326272401211415.png" - - questionmark: str = "https://cdn.discordapp.com/emojis/512367613339369475.png" - - remind_blurple: str = "https://cdn.discordapp.com/emojis/477907609215827968.png" - remind_green: str = "https://cdn.discordapp.com/emojis/477907607785570310.png" - remind_red: str = "https://cdn.discordapp.com/emojis/477907608057937930.png" - - sign_in: str = "https://cdn.discordapp.com/emojis/469952898181234698.png" - sign_out: str = "https://cdn.discordapp.com/emojis/469952898089091082.png" - - superstarify: str = "https://cdn.discordapp.com/emojis/636288153044516874.png" - unsuperstarify: str = "https://cdn.discordapp.com/emojis/636288201258172446.png" - - token_removed: str = "https://cdn.discordapp.com/emojis/470326273298792469.png" # - false positive - - user_ban: str = "https://cdn.discordapp.com/emojis/469952898026045441.png" - user_timeout: str = "https://cdn.discordapp.com/emojis/472472640100106250.png" - user_unban: str = "https://cdn.discordapp.com/emojis/469952898692808704.png" - user_untimeout: str = "https://cdn.discordapp.com/emojis/472472639206719508.png" - user_update: str = "https://cdn.discordapp.com/emojis/469952898684551168.png" - user_verified: str = "https://cdn.discordapp.com/emojis/470326274519334936.png" - user_warn: str = "https://cdn.discordapp.com/emojis/470326274238447633.png" - - voice_state_blue: str = "https://cdn.discordapp.com/emojis/656899769662439456.png" - voice_state_green: str = "https://cdn.discordapp.com/emojis/656899770094452754.png" - voice_state_red: str = "https://cdn.discordapp.com/emojis/656899769905709076.png" - - -Icons = _Icons() - - class _Colours(EnvConfig, env_prefix="colours_"): """Named color constants.""" diff --git a/src/bot/dragonfly_services.py b/src/bot/dragonfly_services.py new file mode 100644 index 0000000..480f838 --- /dev/null +++ b/src/bot/dragonfly_services.py @@ -0,0 +1,165 @@ +"""Interacting with the Dragonfly API.""" + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from enum import Enum +from typing import Any, Self + +from aiohttp import ClientSession + + +class ScanStatus(Enum): + """The status of a package scan.""" + + QUEUED = "queued" + PENDING = "pending" + FINISHED = "finished" + FAILED = "failed" + + +@dataclass +class PackageScanResult: + """A package scan result.""" + + status: ScanStatus + inspector_url: str + queued_at: datetime + pending_at: datetime | None + finished_at: datetime | None + reported_at: datetime | None + version: str + name: str + package_id: str + rules: list[str] + score: int + + @classmethod + def from_dict(cls: type[Self], data: dict) -> Self: + """Create a PackageScanResult from a dictionary.""" + return cls( + status=ScanStatus(data["status"]), + inspector_url=data["inspector_url"], + queued_at=datetime.fromisoformat(data["queued_at"]), + pending_at=datetime.fromisoformat(p) if (p := data["pending_at"]) else None, + finished_at=datetime.fromisoformat(p) if (p := data["finished_at"]) else None, + reported_at=datetime.fromisoformat(p) if (p := data["reported_at"]) else None, + version=data["version"], + name=data["name"], + package_id=data["scan_id"], + rules=[d["name"] for d in data["rules"]], + score=int(data["score"]), + ) + + def __str__(self: Self) -> str: + """Return a string representation of the package scan result.""" + return f"{self.name} {self.version}" + + +class DragonflyServices: + """A class wrapping Dragonfly's API.""" + + def __init__( # noqa: PLR0913 -- Maybe pass the entire constants class? + self: Self, + session: ClientSession, + base_url: str, + auth_url: str, + audience: str, + client_id: str, + client_secret: str, + username: str, + password: str, + ) -> None: + """Initialize the DragonflyServices class.""" + self.session = session + self.base_url = base_url + self.auth_url = auth_url + self.audience = audience + self.client_id = client_id + self.client_secret = client_secret + self.username = username + self.password = password + self.token = "" + self.token_expires_at = datetime.now(tz=UTC) + + async def _update_token(self: Self) -> None: + """Update the OAUTH token.""" + if self.token_expires_at > datetime.now(tz=UTC): + return + + auth_dict = { + "grant_type": "password", + "audience": self.audience, + "client_id": self.client_id, + "client_secret": self.client_secret, + "username": self.username, + "password": self.password, + } + async with self.session.post(self.auth_url, json=auth_dict) as response: + data = await response.json() + self.token = data["access_token"] + self.token_expires_at = datetime.now(tz=UTC) + timedelta(seconds=data["expires_in"]) + + async def make_request( + self: Self, + method: str, + path: str, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> dict: + """Make a request to Dragonfly's API.""" + await self._update_token() + + headers = {"Authorization": "Bearer " + self.token} + + args = { + "url": self.base_url + path, + "method": method, + "headers": headers, + } + + if params is not None: + args["params"] = params + + if json is not None: + args["json"] = json + + async with self.session.request(**args) as response: + return await response.json() + + async def get_scanned_packages( + self: Self, + name: str | None = None, + version: str | None = None, + since: datetime | None = None, + ) -> list[PackageScanResult]: + """Get a list of scanned packages.""" + params = {} + if name: + params["name"] = name + + if version: + params["version"] = version + + if since: + params["since"] = int(since.timestamp()) + + data = await self.make_request("GET", "/package", params=params) + return [PackageScanResult.from_dict(dct) for dct in data] + + async def report_package( # noqa: PLR0913 + self: Self, + name: str, + version: str, + inspector_url: str | None, + additional_information: str | None, + recipient: str | None, + ) -> None: + """Report a package to Dragonfly.""" + data = { + "name": name, + "version": version, + "inspector_url": inspector_url, + "additional_information": additional_information, + "recipient": recipient, + } + await self.make_request("POST", "/report", json=data) diff --git a/src/bot/exts/__init__.py b/src/bot/exts/__init__.py index e69de29..0bb4256 100644 --- a/src/bot/exts/__init__.py +++ b/src/bot/exts/__init__.py @@ -0,0 +1 @@ +"""Extensions for the bot.""" diff --git a/src/bot/exts/audit.py b/src/bot/exts/audit.py index b42b96a..3742684 100644 --- a/src/bot/exts/audit.py +++ b/src/bot/exts/audit.py @@ -1,22 +1,29 @@ -"""Cog for package audition""" - +"""Cog for package audition.""" import math import random -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta +from typing import Self import discord from discord import app_commands, ui from discord.ext import commands from bot.bot import Bot -from bot.exts.dragonfly._api import PackageScanResult - -from .dragonfly._api import lookup_package_info +from bot.dragonfly_services import PackageScanResult class PaginatorView(ui.View): - def __init__(self, *, member: discord.Member | discord.User, packages: list[PackageScanResult], per: int = 15): + """A paginator view.""" + + def __init__( + self: Self, + *, + member: discord.Member | discord.User, + packages: list[PackageScanResult], + per: int = 15, + ) -> None: + """Initialize the paginator view.""" super().__init__(timeout=None) pages = math.ceil(len(packages) / per) self.member = member @@ -27,7 +34,8 @@ def __init__(self, *, member: discord.Member | discord.User, packages: list[Pack self.current = 0 @ui.button(emoji="◀️") - async def previous(self, interaction: discord.Interaction, _) -> None: + async def previous(self: Self, interaction: discord.Interaction, _) -> None: # noqa: ANN001 -- What is this? + """Go to the previous page.""" if self.current == 0: self.current = len(self.embeds) - 1 else: @@ -36,7 +44,8 @@ async def previous(self, interaction: discord.Interaction, _) -> None: await interaction.response.edit_message(embed=self.embeds[self.current], view=self) @ui.button(emoji="⏹️") - async def stop(self, interaction: discord.Interaction, button: ui.Button) -> None: + async def stop(self: Self, interaction: discord.Interaction, button: ui.Button) -> None: + """Stop the paginator.""" self.previous.disabled = True button.disabled = True self.next.disabled = True @@ -44,7 +53,8 @@ async def stop(self, interaction: discord.Interaction, button: ui.Button) -> Non await interaction.response.edit_message(embed=self.embeds[self.current], view=self) @ui.button(emoji="▶️") - async def next(self, interaction: discord.Interaction, _) -> None: + async def next(self: Self, interaction: discord.Interaction, _) -> None: # noqa: ANN001,A003 + """Go to the next page.""" if self.current == len(self.embeds) - 1: self.current = 0 else: @@ -52,14 +62,16 @@ async def next(self, interaction: discord.Interaction, _) -> None: await interaction.response.edit_message(embed=self.embeds[self.current], view=self) - async def interaction_check(self, interaction: discord.Interaction) -> bool: + async def interaction_check(self: Self, interaction: discord.Interaction) -> bool: + """Check if the interaction is from the member.""" if interaction.user == self.member: return True await interaction.response.send_message("This paginator is not for you!", ephemeral=True) return False - def _build_embed(self, packages: list[PackageScanResult], page: int, total: int) -> discord.Embed: + def _build_embed(self: Self, packages: list[PackageScanResult], page: int, total: int) -> discord.Embed: + """Build an embed for the given packages.""" embed = discord.Embed( title="Package Audit", description="\n".join( @@ -76,18 +88,19 @@ def _build_embed(self, packages: list[PackageScanResult], page: int, total: int) class Audit(commands.Cog): - """Cog for package auditing""" + """Cog for package auditing.""" def __init__( - self, + self: Self, bot: Bot, ) -> None: + """Initialize the cog.""" self.bot = bot @app_commands.command(name="audit", description="Randomly pick packages and display them") - async def audit(self, interaction: discord.Interaction, hours: int, amount: int) -> None: + async def audit(self: Self, interaction: discord.Interaction, hours: int, amount: int) -> None: """ - Recalls for scanned packages within a given time frame and amount + Recalls for scanned packages within a given time frame and amount. Parameters ---------- @@ -98,13 +111,12 @@ async def audit(self, interaction: discord.Interaction, hours: int, amount: int) The amount of random packages that should be chosen """ - # Defer immediately because it make take longer than 3 seconds to respond await interaction.response.defer(thinking=True) - since = datetime.now(tz=timezone.utc) - timedelta(hours=hours) + since = datetime.now(tz=UTC) - timedelta(hours=hours) - packages = await lookup_package_info(bot=self.bot, since=since) + packages = await self.bot.dragonfly_services.get_scanned_packages(since=since) packages = random.sample(packages, k=amount) view = PaginatorView(member=interaction.user, packages=packages) @@ -112,4 +124,5 @@ async def audit(self, interaction: discord.Interaction, hours: int, amount: int) async def setup(bot: Bot) -> None: + """Load the Audit cog.""" await bot.add_cog(Audit(bot)) diff --git a/src/bot/exts/core/__init__.py b/src/bot/exts/core/__init__.py index e69de29..ddbeb56 100644 --- a/src/bot/exts/core/__init__.py +++ b/src/bot/exts/core/__init__.py @@ -0,0 +1 @@ +"""Core commands for the bot.""" diff --git a/src/bot/exts/core/error_handler.py b/src/bot/exts/core/error_handler.py index 7c2be28..e0a1203 100644 --- a/src/bot/exts/core/error_handler.py +++ b/src/bot/exts/core/error_handler.py @@ -1,9 +1,10 @@ -"""Error handling""" +"""Error handling.""" import logging import math import random from collections.abc import Iterable +from typing import Self from discord import Embed, Message from discord.ext import commands @@ -22,34 +23,34 @@ class CommandErrorHandler(commands.Cog): """The error handler.""" - def __init__(self, bot: Bot): + def __init__(self: Self, bot: Bot) -> None: self.bot = bot @staticmethod def revert_cooldown_counter(command: commands.Command, message: Message) -> None: """Undoes the last cooldown counter for user-error cases.""" - # pylint: disable-next=protected-access - if command._buckets.valid: - # pylint: disable-next=protected-access - bucket = command._buckets.get_bucket(message) - # pylint: disable-next=protected-access - bucket._tokens = min(bucket.rate, bucket._tokens + 1) + if command._buckets.valid: # noqa: SLF001 -- Underscored attribute + bucket = command._buckets.get_bucket(message) # noqa: SLF001 -- Underscored attribute + bucket._tokens = min(bucket.rate, bucket._tokens + 1) # noqa: SLF001 -- Underscored attribute logging.debug("Cooldown counter reverted as the command was not used correctly.") @staticmethod - # pylint: disable-next=dangerous-default-value def error_embed(message: str, title: Iterable | str = NEGATIVE_REPLIES) -> Embed: """Build a basic embed with red colour and either a random error title or a title provided.""" embed = Embed(colour=Colours.soft_red) if isinstance(title, str): embed.title = title else: - embed.title = random.choice(title) + embed.title = random.choice(title) # noqa: S311 -- wat embed.description = message return embed @commands.Cog.listener() - async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: + async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? + self: Self, + ctx: commands.Context, + error: commands.CommandError, + ) -> None: """Activates when a command raises an error.""" if getattr(error, "handled", False): logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.") @@ -62,10 +63,10 @@ async def on_command_error(self, ctx: commands.Context, error: commands.CommandE error = getattr(error, "original", error) logging.debug( - f"Error Encountered: {type(error).__name__} - {str(error)}, " + f"Error Encountered: {type(error).__name__} - {error!s}, " f"Command: {ctx.command}, " f"Author: {ctx.author}, " - f"Channel: {ctx.channel}" + f"Channel: {ctx.channel}", ) if isinstance(error, commands.CommandNotFound): @@ -100,7 +101,7 @@ async def on_command_error(self, ctx: commands.Context, error: commands.CommandE self.revert_cooldown_counter(ctx.command, ctx.message) embed = self.error_embed( "The argument you provided was invalid: " - f"{error}\n\nUsage:\n```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" + f"{error}\n\nUsage:\n```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```", ) await ctx.send(embed=embed) return @@ -114,7 +115,7 @@ async def on_command_error(self, ctx: commands.Context, error: commands.CommandE embed=self.error_embed( f"There was an error when communicating with the {error.api}", NEGATIVE_REPLIES, - ) + ), ) return @@ -138,10 +139,10 @@ async def on_command_error(self, ctx: commands.Context, error: commands.CommandE if ctx.guild is not None: scope.set_extra("jump_to", ctx.message.jump_url) - log.exception(f"Unhandled command error: {str(error)}", exc_info=error) + log.exception(f"Unhandled command error: {error!s}", exc_info=error) - async def send_command_suggestion(self, ctx: commands.Context, command_name: str) -> None: - """Sends user similar commands if any can be found.""" + async def send_command_suggestion(self: Self, ctx: commands.Context, command_name: str) -> None: + """Send user similar commands if any can be found.""" command_suggestions = [] if similar_command_names := get_command_suggestions(list(self.bot.all_commands.keys()), command_name): for similar_command_name in similar_command_names: diff --git a/src/bot/exts/core/log.py b/src/bot/exts/core/log.py index 7430736..bac191d 100644 --- a/src/bot/exts/core/log.py +++ b/src/bot/exts/core/log.py @@ -1,7 +1,8 @@ -"""Cog to log""" +"""Cog to log.""" import logging -from datetime import datetime +from datetime import UTC, datetime +from typing import Self import discord from discord.ext.commands import Cog, Context @@ -15,19 +16,18 @@ class Log(Cog): """Logging for server events and staff actions.""" - def __init__(self, bot: Bot): + def __init__(self: Self, bot: Bot) -> None: self.bot = bot - # pylint: disable-next=too-many-locals,too-many-arguments - async def send_log_message( - self, + async def send_log_message( # noqa: PLR0913 -- Maybe refactor this? + self: Self, icon_url: str | None, colour: discord.Colour | int, title: str | None, text: str, thumbnail: str | discord.Asset | None = None, channel_id: int = Channels.mod_log, - ping_mods: bool = False, + ping_mods: bool = False, # noqa: FBT001,FBT002 files: list[discord.File] | None = None, content: str | None = None, additional_embeds: list[discord.Embed] | None = None, @@ -36,13 +36,13 @@ async def send_log_message( ) -> Context: """Generate log embed and send to logging channel.""" # Truncate string directly here to avoid removing newlines - embed = discord.Embed(description=text[:4093] + "..." if len(text) > 4096 else text) + embed = discord.Embed(description=text[:4093] + "..." if len(text) > 4096 else text) # noqa: PLR2004 if title and icon_url: embed.set_author(name=title, icon_url=icon_url) embed.colour = colour - embed.timestamp = timestamp_override or datetime.utcnow() + embed.timestamp = timestamp_override or datetime.now(tz=UTC) if footer: embed.set_footer(text=footer) @@ -51,13 +51,10 @@ async def send_log_message( embed.set_thumbnail(url=thumbnail) if ping_mods: - if content: - content = f"<@&{Roles.moderators}> {content}" - else: - content = f"<@&{Roles.moderators}>" + content = f"<@&{Roles.moderators}> {content}" if content else f"<@&{Roles.moderators}>" # Truncate content to 2000 characters and append an ellipsis. - if content and len(content) > 2000: + if content and len(content) > 2000: # noqa: PLR2004 content = content[: 2000 - 3] + "..." channel = self.bot.get_channel(channel_id) diff --git a/src/bot/exts/core/ping.py b/src/bot/exts/core/ping.py index b2cae87..fb291c3 100644 --- a/src/bot/exts/core/ping.py +++ b/src/bot/exts/core/ping.py @@ -1,4 +1,6 @@ -"""Pinging the bot""" +"""Pinging the bot.""" + +from typing import Self from discord import Embed from discord.ext import commands @@ -10,11 +12,11 @@ class Ping(commands.Cog): """Get info about the bot's ping and uptime.""" - def __init__(self, bot: Bot): + def __init__(self: Self, bot: Bot) -> None: self.bot = bot @commands.command(name="ping") - async def ping(self, ctx: commands.Context) -> None: + async def ping(self: Self, ctx: commands.Context) -> None: """Ping the bot to see its latency and state.""" embed = Embed( title=":ping_pong: Pong!", diff --git a/src/bot/exts/core/startup_notify.py b/src/bot/exts/core/startup_notify.py index be0af16..2f1a638 100644 --- a/src/bot/exts/core/startup_notify.py +++ b/src/bot/exts/core/startup_notify.py @@ -1,9 +1,13 @@ -from logging import getLogger +"""Cog that notifies a channel when the bot starts up.""" + import logging +from logging import getLogger +from typing import Self + import discord -from bot.bot import Bot from discord.ext import commands +from bot.bot import Bot from bot.constants import DragonflyConfig log = getLogger(__name__) @@ -11,12 +15,14 @@ class StartupNotify(commands.Cog): - """Cog that notifies a channel when the bot starts up""" + """Cog that notifies a channel when the bot starts up.""" - def __init__(self, bot: Bot) -> None: + def __init__(self: Self, bot: Bot) -> None: + """Initialize StartupNotify cog.""" self.bot = bot - def _build_notify_embed(self) -> discord.Embed: + def _build_notify_embed(self: Self) -> discord.Embed: + """Build the embed to send to the logs channel.""" embed = discord.Embed(description="Ready!") if user := self.bot.user: embed.set_author(name=user.name, icon_url=user.display_avatar.url) @@ -24,13 +30,14 @@ def _build_notify_embed(self) -> discord.Embed: return embed @commands.Cog.listener() - async def on_ready(self): + async def on_ready(self: Self) -> None: + """Send a startup notification to the logs channel.""" channel = self.bot.get_channel(DragonflyConfig.logs_channel_id) if isinstance(channel, discord.abc.Messageable): await channel.send(embed=self._build_notify_embed()) log.info("Successfully sent startup notification message") else: - log.warn("Channel %s is not messageable, could not send startup message", channel) + log.warning("Channel %s is not messageable, could not send startup message", channel) async def setup(bot: Bot) -> None: diff --git a/src/bot/exts/core/sync.py b/src/bot/exts/core/sync.py index 9e618f3..06edb05 100644 --- a/src/bot/exts/core/sync.py +++ b/src/bot/exts/core/sync.py @@ -1,7 +1,8 @@ -"""Sync all application commands""" +"""Sync all application commands.""" import logging from logging import getLogger +from typing import Self import discord from discord.app_commands import AppCommand @@ -15,12 +16,12 @@ class Sync(commands.Cog): - """Sync all application commands""" + """Sync all application commands.""" - def __init__(self, bot: Bot): + def __init__(self: Self, bot: Bot) -> None: self.bot = bot - async def _sync_commands(self) -> list[AppCommand]: + async def _sync_commands(self: Self) -> list[AppCommand]: """App command syncing logic. Returns a list of app commands that were synced.""" tree = self.bot.tree guild = discord.Object(id=constants.Guild.id) @@ -29,25 +30,27 @@ async def _sync_commands(self) -> list[AppCommand]: tree.copy_global_to(guild=guild) synced_commands = await tree.sync(guild=guild) log.debug( - "Synced %s commands: %s", len(synced_commands), ", ".join(command.name for command in synced_commands) + "Synced %s commands: %s", + len(synced_commands), + ", ".join(command.name for command in synced_commands), ) return synced_commands @commands.command(name="sync") @commands.has_permissions(administrator=True) - async def sync_prefix(self, ctx: commands.Context) -> None: - """Prefix command that syncs all application commands""" + async def sync_prefix(self: Self, ctx: commands.Context) -> None: + """Prefix command that syncs all application commands.""" synced_commands = await self._sync_commands() await ctx.send( - f"Synced {len(synced_commands)} commands: {', '.join(command.name for command in synced_commands)}" + f"Synced {len(synced_commands)} commands: {', '.join(command.name for command in synced_commands)}", ) @discord.app_commands.command(name="sync", description="Sync all application commands") @discord.app_commands.checks.has_permissions(administrator=True) - async def sync_slash(self, interaction: discord.Interaction) -> None: - """Slash command that syncs all application commands""" + async def sync_slash(self: Self, interaction: discord.Interaction) -> None: + """Slash command that syncs all application commands.""" synced_commands = await self._sync_commands() await interaction.response.send_message( diff --git a/src/bot/exts/dragonfly/__init__.py b/src/bot/exts/dragonfly/__init__.py index e69de29..2a30e2f 100644 --- a/src/bot/exts/dragonfly/__init__.py +++ b/src/bot/exts/dragonfly/__init__.py @@ -0,0 +1 @@ +"""Dragonfly Services API Wrapper.""" diff --git a/src/bot/exts/dragonfly/_api.py b/src/bot/exts/dragonfly/_api.py deleted file mode 100644 index 9a86376..0000000 --- a/src/bot/exts/dragonfly/_api.py +++ /dev/null @@ -1,102 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from typing import Optional - -from bot.bot import Bot -from bot.constants import DragonflyConfig - - -class ScanStatus(Enum): - QUEUED = "queued" - PENDING = "pending" - FINISHED = "finished" - FAILED = "failed" - - -@dataclass -class PackageScanResult: - status: ScanStatus - inspector_url: str - queued_at: datetime - pending_at: datetime | None - finished_at: datetime | None - reported_at: datetime | None - version: str - name: str - package_id: str - rules: list[str] - score: int - - @classmethod - def from_dict(cls, data: dict): - return cls( - status=ScanStatus(data["status"]), - inspector_url=data["inspector_url"], - queued_at=datetime.fromisoformat(data["queued_at"]), - pending_at=datetime.fromisoformat(p) if (p := data["pending_at"]) else None, - finished_at=datetime.fromisoformat(p) if (p := data["finished_at"]) else None, - reported_at=datetime.fromisoformat(p) if (p := data["reported_at"]) else None, - version=data["version"], - name=data["name"], - package_id=data["scan_id"], - rules=[d["name"] for d in data["rules"]], - score=int(data["score"]), - ) - - def __str__(self) -> str: - return f"{self.name} {self.version}" - - -async def lookup_package_info( - bot: Bot, - *, - name: str | None = None, - version: str | None = None, - since: datetime | None = None, -) -> list[PackageScanResult]: - params = {} - if name: - params["name"] = name - - if version: - params["version"] = version - - if since: - params["since"] = int(since.timestamp()) - - headers = {"Authorization": f"Bearer {bot.access_token}"} - - res = await bot.http_session.get(f"{DragonflyConfig.api_url}/package", params=params, headers=headers) - if res.status == 401: - await bot.authorize() - res = await bot.http_session.get(f"{DragonflyConfig.api_url}/package", params=params, headers=headers) - res.raise_for_status() # We should throw an error if something goes wrong the second time - - data = await res.json() - return [PackageScanResult.from_dict(d) for d in data] - - -async def report_package( - bot: Bot, - *, - name: str, - version: str, - inspector_url: Optional[str], - additional_information: Optional[str], - recipient: Optional[str], -) -> None: - headers = {"Authorization": f"Bearer {bot.access_token}"} - body = { - "name": name, - "version": version, - "inspector_url": inspector_url, - "additional_information": additional_information, - "recipient": recipient, - } - - res = await bot.http_session.post(f"{DragonflyConfig.api_url}/report", json=body, headers=headers) - if res.status == 401: - await bot.authorize() - res = await bot.http_session.post(f"{DragonflyConfig.api_url}/report", json=body, headers=headers) - res.raise_for_status() # We should throw an error if something goes wrong the second time diff --git a/src/bot/exts/dragonfly/dragonfly.py b/src/bot/exts/dragonfly/dragonfly.py index 7de1225..ed5de5c 100644 --- a/src/bot/exts/dragonfly/dragonfly.py +++ b/src/bot/exts/dragonfly/dragonfly.py @@ -1,23 +1,25 @@ -"""Download the most recent packages from PyPI and use Dragonfly to check them for malware""" +"""Download the most recent packages from PyPI and use Dragonfly to check them for malware.""" import logging -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from logging import getLogger -import aiohttp +from typing import Self +import aiohttp import discord from discord.ext import commands, tasks from bot.bot import Bot -from bot.constants import DragonflyConfig, Roles, Channels - -from ._api import PackageScanResult, lookup_package_info, report_package +from bot.constants import Channels, DragonflyConfig, Roles +from bot.dragonfly_services import PackageScanResult log = getLogger(__name__) log.setLevel(logging.INFO) class ConfirmReportModal(discord.ui.Modal): + """Modal for confirming a report.""" + recipient = discord.ui.TextInput( label="Recipient", placeholder="Recipient's Email Address", @@ -40,7 +42,8 @@ class ConfirmReportModal(discord.ui.Modal): style=discord.TextStyle.short, ) - def __init__(self, *, package: PackageScanResult, bot: Bot) -> None: + def __init__(self: Self, *, package: PackageScanResult, bot: Bot) -> None: + """Initialize the modal.""" self.package = package self.bot = bot @@ -50,21 +53,24 @@ def __init__(self, *, package: PackageScanResult, bot: Bot) -> None: super().__init__() - async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + async def on_error(self: Self, interaction: discord.Interaction, error: Exception) -> None: + """Handle errors that occur in the modal.""" if isinstance(error, aiohttp.ClientResponseError): return await interaction.response.send_message(f"Error from upstream: {error.status}", ephemeral=True) await interaction.response.send_message("An unexpected error occured.", ephemeral=True) raise error - def _build_modal_title(self) -> str: + def _build_modal_title(self: Self) -> str: + """Build the modal title.""" title = f"Confirm report for {self.package.name} v{self.package.version}" - if len(title) >= 45: + if len(title) >= 45: # noqa: PLR2004 title = title[:42] + "..." return title - async def on_submit(self, interaction: discord.Interaction): + async def on_submit(self: Self, interaction: discord.Interaction) -> None: + """Submit the report.""" # discord.py returns empty string "" if not filled out, we want it to be `None` additional_information_override = self.additional_information.value or None inspector_url_override = self.inspector_url.value or None @@ -84,12 +90,11 @@ async def on_submit(self, interaction: discord.Interaction): f"User {interaction.user.mention} " f"reported package `{self.package.name}` " f"with additional_description `{additional_information_override}`" - f"with inspector_url `{inspector_url_override}`" + f"with inspector_url `{inspector_url_override}`", ) try: - await report_package( - bot=self.bot, + await self.bot.dragonfly_services.report_package( name=self.package.name, version=self.package.version, inspector_url=inspector_url_override, @@ -104,15 +109,16 @@ async def on_submit(self, interaction: discord.Interaction): class ReportView(discord.ui.View): - """Report view""" + """Report view.""" - def __init__(self, bot: Bot, payload: PackageScanResult) -> None: + def __init__(self: Self, bot: Bot, payload: PackageScanResult) -> None: self.bot = bot self.payload = payload super().__init__(timeout=None) @discord.ui.button(label="Report", style=discord.ButtonStyle.red) - async def report(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + async def report(self: Self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Report a package.""" modal = ConfirmReportModal(package=self.payload, bot=self.bot) await interaction.response.send_modal(modal) @@ -123,8 +129,7 @@ async def report(self, interaction: discord.Interaction, button: discord.ui.Butt def _build_package_scan_result_embed(scan_result: PackageScanResult) -> discord.Embed: - """Build the embed that shows the results of a package scan""" - + """Build the embed that shows the results of a package scan.""" embed = discord.Embed( title=f"New Scan Result: {scan_result.name} v{scan_result.version}", description=f"```YARA rules matched: {', '.join(scan_result.rules) or 'None'}```", @@ -149,8 +154,7 @@ def _build_package_scan_result_embed(scan_result: PackageScanResult) -> discord. def _build_all_packages_scanned_embed(scan_results: list[PackageScanResult]) -> discord.Embed: - """Build the embed that shows a list of all packages scanned""" - + """Build the embed that shows a list of all packages scanned.""" desc = "\n".join(map(str, scan_results)) embed = discord.Embed(title="Dragonfly Scan Logs", description=f"```{desc}```") @@ -166,14 +170,16 @@ async def run( logs_channel: discord.abc.Messageable, score: int, ) -> None: - """Script entrypoint""" - since = datetime.now(tz=timezone.utc) - timedelta(seconds=DragonflyConfig.interval) - scan_results = await lookup_package_info(bot, since=since) + """Script entrypoint.""" + since = datetime.now(tz=UTC) - timedelta(seconds=DragonflyConfig.interval) + scan_results = await bot.dragonfly_services.get_scanned_packages(since=since) for result in scan_results: if result.score >= score: embed = _build_package_scan_result_embed(result) await alerts_channel.send( - f"<@&{DragonflyConfig.alerts_role_id}>", embed=embed, view=ReportView(bot, result) + f"<@&{DragonflyConfig.alerts_role_id}>", + embed=embed, + view=ReportView(bot, result), ) if scan_results: @@ -185,13 +191,17 @@ async def run( class Dragonfly(commands.Cog): - def __init__(self, bot: Bot) -> None: + """Cog for the Dragonfly scanner.""" + + def __init__(self: Self, bot: Bot) -> None: + """Initialize the Dragonfly cog.""" self.bot = bot self.score_threshold = DragonflyConfig.threshold super().__init__() @tasks.loop(seconds=DragonflyConfig.interval) - async def scan_loop(self) -> None: + async def scan_loop(self: Self) -> None: + """Loop that runs the scan task.""" logs_channel = self.bot.get_channel(DragonflyConfig.logs_channel_id) assert isinstance(logs_channel, discord.abc.Messageable) @@ -206,11 +216,13 @@ async def scan_loop(self) -> None: ) @scan_loop.before_loop - async def before_scan_loop(self) -> None: + async def before_scan_loop(self: Self) -> None: + """Wait until the bot is ready.""" await self.bot.wait_until_ready() @scan_loop.error - async def scan_loop_error(self, exc: BaseException) -> None: + async def scan_loop_error(self: Self, exc: BaseException) -> None: + """Log any errors that occur in the scan loop.""" logs_channel = self.bot.get_channel(DragonflyConfig.logs_channel_id) assert isinstance(logs_channel, discord.abc.Messageable) @@ -230,7 +242,8 @@ async def scan_loop_error(self, exc: BaseException) -> None: @commands.has_role(Roles.vipyr_security) @commands.command() - async def start(self, ctx: commands.Context) -> None: + async def start(self: Self, ctx: commands.Context) -> None: + """Start the scan task.""" if self.scan_loop.is_running(): await ctx.send("Task is already running.") else: @@ -239,7 +252,8 @@ async def start(self, ctx: commands.Context) -> None: @commands.has_role(Roles.vipyr_security) @commands.command() - async def stop(self, ctx: commands.Context, force: bool = False) -> None: + async def stop(self: Self, ctx: commands.Context, force: bool = False) -> None: # noqa: FBT001,FBT002 + """Stop the scan task.""" if self.scan_loop.is_running(): if force: self.scan_loop.cancel() @@ -252,8 +266,9 @@ async def stop(self, ctx: commands.Context, force: bool = False) -> None: @discord.app_commands.checks.has_role(Roles.vipyr_security) @discord.app_commands.command(name="lookup", description="Scans a package") - async def lookup(self, interaction: discord.Interaction, name: str, version: str | None = None) -> None: - scan_results = await lookup_package_info(self.bot, name=name, version=version) + async def lookup(self: Self, interaction: discord.Interaction, name: str, version: str | None = None) -> None: + """Pull the scan results for a package.""" + scan_results = await self.bot.dragonfly_services.get_scanned_packages(name=name, version=version) if scan_results: embed = _build_package_scan_result_embed(scan_results[0]) await interaction.response.send_message(embed=embed) @@ -261,21 +276,25 @@ async def lookup(self, interaction: discord.Interaction, name: str, version: str await interaction.response.send_message("No entries were found with the specified filters.") @commands.group() - async def threshold(self, ctx: commands.Context) -> None: + async def threshold(self: Self, ctx: commands.Context) -> None: + """Group of commands for managing the score threshold.""" if ctx.invoked_subcommand is None: await ctx.send_help(self.threshold) @threshold.command() - async def get(self, ctx: commands.Context) -> None: + async def get(self: Self, ctx: commands.Context) -> None: + """Get the score threshold.""" await ctx.send(f"The current threshold is set to `{self.score_threshold}`") @threshold.command() - async def set(self, ctx: commands.Context, value: int) -> None: + async def set(self: Self, ctx: commands.Context, value: int) -> None: # noqa: A003 + """Set the score threshold.""" self.score_threshold = value await ctx.send(f"The current threshold has been set to `{value}`") async def setup(bot: Bot) -> None: + """Load the Dragonfly cog.""" cog = Dragonfly(bot) task = cog.scan_loop if not task.is_running(): diff --git a/src/bot/exts/pypi.py b/src/bot/exts/pypi.py deleted file mode 100644 index 57197b9..0000000 --- a/src/bot/exts/pypi.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Cog for interacting with PyPI""" - -from itertools import islice -from math import ceil -from typing import Generator - -import discord -from discord.ext import commands -from letsbuilda.pypi import PackageMetadata - -from bot.constants import PyPiConfigs - - -class EmbedPaginator: - """Paginate embeds""" - - def __init__(self, packages: list[PackageMetadata], per_page: int) -> None: - self.idx = 0 - self.per_page = per_page - self.packages = packages - self.embeds = self._build_embeds() - - def _batched(self) -> Generator[list[PackageMetadata], None, None]: - it = iter(self.packages) - while True: - batch = list(islice(it, self.per_page)) - if not batch: - break - yield batch - - def _build_embeds(self) -> list[discord.Embed]: - embeds: list[discord.Embed] = [] - - for page_number, packages in enumerate(self._batched()): - embed = discord.Embed( - title="Recently uploaded packages", - color=discord.Color.blurple(), - ) - - for package in packages: - embed.add_field( - name=package.title, - value="\n".join( - ( - package.description or "*No description provided*", - "", - discord.utils.format_dt(package.publication_date), - f"[Package Link]({package.package_link})", - f"[Inspector Link](https://inspector.pypi.io/project/{package.title})", - package.author if PyPiConfigs.show_author_in_embed and package.author else "", - ) - ), - ) - - embed.set_footer(text=f"Page {page_number + 1}/{ceil(len(self.packages) / self.per_page)}") - - embeds.append(embed) - - return embeds - - @property - def current(self) -> discord.Embed: - return self.embeds[self.idx] - - @property - def is_at_last(self) -> bool: - return self.idx == len(self.embeds) - 1 - - @property - def is_at_first(self) -> bool: - return self.idx == 0 - - def next(self) -> None: - if self.idx < len(self.embeds) - 1: - self.idx += 1 - - def prev(self) -> None: - if self.idx > 0: - self.idx -= 1 - - def first(self) -> None: - self.idx = 0 - - def last(self) -> None: - self.idx = len(self.embeds) - 1 - - -class PackageViewer(discord.ui.View): - """Package viewer""" - - def __init__(self, *, packages: list[PackageMetadata]) -> None: - self.paginator = EmbedPaginator(packages, per_page=3) - self.message: discord.Message | None = None - - super().__init__(timeout=None) - - @discord.ui.button(label="First", custom_id="first", style=discord.ButtonStyle.blurple) - async def first(self, interaction: discord.Interaction, _) -> None: - self.paginator.first() - await interaction.response.edit_message(embed=self.paginator.current) - - @discord.ui.button(label="Previous", custom_id="previous", style=discord.ButtonStyle.blurple) - async def prev(self, interaction: discord.Interaction, _) -> None: - self.paginator.prev() - await interaction.response.edit_message(embed=self.paginator.current) - - @discord.ui.button(label="Next", custom_id="next", style=discord.ButtonStyle.blurple) - async def next(self, interaction: discord.Interaction, _) -> None: - self.paginator.next() - await interaction.response.edit_message(embed=self.paginator.current) - - @discord.ui.button(label="Last", custom_id="last", style=discord.ButtonStyle.blurple) - async def last(self, interaction: discord.Interaction, _) -> None: - self.paginator.last() - await interaction.response.edit_message(embed=self.paginator.current) - - -class Pypi(commands.Cog): - """Cog for interacting with PyPI""" - - def __init__(self, bot): - self.bot = bot - - @commands.command() - async def pypi(self, ctx: commands.Context) -> None: - await ctx.send(embed=self.bot.package_view.paginator.current, view=self.bot.package_view) - - -async def setup(bot) -> None: - """Setup the cog on the bot""" - await bot.add_cog(Pypi(bot)) diff --git a/src/bot/exts/utilities/__init__.py b/src/bot/exts/utilities/__init__.py index e69de29..e38900e 100644 --- a/src/bot/exts/utilities/__init__.py +++ b/src/bot/exts/utilities/__init__.py @@ -0,0 +1 @@ +"""Utility commands for the bot.""" diff --git a/src/bot/exts/utilities/internal.py b/src/bot/exts/utilities/internal.py index 03d24ad..ac45393 100644 --- a/src/bot/exts/utilities/internal.py +++ b/src/bot/exts/utilities/internal.py @@ -1,3 +1,5 @@ +"""Internal commands for bot administration and core development.""" + import contextlib import inspect import pprint @@ -6,7 +8,7 @@ import traceback from collections import Counter from io import StringIO -from typing import Any +from typing import Any, Self import arrow import discord @@ -28,7 +30,7 @@ class Internal(Cog): """Administrator and Core Developer commands.""" - def __init__(self, bot: Bot): + def __init__(self: Self, bot: Bot) -> None: self.bot = bot self.env = {} self.ln = 0 @@ -42,12 +44,12 @@ def __init__(self, bot: Bot): self.eval.add_check(is_owner().predicate) @Cog.listener() - async def on_socket_event_type(self, event_type: str) -> None: + async def on_socket_event_type(self: Self, event_type: str) -> None: """When a websocket event is received, increase our counters.""" self.socket_event_total += 1 self.socket_events[event_type] += 1 - def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: + def _format(self: Self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: # noqa: ANN401,C901,PLR0912 """Format the eval output into a string & attempt to format it into an Embed.""" self._ = out @@ -64,7 +66,7 @@ def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: # Create the input dialog for i, line in enumerate(lines): - if i == 0: + if i == 0: # noqa: SIM108 -- ternary would strip the comment # Start dialog start = f"In [{self.ln}]: " @@ -87,9 +89,8 @@ def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: # to indent it. start = "...: ".rjust(len(str(self.ln)) + 7) - if i == len(lines) - 2: - if line.startswith("return"): - line = line[6:].strip() + if i == len(lines) - 2 and line.startswith("return"): + line = line[6:].strip() # noqa: PLW2901 # Combine everything res += start + line + "\n" @@ -118,16 +119,13 @@ def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: # Leave out the traceback message out = "\n" + "\n".join(out.split("\n")[1:]) - if isinstance(out, str): - pretty = out - else: - pretty = pprint.pformat(out, compact=True, width=60) + pretty = out if isinstance(out, str) else pprint.pformat(out, compact=True, width=60) if pretty != str(out): # We're using the pretty version, start on the next line res += "\n" - if pretty.count("\n") > 20: + if pretty.count("\n") > 20: # noqa: PLR2004 # Text too long, shorten li = pretty.split("\n") @@ -143,7 +141,7 @@ def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: return res # Return (text, embed) - async def _eval(self, ctx: Context, code: str) -> discord.Message | None: + async def _eval(self: Self, ctx: Context, code: str) -> discord.Message | None: """Eval the input code string & send an embed to the invoking context.""" self.ln += 1 @@ -180,7 +178,7 @@ async def func(): # (None,) -> Any finally: self.env.update(locals()) """.format( - textwrap.indent(code, " ") + textwrap.indent(code, " "), ) try: @@ -188,7 +186,7 @@ async def func(): # (None,) -> Any func = self.env["func"] res = await func() - except Exception: + except Exception: # noqa: BLE001 res = traceback.format_exc() out, embed = self._format(code, res) @@ -197,7 +195,7 @@ async def func(): # (None,) -> Any # Truncate output to max 15 lines or 1500 characters newline_truncate_index = find_nth_occurrence(out, "\n", 15) - if newline_truncate_index is None or newline_truncate_index > 1500: + if newline_truncate_index is None or newline_truncate_index > 1500: # noqa: PLR2004 truncate_index = 1500 else: truncate_index = newline_truncate_index @@ -212,7 +210,7 @@ async def func(): # (None,) -> Any else: paste_text = f"full contents at {paste_link}" - await ctx.send(f"```py\n{out[:truncate_index]}\n```" f"... response truncated; {paste_text}", embed=embed) + await ctx.send(f"```py\n{out[:truncate_index]}\n```... response truncated; {paste_text}", embed=embed) return None await ctx.send(f"```py\n{out}```", embed=embed) @@ -220,14 +218,14 @@ async def func(): # (None,) -> Any @group(name="internal", aliases=("int",)) @has_any_role(Roles.administrators, Roles.core_developers) - async def internal_group(self, ctx: Context) -> None: - """Internal commands. Top secret!""" + async def internal_group(self: Self, ctx: Context) -> None: + """Internal commands. Top secret!.""" # noqa: D401 if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) @internal_group.command(name="eval", aliases=("e",)) @has_any_role(Roles.administrators) - async def eval(self, ctx: Context, *, code: str) -> None: + async def eval(self: Self, ctx: Context, *, code: str) -> None: # noqa: A003 """Run eval in a REPL-like format.""" code = code.strip("`") if re.match("py(thon)?\n", code): @@ -235,7 +233,9 @@ async def eval(self, ctx: Context, *, code: str) -> None: if ( not re.search( # Check if it's an expression - r"^(return|import|for|while|def|class|" r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M + r"^(return|import|for|while|def|class|from|exit|[a-zA-Z0-9]+\s*=)", + code, + re.M, ) and len(code.split("\n")) == 1 ): @@ -245,7 +245,7 @@ async def eval(self, ctx: Context, *, code: str) -> None: @internal_group.command(name="socketstats", aliases=("socket", "stats")) @has_any_role(Roles.administrators, Roles.core_developers) - async def socketstats(self, ctx: Context) -> None: + async def socketstats(self: Self, ctx: Context) -> None: """Fetch information on the socket events received from Discord.""" running_s = (arrow.utcnow() - self.socket_since).total_seconds() diff --git a/src/bot/log.py b/src/bot/log.py index 499b999..ecd0509 100644 --- a/src/bot/log.py +++ b/src/bot/log.py @@ -1,11 +1,11 @@ -"""Logging""" +"""Logging.""" import logging import os import sys from logging import Logger, handlers from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Self, cast import coloredlogs import sentry_sdk @@ -16,16 +16,13 @@ TRACE_LEVEL = 5 -if TYPE_CHECKING: - LoggerClass = Logger -else: - LoggerClass = logging.getLoggerClass() +LoggerClass = Logger if TYPE_CHECKING else logging.getLoggerClass() class CustomLogger(LoggerClass): """Custom implementation of the `Logger` class with an added `trace` method.""" - def trace(self, msg: str, *args, **kwargs) -> None: + def trace(self: Self, msg: str, *args: tuple, **kwargs: dict) -> None: """ Log 'msg % args' with severity 'TRACE'. @@ -39,7 +36,7 @@ def trace(self, msg: str, *args, **kwargs) -> None: def get_logger(name: str | None = None) -> CustomLogger: - """Utility to make mypy recognise that logger is of type `CustomLogger`.""" + """Helper to make mypy recognise that logger is of type `CustomLogger`.""" # noqa: D401 return cast(CustomLogger, logging.getLogger(name)) diff --git a/src/bot/utils/__init__.py b/src/bot/utils/__init__.py index 91510c9..9bba78c 100644 --- a/src/bot/utils/__init__.py +++ b/src/bot/utils/__init__.py @@ -1,3 +1,5 @@ +"""Utility functions and classes for the bot.""" + from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64 from bot.utils.services import ( PasteTooLongError, diff --git a/src/bot/utils/commands.py b/src/bot/utils/commands.py index 2158a17..6bf1d44 100644 --- a/src/bot/utils/commands.py +++ b/src/bot/utils/commands.py @@ -1,4 +1,4 @@ -"""Command utils""" +"""Command utils.""" from rapidfuzz import process diff --git a/src/bot/utils/exceptions.py b/src/bot/utils/exceptions.py index 5634f6c..edc0fcf 100644 --- a/src/bot/utils/exceptions.py +++ b/src/bot/utils/exceptions.py @@ -1,12 +1,13 @@ -"""Custom Exception(s)""" +"""Custom Exception(s).""" from collections.abc import Hashable +from typing import Self class APIError(Exception): """Raised when an external API (eg. Wikipedia) returns an error response.""" - def __init__(self, api: str, status_code: int, error_msg: str | None = None): + def __init__(self: Self, api: str, status_code: int, error_msg: str | None = None) -> None: super().__init__() self.api = api self.status_code = status_code @@ -16,7 +17,7 @@ def __init__(self, api: str, status_code: int, error_msg: str | None = None): class MovedCommandError(Exception): """Raised when a command has moved locations.""" - def __init__(self, new_command_name: str): + def __init__(self: Self, new_command_name: str) -> None: self.new_command_name = new_command_name @@ -24,16 +25,17 @@ class LockedResourceError(RuntimeError): """ Exception raised when an operation is attempted on a locked resource. - Attributes: + Attributes + ---------- `type` -- name of the locked resource's type `id` -- ID of the locked resource """ - def __init__(self, resource_type: str, resource_id: Hashable): + def __init__(self: Self, resource_type: str, resource_id: Hashable) -> None: self.type = resource_type self.id = resource_id super().__init__( f"Cannot operate on {self.type.lower()} `{self.id}`; " - "it is currently locked and in use by another operation." + "it is currently locked and in use by another operation.", ) diff --git a/src/bot/utils/function.py b/src/bot/utils/function.py index b347ae0..ce996a5 100644 --- a/src/bot/utils/function.py +++ b/src/bot/utils/function.py @@ -3,8 +3,9 @@ import functools import inspect import types -from collections.abc import Callable -from typing import Any, OrderedDict, Sequence +from collections import OrderedDict +from collections.abc import Callable, Sequence +from typing import Any from bot.log import get_logger @@ -20,7 +21,7 @@ class GlobalNameConflictError(Exception): """Raised when there's a conflict between the globals used to resolve annotations of wrapped and its wrapper.""" -def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> Any: +def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> Any: # noqa: ANN401 """ Return a value from `arguments` based on a name or position. @@ -36,23 +37,27 @@ def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> Any: try: name, value = arg_values[arg_pos] + except IndexError as exception: + msg = f"Argument position {arg_pos} is out of bounds." + raise ValueError(msg) from exception + else: return value - except IndexError: - raise ValueError(f"Argument position {arg_pos} is out of bounds.") elif isinstance(name_or_pos, str): arg_name = name_or_pos try: return arguments[arg_name] - except KeyError: - raise ValueError(f"Argument {arg_name!r} doesn't exist.") + except KeyError as exception: + msg = f"Argument {arg_name!r} doesn't exist." + raise ValueError(msg) from exception else: - raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") + msg = "'arg' must either be an int (positional index) or a str (keyword)." + raise TypeError(msg) def get_arg_value_wrapper( decorator_func: Callable[[ArgValGetter], Decorator], name_or_pos: Argument, - func: Callable[[Any], Any] = None, + func: Callable[[Any], Any] | None = None, ) -> Decorator: """ Call `decorator_func` with the value of the arg at the given name/position. @@ -65,7 +70,7 @@ def get_arg_value_wrapper( Return the decorator returned by `decorator_func`. """ - def wrapper(args: BoundArgs) -> Any: + def wrapper(args: BoundArgs) -> Any: # noqa: ANN401 value = get_arg_value(name_or_pos, args) if func: value = func(value) @@ -114,11 +119,11 @@ def update_wrapper_globals( shared_globals = set(wrapper.__code__.co_names) & set(annotation_global_names) shared_globals &= set(wrapped.__globals__) & set(wrapper.__globals__) - ignored_conflict_names if shared_globals: - raise GlobalNameConflictError( - f"wrapper and the wrapped function share the following " - f"global names used by annotations: {', '.join(shared_globals)}. Resolve the conflicts or add " - f"the name to the `ignored_conflict_names` set to suppress this error if this is intentional." + msg = ( + f"wrapper and the wrapped function share the following global names used by annotations: {', '.join(shared_globals)}." # noqa: E501 + "Resolve the conflicts or add the name to the `ignored_conflict_names` set to suppress this error if this is intentional." # noqa: E501 ) + raise GlobalNameConflictError(msg) new_globals = wrapper.__globals__.copy() new_globals.update((k, v) for k, v in wrapped.__globals__.items() if k not in wrapper.__code__.co_names) diff --git a/src/bot/utils/helpers.py b/src/bot/utils/helpers.py index 8e58179..a9f78de 100644 --- a/src/bot/utils/helpers.py +++ b/src/bot/utils/helpers.py @@ -1,4 +1,4 @@ -"""Helper utils""" +"""Helper utils.""" import re from abc import ABCMeta @@ -37,7 +37,7 @@ def pad_base64(data: str) -> str: def remove_subdomain_from_url(url: str) -> str: - """Removes subdomains from a URL whilst preserving the original URL composition.""" + """Remove subdomains from a URL whilst preserving the original URL composition.""" parsed_url = urlparse(url) extracted_url = extract(url) # Eliminate subdomain by using the registered domain only @@ -47,7 +47,7 @@ def remove_subdomain_from_url(url: str) -> str: def suppress_links(message: str) -> str: - """Accepts a message that may contain links, suppresses them, and returns them.""" + """Accept a message that may contain links, suppresses them, and returns them.""" for link in set(re.findall(r"https?://[^\s]+", message, re.IGNORECASE)): message = message.replace(link, f"<{link}>") return message diff --git a/src/bot/utils/lock.py b/src/bot/utils/lock.py index c864654..6eb0420 100644 --- a/src/bot/utils/lock.py +++ b/src/bot/utils/lock.py @@ -1,10 +1,12 @@ +"""Locking utilities for mutually exclusive operations on resources.""" + import asyncio import inspect import types from collections import defaultdict from collections.abc import Awaitable, Callable, Hashable from functools import partial -from typing import Any +from typing import Any, Self from weakref import WeakValueDictionary from bot.log import get_logger @@ -29,23 +31,23 @@ class SharedEvent: when all of the holders finish the event will be set. """ - def __init__(self): + def __init__(self: Self) -> None: self._active_count = 0 self._event = asyncio.Event() self._event.set() - def __enter__(self): + def __enter__(self: Self) -> None: """Increment the count of the active holders and clear the internal event.""" self._active_count += 1 self._event.clear() - def __exit__(self, _exc_type, _exc_val, _exc_tb): # noqa: ANN001 + def __exit__(self: Self, _exc_type, _exc_val, _exc_tb) -> None: # noqa: ANN001 """Decrement the count of the active holders; if 0 is reached set the internal event.""" self._active_count -= 1 if not self._active_count: self._event.set() - async def wait(self) -> None: + async def wait(self: Self) -> None: """Wait for all active holders to exit.""" await self._event.wait() @@ -79,7 +81,7 @@ def decorator(func: types.FunctionType) -> types.FunctionType: name = func.__name__ @command_wraps(func) - async def wrapper(*args, **kwargs) -> Any: + async def wrapper(*args: tuple, **kwargs: dict) -> Any: # noqa: ANN401 -- matches signature of upstream log.trace(f"{name}: mutually exclusive decorator called") if callable(resource_id): @@ -123,7 +125,7 @@ async def wrapper(*args, **kwargs) -> Any: def lock_arg( namespace: Hashable, name_or_pos: function.Argument, - func: Callable[[Any], _IdCallableReturn] = None, + func: Callable[[Any], _IdCallableReturn] | None = None, *, raise_error: bool = False, wait: bool = False, diff --git a/src/bot/utils/messages.py b/src/bot/utils/messages.py index c71067c..067141e 100644 --- a/src/bot/utils/messages.py +++ b/src/bot/utils/messages.py @@ -1,4 +1,4 @@ -"""Message utilities""" +"""Message utilities.""" import contextlib @@ -15,19 +15,18 @@ def format_user(user: discord.abc.User) -> str: async def get_discord_message(ctx: Context, text: str) -> Message | str: """ - Attempts to convert a given `text` to a discord Message object and return it. + Attempt to convert a given `text` to a discord Message object and return it. Conversion will succeed if given a discord Message ID or link. Returns `text` if the conversion fails. """ with contextlib.suppress(commands.BadArgument): - text = await MessageConverter().convert(ctx, text) - return text + return await MessageConverter().convert(ctx, text) async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Embed | None]: """ - Attempts to extract the text and embed from a possible link to a discord Message. + Attempt 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. diff --git a/src/bot/utils/services.py b/src/bot/utils/services.py index 7500c5b..fc527ad 100644 --- a/src/bot/utils/services.py +++ b/src/bot/utils/services.py @@ -1,3 +1,5 @@ +"""Service utilities for the bot.""" + from aiohttp import ClientConnectorError, ClientSession from bot.constants import URLs @@ -18,7 +20,11 @@ class PasteTooLongError(Exception): async def send_to_paste_service( - http_session: ClientSession, contents: str, *, extension: str = "", max_length: int = MAX_PASTE_LENGTH + http_session: ClientSession, + contents: str, + *, + extension: str = "", + max_length: int = MAX_PASTE_LENGTH, ) -> str: """ Upload `contents` to the paste service. @@ -32,14 +38,16 @@ async def send_to_paste_service( Return the generated URL with the extension. """ if max_length > MAX_PASTE_LENGTH: - raise ValueError(f"`max_length` must not be greater than {MAX_PASTE_LENGTH}") + msg = f"`max_length` must not be greater than {MAX_PASTE_LENGTH}" + raise ValueError(msg) extension = extension and f".{extension}" contents_size = len(contents.encode()) if contents_size > max_length: log.info("Contents too large to send to paste service.") - raise PasteTooLongError(f"Contents of size {contents_size} greater than maximum size {max_length}") + msg = f"Contents of size {contents_size} greater than maximum size {max_length}" + raise PasteTooLongError(msg) log.debug(f"Sending contents of size {contents_size} bytes to paste service.") paste_url = URLs.paste_service.format(key="documents") @@ -50,20 +58,20 @@ async def send_to_paste_service( except ClientConnectorError: log.warning( f"Failed to connect to paste service at url {paste_url}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS}).", ) continue except Exception: log.exception( f"An unexpected error has occurred during handling of the request, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS}).", ) continue if "message" in response_json: log.warning( f"Paste service returned error {response_json['message']} with status code {response.status}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS}).", ) continue if "key" in response_json: @@ -78,7 +86,8 @@ async def send_to_paste_service( log.warning( f"Got unexpected JSON response from paste service: {response_json}\n" - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS}).", ) - raise PasteUploadError("Failed to upload contents to paste service") + msg = "Failed to upload contents to paste service" + raise PasteUploadError(msg) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5b90a13..04a23d5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,8 +1,8 @@ -"""Stub test file""" +"""Stub test file.""" from __future__ import annotations def test_smoke() -> None: - """Smoke test for CI""" - assert True is True + """Smoke test for CI.""" + assert True is True # noqa: PLR0133 - Please add some real test soon