diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..2a15b3e --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/powershell:1": { + "version": "1.3.2", + "resolved": "ghcr.io/devcontainers/features/powershell@sha256:c11122f0fc8352fcf3a1c2eab1023daab9db7982a83725af803307fd18fb64f4", + "integrity": "sha256:c11122f0fc8352fcf3a1c2eab1023daab9db7982a83725af803307fd18fb64f4" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "1.4.1", + "resolved": "ghcr.io/devcontainers/features/python@sha256:d7e393af2440444dddb3c275cf7f90c899a24f8e853e4d6315e1be3be7e1d49f", + "integrity": "sha256:d7e393af2440444dddb3c275cf7f90c899a24f8e853e4d6315e1be3be7e1d49f" + } + } +} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 863847f..611de21 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,5 +1,10 @@ version: 2 updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: "monthly" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/docker-build-push.yaml b/.github/workflows/container-build-push.yaml similarity index 67% rename from .github/workflows/docker-build-push.yaml rename to .github/workflows/container-build-push.yaml index f526399..2bbb841 100644 --- a/.github/workflows/docker-build-push.yaml +++ b/.github/workflows/container-build-push.yaml @@ -1,4 +1,4 @@ -name: "Docker Build and Push" +name: "Container Build and Push" on: push: @@ -6,6 +6,7 @@ on: - main tags: - v* + pull_request: permissions: contents: read @@ -16,4 +17,6 @@ permissions: jobs: build-push: - uses: darbiadev/.github/.github/workflows/docker-build-push.yaml@9160d4ddd590c15fe8a1f6d1704bf8806969d2b6 # v4.0.1 + uses: darbiadev/.github/.github/workflows/docker-build-push.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 + with: + file-name: Dockerfile diff --git a/.github/workflows/dependency-review.yaml b/.github/workflows/dependency-review.yaml index cb31094..4d6bda1 100644 --- a/.github/workflows/dependency-review.yaml +++ b/.github/workflows/dependency-review.yaml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: "Dependency Review" - uses: actions/dependency-review-action@fde92acd0840415674c16b39c7d703fc28bc511e # v3.1.2 + uses: actions/dependency-review-action@9129d7d40b8c12c1ed0f60400d00c92d437adcce # v4.1.3 with: config-file: darbiadev/.github/.github/dependency-review-config.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 28e8b79..48843bd 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -8,13 +8,13 @@ on: jobs: pre-commit: - uses: darbiadev/.github/.github/workflows/generic-precommit.yaml@9160d4ddd590c15fe8a1f6d1704bf8806969d2b6 # v4.0.1 + uses: darbiadev/.github/.github/workflows/generic-precommit.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 with: python-version: "3.11" lint: needs: pre-commit - uses: darbiadev/.github/.github/workflows/python-lint.yaml@9160d4ddd590c15fe8a1f6d1704bf8806969d2b6 # v4.0.1 + uses: darbiadev/.github/.github/workflows/python-lint.yaml@6a6eb74ae11149881c29adf5a5f7af23349c8762 # v9.0.0 with: python-version: "3.11" @@ -25,7 +25,7 @@ jobs: os: [ ubuntu-latest ] python-version: [ "3.11" ] - uses: darbiadev/.github/.github/workflows/python-test.yaml@9160d4ddd590c15fe8a1f6d1704bf8806969d2b6 # v4.0.1 + uses: darbiadev/.github/.github/workflows/python-test.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} @@ -37,6 +37,6 @@ jobs: pages: write id-token: write - uses: darbiadev/.github/.github/workflows/github-pages-python-sphinx.yaml@9160d4ddd590c15fe8a1f6d1704bf8806969d2b6 # v4.0.1 + uses: darbiadev/.github/.github/workflows/github-pages-python-sphinx.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 with: python-version: "3.11" diff --git a/.github/workflows/sentry-release.yaml b/.github/workflows/sentry-release.yaml index cbc2104..f7c5e32 100644 --- a/.github/workflows/sentry-release.yaml +++ b/.github/workflows/sentry-release.yaml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: "Create Sentry release" - uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7 # v1.4.1 + uses: getsentry/action-release@e769183448303de84c5a06aaaddf9da7be26d6c7 # v1.7.0 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a28812b..98da483 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-case-conflict - id: check-merge-conflict @@ -13,7 +13,7 @@ repos: args: [ --fix=lf ] - id: end-of-file-fixer - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.1.4" + rev: "v0.2.0" hooks: - id: ruff - id: ruff-format diff --git a/Containerfile b/Dockerfile similarity index 100% rename from Containerfile rename to Dockerfile diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c606234..320de9c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1,6 +1,9 @@ Changelog ========= +- :release:`5.0.0 <4th February 2024>` +- :feature:`-` Reestablish versioning + - :release:`4.1.0 <25th October 2023>` - :feature:`188` Make Dragonfly embeds consistent diff --git a/docs/source/conf.py b/docs/source/conf.py index 5e2a052..936a864 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -60,9 +60,9 @@ def linkcode_resolve(domain: str, info: dict) -> str: if not info["module"]: return None - import importlib - import inspect - import types + import importlib # noqa: PLC0415 + import inspect # noqa: PLC0415 + import types # noqa: PLC0415 mod = importlib.import_module(info["module"]) diff --git a/make.ps1 b/make.ps1 index 0ac4b79..64fe589 100644 --- a/make.ps1 +++ b/make.ps1 @@ -11,7 +11,7 @@ COMMANDS install-dev install local package in editable mode update-deps update the dependencies upgrade-deps upgrade the dependencies - lint run `pre-commit` and `black` and `ruff` + lint run `pre-commit` and `ruff` test run `pytest` build-dist run `python -m build` clean delete generated content @@ -41,27 +41,28 @@ function Invoke-Install-Dev function Invoke-Update-Deps { 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 + pip-compile requirements/requirements.in + pip-compile requirements/requirements-dev.in + pip-compile requirements/requirements-tests.in + pip-compile requirements/requirements-docs.in } function Invoke-Upgrade-Deps { python -m pip install --upgrade pip-tools pre-commit pre-commit autoupdate - 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 + pip-compile --upgrade requirements/requirements.in + pip-compile --upgrade requirements/requirements-dev.in + pip-compile --upgrade requirements/requirements-tests.in + pip-compile --upgrade requirements/requirements-docs.in } function Invoke-Lint { pre-commit run --all-files - python -m black . python -m ruff --fix . + python -m ruff format . + python -m mypy --strict src/ } function Invoke-Test diff --git a/pyproject.toml b/pyproject.toml index 368011a..e6c986b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bot" -version = "4.1.0" +version = "5.0.0" dynamic = ["dependencies", "optional-dependencies"] [project.urls] @@ -19,20 +19,22 @@ 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] +preview = true +unsafe-fixes = true target-version = "py311" line-length = 120 + +[tool.ruff.lint] select = ["ALL"] ignore = [ + "CPY001", # (Missing copyright notice at top of file) "G004", # (Logging statement uses f-string) - Developer UX "S101", # (Use of `assert` detected) - This should probably be changed + "PLR6301", # (Method `x` could be a function, class method, or static method) - false positives ] -[tool.ruff.extend-per-file-ignores] +[tool.ruff.lint.extend-per-file-ignores] "docs/*" = [ "INP001", # (File `tests/*.py` is part of an implicit namespace package. Add an `__init__.py`.) - Docs are not modules ] @@ -41,5 +43,17 @@ ignore = [ "S101", # (Use of `assert` detected) - Yes, that's the point ] -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "numpy" + +[[tool.mypy.overrides]] +module = [ + "coloredlogs", + "pydis_core.*" +] +ignore_missing_imports = true + +[tool.coverage.run] +source = [ + "bot", +] diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in index 1d1a15e..4cb96ee 100644 --- a/requirements/requirements-dev.in +++ b/requirements/requirements-dev.in @@ -4,3 +4,4 @@ pip-tools pre-commit ruff +mypy diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 77eead6..c596e92 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -10,33 +10,43 @@ cfgv==3.4.0 # via pre-commit click==8.1.7 # via pip-tools -distlib==0.3.7 +distlib==0.3.8 # via virtualenv -filelock==3.12.4 +filelock==3.13.1 # via # -c requirements/requirements.txt # virtualenv -identify==2.5.30 +identify==2.5.33 # via pre-commit +mypy==1.8.0 + # via -r requirements/requirements-dev.in +mypy-extensions==1.0.0 + # via mypy nodeenv==1.8.0 # via pre-commit packaging==23.2 # via build -pip-tools==7.3.0 +pip-tools==7.4.0 # via -r requirements/requirements-dev.in -platformdirs==3.11.0 +platformdirs==4.2.0 # via virtualenv -pre-commit==3.5.0 +pre-commit==3.6.2 # via -r requirements/requirements-dev.in pyproject-hooks==1.0.0 - # via build + # via + # build + # pip-tools pyyaml==6.0.1 # via pre-commit -ruff==0.1.5 +ruff==0.3.0 # via -r requirements/requirements-dev.in -virtualenv==20.24.5 +typing-extensions==4.9.0 + # via + # -c requirements/requirements.txt + # mypy +virtualenv==20.25.0 # via pre-commit -wheel==0.41.2 +wheel==0.42.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/requirements-docs.txt b/requirements/requirements-docs.txt index 4441f91..a5e0b3b 100644 --- a/requirements/requirements-docs.txt +++ b/requirements/requirements-docs.txt @@ -2,45 +2,45 @@ # 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 +# pip-compile requirements/requirements-docs.in # -alabaster==0.7.13 +alabaster==0.7.16 # via sphinx anyascii==0.3.2 # via sphinx-autoapi -astroid==3.0.0 +astroid==3.0.2 # via sphinx-autoapi -babel==2.13.0 +babel==2.14.0 # via sphinx -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via furo -certifi==2023.7.22 +certifi==2024.2.2 # via # -c requirements/requirements.txt # requests -charset-normalizer==3.3.0 +charset-normalizer==3.3.2 # via # -c requirements/requirements.txt # requests docutils==0.20.1 # via sphinx -furo==2023.9.10 +furo==2024.1.29 # via -r requirements/requirements-docs.in -idna==3.4 +idna==3.6 # via # -c requirements/requirements.txt # requests imagesize==1.4.1 # via sphinx -jinja2==3.1.2 +jinja2==3.1.3 # via # sphinx # sphinx-autoapi -markupsafe==2.1.3 +markupsafe==2.1.5 # via jinja2 packaging==23.2 # via sphinx -pygments==2.16.1 +pygments==2.17.2 # via # furo # sphinx @@ -65,28 +65,23 @@ sphinx==7.2.6 # 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 +sphinxcontrib-applehelp==1.0.8 # via sphinx -sphinxcontrib-devhelp==1.0.5 +sphinxcontrib-devhelp==1.0.6 # via sphinx -sphinxcontrib-htmlhelp==2.0.4 +sphinxcontrib-htmlhelp==2.0.5 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.6 +sphinxcontrib-qthelp==1.0.7 # via sphinx -sphinxcontrib-serializinghtml==1.1.9 +sphinxcontrib-serializinghtml==1.1.10 # via sphinx -urllib3==2.0.6 +urllib3==2.2.0 # via # -c requirements/requirements.txt # requests diff --git a/requirements/requirements-tests.txt b/requirements/requirements-tests.txt index ee4cd90..a972890 100644 --- a/requirements/requirements-tests.txt +++ b/requirements/requirements-tests.txt @@ -2,15 +2,15 @@ # 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 +# pip-compile requirements/requirements-tests.in # iniconfig==2.0.0 # via pytest packaging==23.2 # via pytest -pluggy==1.3.0 +pluggy==1.4.0 # via pytest -pytest==7.4.3 +pytest==8.0.2 # via # -r requirements/requirements-tests.in # pytest-randomly diff --git a/requirements/requirements.txt b/requirements/requirements.txt index d1cb12a..84b6769 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -2,114 +2,108 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in +# pip-compile requirements/requirements.in # aiodns==3.1.1 # via pydis-core -aiohttp==3.8.6 +aiohttp==3.9.3 # via # -r requirements/requirements.in # discord-py aiosignal==1.3.1 # via aiohttp -annotated-types==0.5.0 +annotated-types==0.6.0 # via pydantic arrow==1.3.0 # via -r requirements/requirements.in -async-timeout==4.0.3 +attrs==23.2.0 # via aiohttp -attrs==23.1.0 - # via aiohttp -certifi==2023.7.22 +certifi==2024.2.2 # via # requests # sentry-sdk cffi==1.16.0 # via pycares -charset-normalizer==3.3.0 - # via - # aiohttp - # requests +charset-normalizer==3.3.2 + # via requests coloredlogs==15.0.1 # via -r requirements/requirements.in discord-py==2.3.2 # via # -r requirements/requirements.in # pydis-core -filelock==3.12.4 +filelock==3.13.1 # via tldextract -frozenlist==1.4.0 +frozenlist==1.4.1 # via # aiohttp # aiosignal -greenlet==3.0.0 +greenlet==3.0.3 # via sqlalchemy humanfriendly==10.0 # via coloredlogs -idna==3.4 +idna==3.6 # via # requests # tldextract # yarl -multidict==6.0.4 +multidict==6.0.5 # via # aiohttp # yarl -psycopg[binary]==3.1.12 +psycopg[binary]==3.1.18 # via -r requirements/requirements.in -psycopg-binary==3.1.12 +psycopg-binary==3.1.18 # via psycopg -pycares==4.3.0 +pycares==4.4.0 # via aiodns pycparser==2.21 # via cffi -pydantic==2.4.2 +pydantic==2.6.0 # via # pydantic-settings # pydis-core -pydantic-core==2.10.1 +pydantic-core==2.16.1 # via pydantic -pydantic-settings==2.0.3 +pydantic-settings==2.2.1 # via -r requirements/requirements.in -pydis-core==10.4.0 +pydis-core==10.7.0 # via -r requirements/requirements.in python-dateutil==2.8.2 # via arrow -python-dotenv==1.0.0 +python-dotenv==1.0.1 # via pydantic-settings -rapidfuzz==3.5.2 +rapidfuzz==3.6.1 # via -r requirements/requirements.in -regex==2023.10.3 +regex==2023.12.25 # via -r requirements/requirements.in requests==2.31.0 # via # requests-file # tldextract -requests-file==1.5.1 +requests-file==2.0.0 # via tldextract -sentry-sdk==1.34.0 +sentry-sdk==1.40.6 # via -r requirements/requirements.in six==1.16.0 - # via - # python-dateutil - # requests-file -sqlalchemy==2.0.23 + # via python-dateutil +sqlalchemy==2.0.27 # via -r requirements/requirements.in statsd==4.0.1 # via pydis-core -tldextract==5.1.0 +tldextract==5.1.1 # via -r requirements/requirements.in -types-python-dateutil==2.8.19.14 +types-python-dateutil==2.8.19.20240106 # via arrow -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # psycopg # pydantic # pydantic-core # sqlalchemy -urllib3==2.0.6 +urllib3==2.2.0 # via # requests # sentry-sdk -yarl==1.9.2 +yarl==1.9.4 # via aiohttp diff --git a/src/bot/__main__.py b/src/bot/__main__.py index 19c66b4..9e92a9e 100644 --- a/src/bot/__main__.py +++ b/src/bot/__main__.py @@ -22,21 +22,13 @@ 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_) + return commands.when_mentioned_or(*extras)(bot_, message_) # type: ignore[return-value] 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( + async with ClientSession(headers={"Content-Type": "application/json"}, timeout=ClientTimeout(total=30)) as session: + dragonfly_services = DragonflyServices( session=session, base_url=constants.Dragonfly.base_url, auth_url=constants.Dragonfly.auth_url, @@ -47,6 +39,15 @@ async def main() -> None: password=constants.Dragonfly.password, ) + 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, + dragonfly_services=dragonfly_services, + ) + await bot.start(constants.Bot.token) diff --git a/src/bot/bot.py b/src/bot/bot.py index f7c585e..9dfa825 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -10,11 +10,12 @@ from sentry_sdk import push_scope from bot import exts +from bot.dragonfly_services import DragonflyServices log = logging.getLogger(__name__) -class CommandTree(discord.app_commands.CommandTree): +class CommandTree(discord.app_commands.CommandTree): # type: ignore[type-arg] """Custom command tree that handles errors raised by commands.""" def __init__(self: Self, bot: commands.Bot) -> None: @@ -22,7 +23,7 @@ def __init__(self: Self, bot: commands.Bot) -> None: async def on_error( self: Self, - interaction: discord.Interaction, + interaction: discord.Interaction, # type: ignore[type-arg] error: discord.app_commands.AppCommandError, ) -> None: """Override the default error handler to handle custom errors.""" @@ -50,11 +51,12 @@ async def on_error( raise error -class Bot(BotBase): +class Bot(BotBase): # type: ignore[misc] """Bot implementation.""" def __init__( self: Self, + dragonfly_services: DragonflyServices, *args: tuple, **kwargs: dict, ) -> None: @@ -71,6 +73,7 @@ def __init__( **kwargs, ) + self.dragonfly_services = dragonfly_services self.all_extensions: frozenset[str] | None = None async def setup_hook(self: Self) -> None: @@ -83,7 +86,7 @@ async def setup_hook(self: Self) -> None: log.debug("load_extensions") scheduling.create_task(self.load_extensions(exts)) - async def on_error(self: Self, event: str, *args: tuple, **kwargs: dict) -> None: + async def on_error(self: Self, event: str, *args: tuple, **kwargs: dict) -> None: # type: ignore[type-arg] """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 335101f..052928e 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -7,7 +7,7 @@ """ from os import getenv -from typing import ClassVar, Self +from typing import ClassVar from pydantic import root_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -33,6 +33,18 @@ class _Miscellaneous(EnvConfig): Miscellaneous = _Miscellaneous() + +class _ThreatIntelFeed(EnvConfig, env_prefix="tif_"): + """Threat Intelligence Feed Configuration.""" + + repository: str = "pypi/pypi-observation-reports-private" + interval: int = 60 * 60 # 1 hour + access_token: str = "" + channel_id: int = 1121471544355455058 + + +ThreatIntelFeed = _ThreatIntelFeed() + FILE_LOGS = Miscellaneous.file_logs DEBUG_MODE = Miscellaneous.debug @@ -63,6 +75,8 @@ class _DragonflyConfig(EnvConfig, env_prefix="dragonfly_"): threshold: int = 8 timeout: int = 25 + reporter_url: str = "" + DragonflyConfig = _DragonflyConfig() @@ -125,7 +139,7 @@ class _Roles(EnvConfig, env_prefix="roles_"): class _Guild(EnvConfig, env_prefix="guild_"): - id: int = 1121450543462760448 # noqa: A003 - inside a class, this is fine + id: int = 1121450543462760448 moderation_roles: ClassVar[list[int]] = [Roles.moderators] @@ -171,10 +185,10 @@ class _Colours(EnvConfig, env_prefix="colours_"): gold: int = 0xE6C200 @root_validator(pre=True) - def parse_hex_values(cls: type[Self], values: dict[str, int]) -> dict[str, int]: # noqa: N805 - check this + def parse_hex_values(cls, values: dict[str, int]) -> dict[str, int]: # noqa: N805 - check this """Verify that colors are valid hex.""" for key, value in values.items(): - values[key] = int(value, 16) + values[key] = int(value, 16) # type: ignore[call-overload] return values diff --git a/src/bot/dragonfly_services.py b/src/bot/dragonfly_services.py index 300dfb8..9b3c27a 100644 --- a/src/bot/dragonfly_services.py +++ b/src/bot/dragonfly_services.py @@ -1,11 +1,13 @@ """Interacting with the Dragonfly API.""" +import dataclasses from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import Enum from typing import Any, Self from aiohttp import ClientSession +from pydantic import BaseModel class ScanStatus(Enum): @@ -17,48 +19,48 @@ class ScanStatus(Enum): FAILED = "failed" -@dataclass -class PackageScanResult: - """A package scan result.""" +class Package(BaseModel): + """Model representing a package queried from the database.""" - status: ScanStatus - inspector_url: str - queued_at: datetime + scan_id: str + name: str + version: str + status: ScanStatus | None + score: int | None + inspector_url: str | None + rules: list[str] = [] + download_urls: list[str] = [] + queued_at: datetime | None + queued_by: str | None + reported_at: datetime | None + reported_by: str | None pending_at: datetime | None + pending_by: str | 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.""" + finished_by: str | None + commit_hash: str | None + + def __str__(self) -> str: + """Return package name and version.""" return f"{self.name} {self.version}" +@dataclass +class PackageReport: + """Represents the payload sent to the report endpoint.""" + + name: str + version: str + inspector_url: str | None + additional_information: str | None + recipient: str | None + use_email: bool + + class DragonflyServices: """A class wrapping Dragonfly's API.""" - def __init__( # noqa: PLR0913 -- Maybe pass the entire constants class? + def __init__( # noqa: PLR0913,PLR0917 -- Maybe pass the entire constants class? self: Self, session: ClientSession, base_url: str, @@ -106,7 +108,7 @@ async def make_request( path: str, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, - ) -> dict: + ) -> dict: # type: ignore[type-arg] """Make a request to Dragonfly's API.""" await self._update_token() @@ -124,16 +126,16 @@ async def make_request( if json is not None: args["json"] = json - async with self.session.request(**args) as response: + async with self.session.request(**args) as response: # type: ignore[arg-type] response.raise_for_status() - return await response.json() + return await response.json() # type: ignore[no-any-return] async def get_scanned_packages( self: Self, name: str | None = None, version: str | None = None, since: datetime | None = None, - ) -> list[PackageScanResult]: + ) -> list[Package]: """Get a list of scanned packages.""" params = {} if name: @@ -143,27 +145,17 @@ async def get_scanned_packages( params["version"] = version if since: - params["since"] = int(since.timestamp()) + params["since"] = int(since.timestamp()) # type: ignore[assignment] data = await self.make_request("GET", "/package", params=params) - return [PackageScanResult.from_dict(dct) for dct in data] + return list(map(Package.model_validate, data)) - async def report_package( # noqa: PLR0913 + async def report_package( self: Self, - name: str, - version: str, - inspector_url: str | None, - additional_information: str | None, - recipient: str | None, + report: PackageReport, ) -> None: """Report a package to Dragonfly.""" - data = { - "name": name, - "version": version, - "inspector_url": inspector_url, - "additional_information": additional_information, - "recipient": recipient, - } + data = dataclasses.asdict(report) await self.make_request("POST", "/report", json=data) async def queue_package(self: Self, name: str, version: str) -> None: diff --git a/src/bot/exts/audit.py b/src/bot/exts/audit.py index 3742684..a47b635 100644 --- a/src/bot/exts/audit.py +++ b/src/bot/exts/audit.py @@ -10,7 +10,7 @@ from discord.ext import commands from bot.bot import Bot -from bot.dragonfly_services import PackageScanResult +from bot.dragonfly_services import Package class PaginatorView(ui.View): @@ -20,7 +20,7 @@ def __init__( self: Self, *, member: discord.Member | discord.User, - packages: list[PackageScanResult], + packages: list[Package], per: int = 15, ) -> None: """Initialize the paginator view.""" @@ -34,7 +34,7 @@ def __init__( self.current = 0 @ui.button(emoji="◀️") - async def previous(self: Self, interaction: discord.Interaction, _) -> None: # noqa: ANN001 -- What is this? + async def previous(self: Self, interaction: discord.Interaction, _) -> None: # type: ignore[no-untyped-def, type-arg] # noqa: ANN001 -- What is this? """Go to the previous page.""" if self.current == 0: self.current = len(self.embeds) - 1 @@ -44,7 +44,7 @@ async def previous(self: Self, interaction: discord.Interaction, _) -> None: # await interaction.response.edit_message(embed=self.embeds[self.current], view=self) @ui.button(emoji="⏹️") - async def stop(self: Self, interaction: discord.Interaction, button: ui.Button) -> None: + async def stop(self: Self, interaction: discord.Interaction, button: ui.Button) -> None: # type: ignore[override, type-arg] """Stop the paginator.""" self.previous.disabled = True button.disabled = True @@ -53,7 +53,7 @@ async def stop(self: Self, interaction: discord.Interaction, button: ui.Button) await interaction.response.edit_message(embed=self.embeds[self.current], view=self) @ui.button(emoji="▶️") - async def next(self: Self, interaction: discord.Interaction, _) -> None: # noqa: ANN001,A003 + async def next(self: Self, interaction: discord.Interaction, _) -> None: # type: ignore[no-untyped-def, type-arg] # noqa: ANN001 """Go to the next page.""" if self.current == len(self.embeds) - 1: self.current = 0 @@ -62,7 +62,7 @@ async def next(self: Self, interaction: discord.Interaction, _) -> None: # noqa await interaction.response.edit_message(embed=self.embeds[self.current], view=self) - async def interaction_check(self: Self, interaction: discord.Interaction) -> bool: + async def interaction_check(self: Self, interaction: discord.Interaction) -> bool: # type: ignore[type-arg] """Check if the interaction is from the member.""" if interaction.user == self.member: return True @@ -70,7 +70,7 @@ async def interaction_check(self: Self, interaction: discord.Interaction) -> boo await interaction.response.send_message("This paginator is not for you!", ephemeral=True) return False - def _build_embed(self: Self, packages: list[PackageScanResult], page: int, total: int) -> discord.Embed: + def _build_embed(self: Self, packages: list[Package], page: int, total: int) -> discord.Embed: """Build an embed for the given packages.""" embed = discord.Embed( title="Package Audit", @@ -97,8 +97,8 @@ def __init__( """Initialize the cog.""" self.bot = bot - @app_commands.command(name="audit", description="Randomly pick packages and display them") - async def audit(self: Self, interaction: discord.Interaction, hours: int, amount: int) -> None: + @app_commands.command(name="audit", description="Randomly pick packages and display them") # type: ignore[arg-type] + async def audit(self: Self, interaction: discord.Interaction, hours: int, amount: int) -> None: # type: ignore[type-arg] """ Recalls for scanned packages within a given time frame and amount. diff --git a/src/bot/exts/core/error_handler.py b/src/bot/exts/core/error_handler.py index e0a1203..1f22479 100644 --- a/src/bot/exts/core/error_handler.py +++ b/src/bot/exts/core/error_handler.py @@ -27,28 +27,28 @@ def __init__(self: Self, bot: Bot) -> None: self.bot = bot @staticmethod - def revert_cooldown_counter(command: commands.Command, message: Message) -> None: + def revert_cooldown_counter(command: commands.Command, message: Message) -> None: # type: ignore[type-arg] """Undoes the last cooldown counter for user-error cases.""" 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 + bucket = command._buckets.get_bucket(message) # type: ignore[arg-type] # noqa: SLF001 -- Underscored attribute + bucket._tokens = min(bucket.rate, bucket._tokens + 1) # type: ignore[union-attr] # noqa: SLF001 -- Underscored attribute logging.debug("Cooldown counter reverted as the command was not used correctly.") @staticmethod - def error_embed(message: str, title: Iterable | str = NEGATIVE_REPLIES) -> Embed: + def error_embed(message: str, title: Iterable | str = NEGATIVE_REPLIES) -> Embed: # type: ignore[type-arg] """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) # noqa: S311 -- wat + embed.title = random.choice(title) # type: ignore[arg-type] # noqa: S311 -- wat embed.description = message return embed @commands.Cog.listener() async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? self: Self, - ctx: commands.Context, + ctx: commands.Context, # type: ignore[type-arg] error: commands.CommandError, ) -> None: """Activates when a command raises an error.""" @@ -70,12 +70,12 @@ async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? ) if isinstance(error, commands.CommandNotFound): - await self.send_command_suggestion(ctx, ctx.invoked_with) + await self.send_command_suggestion(ctx, ctx.invoked_with) # type: ignore[arg-type] return if isinstance(error, commands.UserInputError): - self.revert_cooldown_counter(ctx.command, ctx.message) - usage = f"```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" + self.revert_cooldown_counter(ctx.command, ctx.message) # type: ignore[arg-type] + usage = f"```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" # type: ignore[union-attr] embed = self.error_embed(f"Your input was invalid: {error}\n\nUsage:{usage}") await ctx.send(embed=embed) return @@ -98,9 +98,9 @@ async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? return if isinstance(error, commands.BadArgument): - self.revert_cooldown_counter(ctx.command, ctx.message) + self.revert_cooldown_counter(ctx.command, ctx.message) # type: ignore[arg-type] embed = self.error_embed( - "The argument you provided was invalid: " + "The argument you provided was invalid: " # type: ignore[union-attr] f"{error}\n\nUsage:\n```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```", ) await ctx.send(embed=embed) @@ -113,7 +113,7 @@ async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? if isinstance(error, APIError): await ctx.send( embed=self.error_embed( - f"There was an error when communicating with the {error.api}", + f"There was an error when communicating with the {error.api}", # type: ignore[attr-defined] NEGATIVE_REPLIES, ), ) @@ -121,7 +121,7 @@ async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? if isinstance(error, MovedCommandError): description = ( - f"This command, `{ctx.prefix}{ctx.command.qualified_name}` has moved to `{error.new_command_name}`.\n" + f"This command, `{ctx.prefix}{ctx.command.qualified_name}` has moved to `{error.new_command_name}`.\n" # type: ignore[attr-defined, union-attr] f"Please use `{error.new_command_name}` instead." ) await ctx.send(embed=self.error_embed(description, NEGATIVE_REPLIES)) @@ -130,7 +130,7 @@ async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? with push_scope() as scope: scope.user = {"id": ctx.author.id, "username": str(ctx.author)} - scope.set_tag("command", ctx.command.qualified_name) + scope.set_tag("command", ctx.command.qualified_name) # type: ignore[union-attr] scope.set_tag("message_id", ctx.message.id) scope.set_tag("channel_id", ctx.channel.id) @@ -141,7 +141,7 @@ async def on_command_error( # noqa: C901,PLR0911 -- Probably refactor this? log.exception(f"Unhandled command error: {error!s}", exc_info=error) - async def send_command_suggestion(self: Self, ctx: commands.Context, command_name: str) -> None: + async def send_command_suggestion(self: Self, ctx: commands.Context, command_name: str) -> None: # type: ignore[type-arg] """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): diff --git a/src/bot/exts/core/log.py b/src/bot/exts/core/log.py index bac191d..d9922c9 100644 --- a/src/bot/exts/core/log.py +++ b/src/bot/exts/core/log.py @@ -19,7 +19,7 @@ class Log(Cog): def __init__(self: Self, bot: Bot) -> None: self.bot = bot - async def send_log_message( # noqa: PLR0913 -- Maybe refactor this? + async def send_log_message( # noqa: PLR0913,PLR0917 -- Maybe refactor this? self: Self, icon_url: str | None, colour: discord.Colour | int, @@ -33,7 +33,7 @@ async def send_log_message( # noqa: PLR0913 -- Maybe refactor this? additional_embeds: list[discord.Embed] | None = None, timestamp_override: datetime | None = None, footer: str | None = None, - ) -> Context: + ) -> Context: # type: ignore[type-arg] """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) # noqa: PLR2004 @@ -41,7 +41,7 @@ async def send_log_message( # noqa: PLR0913 -- Maybe refactor this? if title and icon_url: embed.set_author(name=title, icon_url=icon_url) - embed.colour = colour + embed.colour = colour # type: ignore[assignment] embed.timestamp = timestamp_override or datetime.now(tz=UTC) if footer: @@ -64,7 +64,7 @@ async def send_log_message( # noqa: PLR0913 -- Maybe refactor this? for additional_embed in additional_embeds: await channel.send(embed=additional_embed) - return await self.bot.get_context(log_message) # Optionally return for use with antispam + return await self.bot.get_context(log_message) # type: ignore[no-any-return] # Optionally return for use with antispam async def setup(bot: Bot) -> None: diff --git a/src/bot/exts/core/ping.py b/src/bot/exts/core/ping.py index fb291c3..fb84e9d 100644 --- a/src/bot/exts/core/ping.py +++ b/src/bot/exts/core/ping.py @@ -16,7 +16,7 @@ def __init__(self: Self, bot: Bot) -> None: self.bot = bot @commands.command(name="ping") - async def ping(self: Self, ctx: commands.Context) -> None: + async def ping(self: Self, ctx: commands.Context) -> None: # type: ignore[type-arg] """Ping the bot to see its latency and state.""" embed = Embed( title=":ping_pong: Pong!", diff --git a/src/bot/exts/core/sync.py b/src/bot/exts/core/sync.py index 06edb05..b615f02 100644 --- a/src/bot/exts/core/sync.py +++ b/src/bot/exts/core/sync.py @@ -35,11 +35,11 @@ async def _sync_commands(self: Self) -> list[AppCommand]: ", ".join(command.name for command in synced_commands), ) - return synced_commands + return synced_commands # type: ignore[no-any-return] @commands.command(name="sync") @commands.has_permissions(administrator=True) - async def sync_prefix(self: Self, ctx: commands.Context) -> None: + async def sync_prefix(self: Self, ctx: commands.Context) -> None: # type: ignore[type-arg] """Prefix command that syncs all application commands.""" synced_commands = await self._sync_commands() @@ -47,9 +47,9 @@ async def sync_prefix(self: Self, ctx: commands.Context) -> None: 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.command(name="sync", description="Sync all application commands") # type: ignore[arg-type] @discord.app_commands.checks.has_permissions(administrator=True) - async def sync_slash(self: Self, interaction: discord.Interaction) -> None: + async def sync_slash(self: Self, interaction: discord.Interaction) -> None: # type: ignore[type-arg] """Slash command that syncs all application commands.""" synced_commands = await self._sync_commands() diff --git a/src/bot/exts/dragonfly/dragonfly.py b/src/bot/exts/dragonfly/dragonfly.py index 5e883e6..6e3f2d0 100644 --- a/src/bot/exts/dragonfly/dragonfly.py +++ b/src/bot/exts/dragonfly/dragonfly.py @@ -10,19 +10,84 @@ import discord import sentry_sdk from discord.ext import commands, tasks +from discord.utils import format_dt from bot.bot import Bot from bot.constants import Channels, DragonflyConfig, Roles -from bot.dragonfly_services import PackageScanResult +from bot.dragonfly_services import DragonflyServices, Package, PackageReport log = getLogger(__name__) log.setLevel(logging.INFO) -class ConfirmReportModal(discord.ui.Modal): +def _build_modal_title(name: str, version: str) -> str: + """Build the modal title.""" + title = f"Confirm report for {name} v{version}" + if len(title) >= 45: # noqa: PLR2004 + title = title[:42] + "..." + + return title + + +def _build_package_report_log_embed( + *, + member: discord.User | discord.Member, + package_name: str, + package_version: str, + description: str | None, + inspector_url: str, +) -> discord.Embed: + embed = discord.Embed( + title=f"Package reported: {package_name} v{package_version}", + color=discord.Colour.red(), + description=description or "*No description provided*", + timestamp=datetime.now(tz=UTC), + ) + + embed.set_author(name=member.name, icon_url=member.display_avatar.url) + embed.add_field(name="Reported by", value=member.mention) + embed.add_field(name="Inspector URL", value=f"[Inspector URL]({inspector_url})") + + return embed + + +async def handle_submit( + *, + report: PackageReport, + interaction: discord.Interaction, + dragonfly_services: DragonflyServices, +) -> None: + """Handle modal submit.""" + log.info( + "User %s reported package %s@%s with additional_information '%s' and inspector_url '%s'", + interaction.user, + report.name, + report.version, + report.additional_information, + report.inspector_url, + ) + + log_channel = interaction.client.get_channel(Channels.reporting) + if isinstance(log_channel, discord.abc.Messageable): + embed = _build_package_report_log_embed( + member=interaction.user, + package_name=report.name, + package_version=report.version, + description=report.additional_information, + inspector_url=report.inspector_url or "", + ) + + await log_channel.send(embed=embed) + + await dragonfly_services.report_package(report) + + await interaction.response.send_message("Reported!", ephemeral=True) + + +class ConfirmEmailReportModal(discord.ui.Modal): """Modal for confirming a report.""" - recipient = discord.ui.TextInput( + recipient = discord.ui.TextInput( # type: ignore[var-annotated] label="Recipient", placeholder="Recipient's Email Address", required=False, @@ -30,96 +95,164 @@ class ConfirmReportModal(discord.ui.Modal): style=discord.TextStyle.short, ) - additional_information = discord.ui.TextInput( + additional_information = discord.ui.TextInput( # type: ignore[var-annotated] label="Additional information", placeholder="Additional information", required=False, style=discord.TextStyle.long, ) - inspector_url = discord.ui.TextInput( + inspector_url = discord.ui.TextInput( # type: ignore[var-annotated] label="Inspector URL", placeholder="Inspector URL", required=False, style=discord.TextStyle.short, ) - def __init__(self: Self, *, package: PackageScanResult, bot: Bot) -> None: + def __init__(self: Self, *, package: Package, bot: Bot) -> None: """Initialize the modal.""" self.package = package self.bot = bot # set dynamic properties here because we can't set dynamic class attributes - self.title = self._build_modal_title() + self.title = _build_modal_title(package.name, package.version) self.inspector_url.default = package.inspector_url super().__init__() - async def on_error(self: Self, interaction: discord.Interaction, error: Exception) -> None: + async def on_error(self: Self, interaction: discord.Interaction, error: Exception) -> None: # type: ignore[override, type-arg] """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) + message = ( + f"Error from upstream: {error.status}\n" + f"```{error.message}```\n" + f"Retry using Observation API instead?" + ) + view = ReportMethodSwitchConfirmationView(previous_modal=self) + return await interaction.response.send_message(message, view=view, ephemeral=True) await interaction.response.send_message("An unexpected error occured.", ephemeral=True) raise error - 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: # noqa: PLR2004 - title = title[:42] + "..." + async def on_submit(self: Self, interaction: discord.Interaction) -> None: + """Modal submit callback.""" + report = PackageReport( + name=self.package.name, + version=self.package.version, + inspector_url=self.inspector_url.value or None, + additional_information=self.additional_information.value or None, + recipient=self.recipient.value or None, + use_email=True, + ) + + await handle_submit(report=report, interaction=interaction, dragonfly_services=self.bot.dragonfly_services) + + +class ConfirmReportModal(discord.ui.Modal): + """Modal for confirming a report through the Observations API.""" + + additional_information = discord.ui.TextInput( + label="Additional information", + placeholder="Additional information", + required=True, + style=discord.TextStyle.long, + ) + + inspector_url = discord.ui.TextInput( + label="Inspector URL", + placeholder="Inspector URL", + required=False, + style=discord.TextStyle.short, + ) + + def __init__(self: Self, *, package: Package, bot: Bot) -> None: + """Initialize the modal.""" + self.package = package + self.bot = bot + + # set dynamic properties here because we can't set dynamic class attributes + self.title = _build_modal_title(package.name, package.version) + self.inspector_url.default = package.inspector_url + self.recipient = None + + super().__init__() - return title + async def on_error(self: Self, interaction: discord.Interaction, error: Exception) -> None: + """Handle errors that occur in the modal.""" + if isinstance(error, aiohttp.ClientResponseError): + message = f"Error from upstream: {error.status}\n```{error.message}```\nRetry using email instead?" + view = ReportMethodSwitchConfirmationView(previous_modal=self) + return await interaction.response.send_message(message, view=view, ephemeral=True) + + await interaction.response.send_message("An unexpected error occured.", ephemeral=True) + raise error 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 - - log.info( - "User %s reported package %s@%s with additional_information '%s' and inspector_url '%s'", - interaction.user, - self.package.name, - self.package.version, - additional_information_override, - inspector_url_override, + """Modal submit callback.""" + report = PackageReport( + name=self.package.name, + version=self.package.version, + inspector_url=self.inspector_url.value or None, + additional_information=self.additional_information.value, + recipient=None, + use_email=False, ) - log_channel = interaction.client.get_channel(Channels.reporting) - if isinstance(log_channel, discord.abc.Messageable): - await log_channel.send( - 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}`", - ) + await handle_submit(report=report, interaction=interaction, dragonfly_services=self.bot.dragonfly_services) - try: - await self.bot.dragonfly_services.report_package( - name=self.package.name, - version=self.package.version, - inspector_url=inspector_url_override, - additional_information=additional_information_override, - recipient=self.recipient.value, - ) - await interaction.response.send_message("Reported!", ephemeral=True) - except: - await interaction.response.send_message("An unexpected error occured!", ephemeral=True) - raise +class ReportMethodSwitchConfirmationView(discord.ui.View): + """Prompt user if they want to switch reporting methods (email/API). + + View sent when reporting via the Observation API fails, and we want to ask the + user if they want to switch to another method of sending reports. + """ + + def __init__(self: Self, previous_modal: ConfirmReportModal | ConfirmEmailReportModal) -> None: + super().__init__() + self.previous_modal = previous_modal + self.package = previous_modal.package + self.bot = previous_modal.bot + + @discord.ui.button(label="Yes", style=discord.ButtonStyle.green) + async def confirm(self: Self, interaction: discord.Interaction, _button: discord.ui.Button) -> None: + """Confirm button callback.""" + if isinstance(self.previous_modal, ConfirmReportModal): + modal = ConfirmEmailReportModal(package=self.package, bot=self.bot) + else: + modal = ConfirmReportModal(package=self.package, bot=self.bot) + + await interaction.response.send_modal(modal) + + self.disable_all() + await interaction.edit_original_response(view=self) + + @discord.ui.button(label="No, retry the operation", style=discord.ButtonStyle.red) + async def cancel(self: Self, interaction: discord.Interaction, _button: discord.ui.Button) -> None: + """Cancel button callback.""" + modal = type(self.previous_modal)(package=self.package, bot=self.bot) + + await interaction.response.send_modal(modal) + + self.disable_all() + await interaction.edit_original_response(view=self) + + def disable_all(self: Self) -> None: + """Disable both confirm and cancel buttons.""" + self.confirm.disabled = True + self.cancel.disabled = True class ReportView(discord.ui.View): """Report view.""" - def __init__(self: Self, bot: Bot, payload: PackageScanResult) -> None: + def __init__(self: Self, bot: Bot, payload: Package) -> None: self.bot = bot self.payload = payload super().__init__(timeout=None) @discord.ui.button(label="Report", style=discord.ButtonStyle.red) - async def report(self: Self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + async def report(self: Self, interaction: discord.Interaction, button: discord.ui.Button) -> None: # type: ignore[type-arg] """Report a package.""" modal = ConfirmReportModal(package=self.payload, bot=self.bot) await interaction.response.send_modal(modal) @@ -130,22 +263,219 @@ async def report(self: Self, interaction: discord.Interaction, button: discord.u await interaction.edit_original_response(view=self) -def _build_package_scan_result_embed(scan_result: PackageScanResult) -> discord.Embed: +class NoteModal(discord.ui.Modal, title="Add a note"): + """A modal that allows users to add a note to a package.""" + + _interaction: discord.Interaction | None = None + note_content = discord.ui.TextInput( + label="Content", + placeholder="Enter the note content here", + min_length=1, + max_length=1000, # Don't want to overfill the embed + ) + + def __init__(self, embed: discord.Embed, view: discord.ui.View) -> None: + super().__init__() + + self.embed = embed + self.view = view + + async def on_submit(self, interaction: discord.Interaction) -> None: + """Modal submit callback.""" + if not interaction.response.is_done(): + await interaction.response.defer() + self._interaction = interaction + + content = f"{self.note_content.value} • {interaction.user.mention}" + + # We need to check what fields the embed has to determine where to add the note + # If the embed has no fields, we add the note and return + # Otherwise, we need to make sure the note is added after the event log + # This involves clearing the fields and re-adding them in the correct order + # Which is why we save the event log in a variable + + match len(self.embed.fields): + case 0: # Package is awaiting triage, no notes or event log + notes = [content] + event_log = None + case 1: # Package either has notes or event log + if self.embed.fields[0].name == "Notes": + notes = [self.embed.fields[0].value, content] + else: + event_log = self.embed.fields[0].value + notes = [content] + self.embed.clear_fields() + case 2: # Package has both notes and event log + if self.embed.fields[0].name == "Notes": + notes = [self.embed.fields[0].value, content] + event_log = self.embed.fields[1].value + else: + notes = [self.embed.fields[1].value, content] + event_log = self.embed.fields[0].value + self.embed.clear_fields() + + self.embed.add_field(name="Notes", value="\n".join(notes), inline=False) + + if event_log: + self.embed.add_field(name="Event log", value=event_log, inline=False) + + await interaction.message.edit(embed=self.embed, view=self.view) + + async def on_error( + self, + interaction: discord.Interaction, + error: Exception, + ) -> None: + """Handle errors that occur in the modal.""" + await interaction.response.send_message( + "An unexpected error occured.", + ephemeral=True, + ) + raise error + + +class MalwareView(discord.ui.View): + """View for the malware triage system.""" + + message: discord.Message | None = None + + def __init__( + self: Self, + embed: discord.Embed, + bot: Bot, + payload: Package, + ) -> None: + self.embed = embed + self.bot = bot + self.payload = payload + self.event_log = [] + + super().__init__() + + async def add_event(self, message: str) -> None: + """Add an event to the event log.""" + # Much like earlier, we need to check the fields of the embed to determine where to add the event log + match len(self.embed.fields): + case 0: + pass + case 1: + if self.embed.fields[0].name == "Event log": + self.embed.clear_fields() + case 2: + if self.embed.fields[0].name == "Event log": + self.embed.clear_fields() + elif self.embed.fields[1].name == "Event log": + self.embed.remove_field(1) + + self.event_log.append( + message, + ) # For future reference, we save the event log in a variable + self.embed.add_field( + name="Event log", + value="\n".join(self.event_log), + inline=False, + ) + + async def update_status(self, status: str) -> None: + """Update the status of the package in the embed.""" + self.embed.set_footer(text=status) + + def get_timestamp(self) -> str: + """Return the current timestamp, formatted in Discord's relative style.""" + return format_dt(datetime.now(UTC), style="R") + + @discord.ui.button( + label="Report", + style=discord.ButtonStyle.red, + ) + async def report( + self, + interaction: discord.Interaction, + button: discord.ui.Button, + ) -> None: + """Report package and update the embed.""" + self.approve.disabled = False + await self.add_event( + f"Reported by {interaction.user.mention} • {self.get_timestamp()}", + ) + await self.update_status("Flagged as malicious") + + self.embed.color = discord.Color.red() + + modal = ConfirmReportModal(package=self.payload, bot=self.bot) + await interaction.response.send_modal(modal) + + timed_out = await modal.wait() + if not timed_out: + button.disabled = True + await interaction.edit_original_response(view=self, embed=self.embed) + + @discord.ui.button( + label="Approve", + style=discord.ButtonStyle.green, + ) + async def approve( + self, + interaction: discord.Interaction, + button: discord.ui.Button, + ) -> None: + """Approve package and update the embed.""" + self.report.disabled = False + await self.add_event( + f"Approved by {interaction.user.mention} • {self.get_timestamp()}", + ) + await self.update_status("Flagged as benign") + + button.disabled = True + + self.embed.color = discord.Color.green() + await interaction.response.edit_message(view=self, embed=self.embed) + + @discord.ui.button( + label="Add note", + style=discord.ButtonStyle.grey, + ) + async def add_note( + self, + interaction: discord.Interaction, + _button: discord.ui.Button, + ) -> None: + """Add note to the embed.""" + await interaction.response.send_modal(NoteModal(embed=self.embed, view=self)) + + async def on_error( + self, + interaction: discord.Interaction[discord.Client], + error: Exception, + _item: discord.ui.Item, + ) -> None: + """Handle errors that occur in the view.""" + await interaction.response.send_message( + "An unexpected error occured.", + ephemeral=True, + ) + raise error + + +def _build_package_scan_result_embed(scan_result: Package) -> discord.Embed: """Build the embed that shows the results of a package scan.""" + condition = (scan_result.score or 0) >= DragonflyConfig.threshold + title, color = ("Malicious", 0xF70606) if condition else ("Benign", 0x4CBB17) + embed = discord.Embed( - title=f"Malicious package found: {scan_result.name} @ {scan_result.version}", + title=f"{title} package found: {scan_result.name} @ {scan_result.version}", description=f"```YARA rules matched: {', '.join(scan_result.rules) or 'None'}```", - color=0xF70606, + color=color, ) embed.add_field( - name="\u200B", + name="\u200b", value=f"[Inspector]({scan_result.inspector_url})", inline=True, ) embed.add_field( - name="\u200B", + name="\u200b", value=f"[PyPI](https://pypi.org/project/{scan_result.name}/{scan_result.version})", inline=True, ) @@ -153,7 +483,28 @@ def _build_package_scan_result_embed(scan_result: PackageScanResult) -> discord. return embed -def _build_all_packages_scanned_embed(scan_results: list[PackageScanResult]) -> discord.Embed: +def _build_package_scan_result_triage_embed( + scan_result: Package, +) -> discord.Embed: + """Build the embed for the malware triage system.""" + embed = discord.Embed( + title="View on Inspector", + description="\n".join(scan_result.rules), + url=scan_result.inspector_url, + color=discord.Color.orange(), + timestamp=datetime.now(UTC), + ) + embed.set_author( + name=f"{scan_result.name}@{scan_result.version}", + url=f"https://pypi.org/project/{scan_result.name}/{scan_result.version}", + icon_url="https://seeklogo.com/images/P/pypi-logo-5B953CE804-seeklogo.com.png", + ) + embed.set_footer(text="Awaiting triage") + + return embed + + +def _build_all_packages_scanned_embed(scan_results: list[Package]) -> discord.Embed: """Build the embed that shows a list of all packages scanned.""" if scan_results: description = "\n".join(map(str, scan_results)) @@ -173,11 +524,13 @@ async def run( 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( + embed = _build_package_scan_result_triage_embed(result) + view = MalwareView(embed=embed, bot=bot, payload=result) + + view.message = await alerts_channel.send( f"<@&{DragonflyConfig.alerts_role_id}>", embed=embed, - view=ReportView(bot, result), + view=view, ) await logs_channel.send(embed=_build_all_packages_scanned_embed(scan_results)) @@ -193,6 +546,15 @@ def __init__(self: Self, bot: Bot) -> None: self.since = datetime.now(tz=UTC) - timedelta(seconds=DragonflyConfig.interval) super().__init__() + @commands.hybrid_command(name="username") # type: ignore [arg-type] + async def get_username_command(self, ctx: commands.Context[Bot]) -> None: + """Get the username of the currently logged in user to the PyPI Observation API.""" + async with ctx.bot.http_session.get(DragonflyConfig.reporter_url + "/echo") as res: + json = await res.json() + username = json["username"] + + await ctx.send(username) + @tasks.loop(seconds=DragonflyConfig.interval) async def scan_loop(self: Self) -> None: """Loop that runs the scan task.""" @@ -252,7 +614,7 @@ async def queue(self: Self, ctx: commands.Context, name: str, version: str) -> N @commands.has_role(Roles.vipyr_security) @commands.command() - async def start(self: Self, ctx: commands.Context) -> None: + async def start(self: Self, ctx: commands.Context) -> None: # type: ignore[type-arg] """Start the scan task.""" if self.scan_loop.is_running(): await ctx.send("Task is already running") @@ -262,7 +624,7 @@ async def start(self: Self, ctx: commands.Context) -> None: @commands.has_role(Roles.vipyr_security) @commands.command() - async def stop(self: Self, ctx: commands.Context, force: bool = False) -> None: # noqa: FBT001,FBT002 + async def stop(self: Self, ctx: commands.Context, force: bool = False) -> None: # type: ignore[type-arg] # noqa: FBT001,FBT002 """Stop the scan task.""" if self.scan_loop.is_running(): if force: @@ -274,9 +636,9 @@ async def stop(self: Self, ctx: commands.Context, force: bool = False) -> None: else: await ctx.send("Task is not running") - @discord.app_commands.checks.has_role(Roles.vipyr_security) + @discord.app_commands.checks.has_role(Roles.vipyr_security) # type: ignore[arg-type] @discord.app_commands.command(name="lookup", description="Scans a package") - async def lookup(self: Self, interaction: discord.Interaction, name: str, version: str | None = None) -> None: + async def lookup(self: Self, interaction: discord.Interaction, name: str, version: str | None = None) -> None: # type: ignore[type-arg] """Pull the scan results for a package.""" scan_results = await self.bot.dragonfly_services.get_scanned_packages(name=name, version=version) if scan_results: @@ -286,18 +648,18 @@ async def lookup(self: Self, interaction: discord.Interaction, name: str, versio await interaction.response.send_message("No entries were found with the specified filters.") @commands.group() - async def threshold(self: Self, ctx: commands.Context) -> None: + async def threshold(self: Self, ctx: commands.Context) -> None: # type: ignore[type-arg] """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: Self, ctx: commands.Context) -> None: + @threshold.command() # type: ignore[arg-type] + async def get(self: Self, ctx: commands.Context) -> None: # type: ignore[type-arg] """Get the score threshold.""" await ctx.send(f"The current threshold is set to `{self.score_threshold}`") - @threshold.command() - async def set(self: Self, ctx: commands.Context, value: int) -> None: # noqa: A003 + @threshold.command() # type: ignore[arg-type] + async def set(self: Self, ctx: commands.Context, value: int) -> None: # type: ignore[type-arg] """Set the score threshold.""" self.score_threshold = value await ctx.send(f"The current threshold has been set to `{value}`") diff --git a/src/bot/exts/dragonfly/threat_intel_feed.py b/src/bot/exts/dragonfly/threat_intel_feed.py new file mode 100644 index 0000000..9f44d7f --- /dev/null +++ b/src/bot/exts/dragonfly/threat_intel_feed.py @@ -0,0 +1,166 @@ +"""Threat Intelligence Feed Cog.""" + +import json +import logging +import re +from io import BytesIO +from logging import getLogger +from typing import Any +from zipfile import ZipFile + +import aiohttp +import discord +from discord.ext import commands, tasks + +from bot import constants +from bot.bot import Bot +from bot.dragonfly_services import Package + +log = getLogger(__name__) +log.setLevel(logging.INFO) + +_p = re.compile(r"https://inspector.pypi.io/project/(?P\w+)/(?P[\w.]+)/.*") + + +def build_github_link_from_path(path: str) -> str: + """Build a GitHub link to the given path.""" + segments = path.split("/") + path = "/".join(segments[1:]) + + return f"https://github.com/{constants.ThreatIntelFeed.repository}/blob/main/{path}" + + +def parse_package_info_from_inspector_url(inspector_url: str) -> tuple[str, str] | None: + """Return a tuple of package name and version, parsed from the inspector URL. None if it couldn't be parsed.""" + if g := _p.match(inspector_url): + name = g.group("name") + version = g.group("version") + + return name, version + + return None + + +def search(d: dict, key: Any) -> Any | None: # noqa: ANN401 - we can't know the type of the dict ahead of time + """Recursively search for the first occurence of a key in a dict. None if not found.""" + for k, v in d.items(): + if k == key: + return v + + if isinstance(v, dict) and (val := search(v, key)): + return val + + return None + + +def build_embed(package: Package, path: str, inspector_url: str) -> discord.Embed: + """Return the embed to be sent in the threat intelligence feed.""" + if package.reported_at: + ts = discord.utils.format_dt(package.reported_at, style="F") + description = f"We already reported this package at {ts}" + color = discord.Color.green() + else: + description = f"We didn't catch this package! Here are our matched rules: ```{', '.join(package.rules)}```" + color = discord.Colour.red() + + embed = discord.Embed( + title=f"New Report: {package.name} v{package.version}", + description=description, + color=color, + url=build_github_link_from_path(path), + ) + + embed.add_field(name="Inspector URL", value=f"[Inspector URL]({inspector_url})") + + return embed + + +def build_package_not_found_embed(name: str, version: str, path: str) -> discord.Embed: + """Return the embed for when a report was filed for a package which we don't have records for.""" + return discord.Embed( + title="Package not found!", + description=( + f"A report was filed for {name} v{version}, " + "however we don't have any records for this package in our database. " + "This means that we are missing packages, please investigate this!" + ), + color=discord.Color.red(), + url=build_github_link_from_path(path), + ) + + +async def fetch_zipfile(http_client: aiohttp.ClientSession) -> ZipFile: + """Download the source zipfile from GitHub for the feed source repository.""" + url = f"https://api.github.com/repos/{constants.ThreatIntelFeed.repository}/zipball" + headers = {"Authorization": f"Bearer {constants.ThreatIntelFeed.access_token}"} + + async with http_client.get(url, headers=headers) as res: + res.raise_for_status() + b = await res.content.read() + + buffer = BytesIO(b) + return ZipFile(buffer) + + +class ThreatIntelFeed(commands.Cog): + """Threat Intelligence Feed Cog.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.reports_seen: set[str] = set() + + @tasks.loop(seconds=constants.ThreatIntelFeed.interval) + async def watcher(self) -> None: + """Watch the GitHub repository for changes.""" + zipfile = await fetch_zipfile(self.bot.http_session) + paths = {path for path in zipfile.namelist() if path.endswith(".json")} + + channel = self.bot.get_channel(constants.ThreatIntelFeed.channel_id) + if not isinstance(channel, discord.abc.Messageable): + log.error("Threat intel feed channel is not messageable") + return + + # The first time around, just add all the reports to our "seen reports" set + if len(self.reports_seen) == 0: + self.reports_seen |= paths + return + + for path in paths: + if path in self.reports_seen: + continue + + content = json.loads(zipfile.read(path).decode()) + inspector_url: str | None = search(content, "inspector_url") + if not inspector_url: + log.error("Inspector URL not found in %s, skipping", path) + continue + + match parse_package_info_from_inspector_url(inspector_url): + case name, version: + results = await self.bot.dragonfly_services.get_scanned_packages(name=name, version=version) + package = results[0] if results else None + + if package: + embed = build_embed(package, path, inspector_url) + else: + embed = build_package_not_found_embed(name, version, path) + + await channel.send(embed=embed) + + case None: + log.error('Unable to parse inspector URL: "%s" in %s, skipping', inspector_url, path) + continue + + @watcher.before_loop + async def before_watcher(self) -> None: + """Before first task run hook.""" + await self.bot.wait_until_ready() + + +async def setup(bot: Bot) -> None: + """Extension setup.""" + cog = ThreatIntelFeed(bot) + task = cog.watcher + if not task.is_running: + task.start() + await bot.add_cog(cog) diff --git a/src/bot/exts/utilities/internal.py b/src/bot/exts/utilities/internal.py index ac45393..1797771 100644 --- a/src/bot/exts/utilities/internal.py +++ b/src/bot/exts/utilities/internal.py @@ -32,13 +32,13 @@ class Internal(Cog): def __init__(self: Self, bot: Bot) -> None: self.bot = bot - self.env = {} + self.env = {} # type: ignore[var-annotated] self.ln = 0 self.stdout = StringIO() self.socket_since = arrow.utcnow() self.socket_event_total = 0 - self.socket_events = Counter() + self.socket_events = Counter() # type: ignore[var-annotated] if DEBUG_MODE: self.eval.add_check(is_owner().predicate) @@ -112,7 +112,7 @@ def _format(self: Self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: if isinstance(out, discord.Embed): # We made an embed? Send that as embed res += "" - res = (res, out) + res = (res, out) # type: ignore[assignment] else: if isinstance(out, str) and out.startswith("Traceback (most recent call last):\n"): @@ -137,11 +137,11 @@ def _format(self: Self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: # Add the output res += pretty - res = (res, None) + res = (res, None) # type: ignore[assignment] - return res # Return (text, embed) + return res # type: ignore[return-value] # Return (text, embed) - async def _eval(self: Self, ctx: Context, code: str) -> discord.Message | None: + async def _eval(self: Self, ctx: Context, code: str) -> discord.Message | None: # type: ignore[type-arg] """Eval the input code string & send an embed to the invoking context.""" self.ln += 1 @@ -210,22 +210,22 @@ async def func(): # (None,) -> Any else: paste_text = f"full contents at {paste_link}" - await ctx.send(f"```py\n{out[:truncate_index]}\n```... response truncated; {paste_text}", embed=embed) + await ctx.send(f"```py\n{out[:truncate_index]}\n```... response truncated; {paste_text}", embed=embed) # type: ignore[arg-type] return None - await ctx.send(f"```py\n{out}```", embed=embed) + await ctx.send(f"```py\n{out}```", embed=embed) # type: ignore[arg-type] return None @group(name="internal", aliases=("int",)) @has_any_role(Roles.administrators, Roles.core_developers) - async def internal_group(self: Self, ctx: Context) -> None: + async def internal_group(self: Self, ctx: Context) -> None: # type: ignore[type-arg] """Internal commands. Top secret!.""" # noqa: D401 if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) - @internal_group.command(name="eval", aliases=("e",)) + @internal_group.command(name="eval", aliases=("e",)) # type: ignore[arg-type] @has_any_role(Roles.administrators) - async def eval(self: Self, ctx: Context, *, code: str) -> None: # noqa: A003 + async def eval(self: Self, ctx: Context, *, code: str) -> None: # type: ignore[type-arg] """Run eval in a REPL-like format.""" code = code.strip("`") if re.match("py(thon)?\n", code): @@ -235,7 +235,7 @@ async def eval(self: Self, ctx: Context, *, code: str) -> None: # noqa: A003 not re.search( # Check if it's an expression r"^(return|import|for|while|def|class|from|exit|[a-zA-Z0-9]+\s*=)", code, - re.M, + re.MULTILINE, ) and len(code.split("\n")) == 1 ): @@ -243,9 +243,9 @@ async def eval(self: Self, ctx: Context, *, code: str) -> None: # noqa: A003 await self._eval(ctx, code) - @internal_group.command(name="socketstats", aliases=("socket", "stats")) + @internal_group.command(name="socketstats", aliases=("socket", "stats")) # type: ignore[arg-type] @has_any_role(Roles.administrators, Roles.core_developers) - async def socketstats(self: Self, ctx: Context) -> None: + async def socketstats(self: Self, ctx: Context) -> None: # type: ignore[type-arg] """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 ecd0509..430faaf 100644 --- a/src/bot/log.py +++ b/src/bot/log.py @@ -19,10 +19,10 @@ LoggerClass = Logger if TYPE_CHECKING else logging.getLoggerClass() -class CustomLogger(LoggerClass): +class CustomLogger(LoggerClass): # type: ignore[misc, valid-type] """Custom implementation of the `Logger` class with an added `trace` method.""" - def trace(self: Self, msg: str, *args: tuple, **kwargs: dict) -> None: + def trace(self: Self, msg: str, *args: tuple, **kwargs: dict) -> None: # type: ignore[type-arg] """ Log 'msg % args' with severity 'TRACE'. @@ -42,7 +42,7 @@ def get_logger(name: str | None = None) -> CustomLogger: def setup() -> None: """Set up loggers.""" - logging.TRACE = TRACE_LEVEL + logging.TRACE = TRACE_LEVEL # type: ignore[attr-defined] logging.addLevelName(TRACE_LEVEL, "TRACE") logging.setLoggerClass(CustomLogger) diff --git a/src/bot/utils/__init__.py b/src/bot/utils/__init__.py index 9bba78c..e60a4b0 100644 --- a/src/bot/utils/__init__.py +++ b/src/bot/utils/__init__.py @@ -9,10 +9,10 @@ __all__ = [ "CogABCMeta", + "PasteTooLongError", + "PasteUploadError", "find_nth_occurrence", "has_lines", "pad_base64", "send_to_paste_service", - "PasteUploadError", - "PasteTooLongError", ] diff --git a/src/bot/utils/function.py b/src/bot/utils/function.py index ce996a5..3a189b7 100644 --- a/src/bot/utils/function.py +++ b/src/bot/utils/function.py @@ -13,7 +13,7 @@ Argument = int | str BoundArgs = OrderedDict[str, Any] -Decorator = Callable[[Callable], Callable] +Decorator = Callable[[Callable], Callable] # type: ignore[type-arg] ArgValGetter = Callable[[BoundArgs], Any] @@ -36,7 +36,7 @@ def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> Any: # noqa: arg_pos = name_or_pos try: - name, value = arg_values[arg_pos] + _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 @@ -79,7 +79,7 @@ def wrapper(args: BoundArgs) -> Any: # noqa: ANN401 return decorator_func(wrapper) -def get_bound_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> BoundArgs: +def get_bound_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> BoundArgs: # type: ignore[type-arg] """ Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. @@ -96,7 +96,7 @@ def update_wrapper_globals( wrapper: types.FunctionType, wrapped: types.FunctionType, *, - ignored_conflict_names: set[str] = frozenset(), + ignored_conflict_names: set[str] = frozenset(), # type: ignore[assignment] ) -> types.FunctionType: """ Update globals of `wrapper` with the globals from `wrapped`. @@ -141,7 +141,7 @@ def command_wraps( assigned: Sequence[str] = functools.WRAPPER_ASSIGNMENTS, updated: Sequence[str] = functools.WRAPPER_UPDATES, *, - ignored_conflict_names: set[str] = frozenset(), + ignored_conflict_names: set[str] = frozenset(), # type: ignore[assignment] ) -> Callable[[types.FunctionType], types.FunctionType]: """Update the decorated function to look like `wrapped` and update globals for discordpy forwardref evaluation.""" diff --git a/src/bot/utils/helpers.py b/src/bot/utils/helpers.py index a9f78de..0d12bc3 100644 --- a/src/bot/utils/helpers.py +++ b/src/bot/utils/helpers.py @@ -28,7 +28,7 @@ def has_lines(string: str, count: int) -> bool: split = string.split("\n", count - 1) # Make sure the last part isn't empty, which would happen if there was a final newline. - return split[-1] and len(split) == count + return split[-1] and len(split) == count # type: ignore[return-value] def pad_base64(data: str) -> str: diff --git a/src/bot/utils/lock.py b/src/bot/utils/lock.py index 6eb0420..62585cf 100644 --- a/src/bot/utils/lock.py +++ b/src/bot/utils/lock.py @@ -16,7 +16,7 @@ from .exceptions import LockedResourceError log = get_logger(__name__) -__lock_dicts = defaultdict(WeakValueDictionary) +__lock_dicts = defaultdict(WeakValueDictionary) # type: ignore[var-annotated] _IdCallableReturn = Hashable | Awaitable[Hashable] _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] @@ -41,7 +41,7 @@ def __enter__(self: Self) -> None: self._active_count += 1 self._event.clear() - def __exit__(self: Self, _exc_type, _exc_val, _exc_tb) -> None: # noqa: ANN001 + def __exit__(self: Self, _exc_type, _exc_val, _exc_tb) -> None: # type: ignore[no-untyped-def] # 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: @@ -58,7 +58,7 @@ def lock( *, raise_error: bool = False, wait: bool = False, -) -> Callable: +) -> Callable: # type: ignore[type-arg] """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. @@ -80,8 +80,8 @@ def lock( def decorator(func: types.FunctionType) -> types.FunctionType: name = func.__name__ - @command_wraps(func) - async def wrapper(*args: tuple, **kwargs: dict) -> Any: # noqa: ANN401 -- matches signature of upstream + @command_wraps(func) # type: ignore[arg-type] + async def wrapper(*args: tuple, **kwargs: dict) -> Any: # type: ignore[type-arg] # noqa: ANN401 -- matches signature of upstream log.trace(f"{name}: mutually exclusive decorator called") if callable(resource_id): @@ -114,7 +114,7 @@ async def wrapper(*args: tuple, **kwargs: dict) -> Any: # noqa: ANN401 -- match else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") if raise_error: - raise LockedResourceError(str(namespace), id_) + raise LockedResourceError(str(namespace), id_) # type: ignore[arg-type] return None return wrapper @@ -129,7 +129,7 @@ def lock_arg( *, raise_error: bool = False, wait: bool = False, -) -> Callable: +) -> Callable: # type: ignore[type-arg] """ Apply the `lock` decorator using the value of the arg at the given name/position as the ID. diff --git a/src/bot/utils/messages.py b/src/bot/utils/messages.py index 067141e..5c815a9 100644 --- a/src/bot/utils/messages.py +++ b/src/bot/utils/messages.py @@ -13,7 +13,7 @@ def format_user(user: discord.abc.User) -> str: return f"{user.mention} (`{user.id}`)" -async def get_discord_message(ctx: Context, text: str) -> Message | str: +async def get_discord_message(ctx: Context, text: str) -> Message | str: # type: ignore[return, type-arg] """ Attempt to convert a given `text` to a discord Message object and return it. @@ -24,7 +24,7 @@ async def get_discord_message(ctx: Context, text: str) -> Message | str: return await MessageConverter().convert(ctx, text) -async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Embed | None]: +async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Embed | None]: # type: ignore[type-arg] """ Attempt to extract the text and embed from a possible link to a discord Message. @@ -40,7 +40,7 @@ async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Embed | None msg = await get_discord_message(ctx, text) # Ensure the user has read permissions for the channel the message is in if isinstance(msg, Message): - permissions = msg.channel.permissions_for(ctx.author) + permissions = msg.channel.permissions_for(ctx.author) # type: ignore[arg-type] if permissions.read_messages: text = msg.clean_content # Take first embed because we can't send multiple embeds