├── scripts
├── __init__.py
└── fetch_octicons.py
├── docs
├── README.md
├── contributing
│ ├── README.md
│ ├── developer-commands.md
│ └── design
│ │ ├── index.md
│ │ └── config.md
├── assets
│ ├── images
│ │ ├── logo.png
│ │ └── favicon.png
│ └── css
│ │ └── extra_admonitions.css
├── terms.md
├── privacy.md
├── .readthedocs.yaml
└── future-plans.md
├── monty
├── alembic
│ ├── __init__.py
│ ├── versions
│ │ ├── __init__.py
│ │ ├── d1f327f1548f_.py
│ │ ├── 7d2f79cf061c_add_per_guild_issue_linking_config.py
│ │ ├── 50ddfc74e23c_add_per_guild_configuration.py
│ │ ├── 2023_06_08_1_add_github_comment_linking.py
│ │ ├── 6a57a6d8d400_.py
│ │ ├── 2023_03_17_1_allow_disabling_codesnippets.py
│ │ ├── 2022_06_28_feature_rollouts.py
│ │ └── 19e4f2aee642_guild_features.py
│ ├── README
│ ├── script.py.mako
│ └── env.py
├── components
│ ├── __init__.py
│ └── app_emoji_syncing.py
├── exts
│ ├── core
│ │ ├── __init__.py
│ │ ├── events.py
│ │ ├── global_checks.py
│ │ ├── uptime_ping.py
│ │ ├── gateway.py
│ │ ├── delete.py
│ │ └── logging.py
│ ├── info
│ │ ├── __init__.py
│ │ ├── github
│ │ │ ├── __init__.py
│ │ │ └── graphql_models.py
│ │ ├── docs
│ │ │ ├── __init__.py
│ │ │ ├── _redis_cache.py
│ │ │ └── _html.py
│ │ └── stackoverflow.py
│ ├── meta
│ │ ├── __init__.py
│ │ └── timed.py
│ ├── utils
│ │ ├── __init__.py
│ │ ├── status_codes.py
│ │ └── misc.py
│ ├── filters
│ │ ├── __init__.py
│ │ ├── codeblock
│ │ │ └── __init__.py
│ │ └── webhook_remover.py
│ ├── python
│ │ ├── __init__.py
│ │ ├── realpython.py
│ │ └── wikipedia.py
│ └── __init__.py
├── resources
│ ├── __init__.py
│ ├── emojis
│ │ ├── gh_merge.png
│ │ ├── gh_skip.png
│ │ ├── gh_discussion.png
│ │ ├── gh_issue_draft.png
│ │ ├── gh_issue_open.png
│ │ ├── gh_pull_request.png
│ │ ├── gh_discussion_closed.png
│ │ ├── gh_pull_request_draft.png
│ │ ├── gh_discussion_duplicate.png
│ │ ├── gh_discussion_outdated.png
│ │ ├── gh_pull_request_closed.png
│ │ ├── gh_issue_closed_completed.png
│ │ ├── gh_discussion_open_answered.png
│ │ └── gh_discussion_closed_unresolved.png
│ └── repo_aliases.json
├── database
│ ├── base.py
│ ├── __init__.py
│ ├── guild.py
│ ├── feature.py
│ ├── rollouts.py
│ ├── guild_config.py
│ └── package.py
├── github_client.py
├── config
│ ├── __init__.py
│ ├── _validate_metadata.py
│ ├── validators.py
│ ├── components.py
│ └── metadata.py
├── metadata.py
├── command.py
├── migrations.py
├── statsd.py
├── utils
│ ├── features.py
│ ├── code.py
│ ├── extensions.py
│ ├── rollouts.py
│ ├── __init__.py
│ ├── services.py
│ ├── lock.py
│ ├── inventory_parser.py
│ └── responses.py
├── events.py
├── __init__.py
├── errors.py
├── monkey_patches.py
├── __main__.py
├── aiohttp_session.py
└── log.py
├── .github
├── FUNDING.yml
├── dependabot.yaml
├── actions
│ └── setup-env
│ │ └── action.yaml
└── workflows
│ ├── status_embed.yaml
│ └── ci.yaml
├── .gitattributes
├── .dockerignore
├── .pre-commit-config.yaml
├── LICENSE
├── README.md
├── Dockerfile
├── LICENSE_THIRD_PARTY
├── .gitignore
├── docker-compose.yaml
├── mkdocs.yaml
├── alembic.ini
└── pyproject.toml
/scripts/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | ../README.md
--------------------------------------------------------------------------------
/monty/alembic/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/components/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/exts/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/exts/info/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/exts/meta/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/exts/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/exts/filters/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/exts/python/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/monty/alembic/versions/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/contributing/README.md:
--------------------------------------------------------------------------------
1 | ../../CONTRIBUTING.md
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [onerandomusername]
2 |
--------------------------------------------------------------------------------
/monty/alembic/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | .basedpyright/baseline.json linguist-generated=true
2 |
--------------------------------------------------------------------------------
/monty/resources/__init__.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 |
3 |
4 | folder = pathlib.Path(__file__).parent
5 |
--------------------------------------------------------------------------------
/docs/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/docs/assets/images/logo.png
--------------------------------------------------------------------------------
/docs/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/docs/assets/images/favicon.png
--------------------------------------------------------------------------------
/monty/database/base.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import DeclarativeBase
2 |
3 |
4 | class Base(DeclarativeBase):
5 | pass
6 |
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_merge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_merge.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_skip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_skip.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_discussion.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_discussion.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_issue_draft.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_issue_draft.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_issue_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_issue_open.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_pull_request.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_pull_request.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_discussion_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_discussion_closed.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_pull_request_draft.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_pull_request_draft.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_discussion_duplicate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_discussion_duplicate.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_discussion_outdated.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_discussion_outdated.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_pull_request_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_pull_request_closed.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_issue_closed_completed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_issue_closed_completed.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_discussion_open_answered.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_discussion_open_answered.png
--------------------------------------------------------------------------------
/monty/resources/emojis/gh_discussion_closed_unresolved.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onerandomusername/monty-python/HEAD/monty/resources/emojis/gh_discussion_closed_unresolved.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Exclude everything
2 | *
3 |
4 | # Make exceptions for what's needed
5 | !alembic.ini
6 | !monty/
7 | !pyproject.toml
8 | !uv.lock
9 | !LICENSE
10 | !LICENSE_THIRD_PARTY
11 |
--------------------------------------------------------------------------------
/monty/exts/info/github/__init__.py:
--------------------------------------------------------------------------------
1 | from .cog import GithubInfo
2 |
3 |
4 | TYPE_CHECKING = 0
5 |
6 | if TYPE_CHECKING:
7 | from monty.bot import Monty
8 |
9 |
10 | def setup(bot: "Monty") -> None:
11 | """Setup the Github Info cog."""
12 | bot.add_cog(GithubInfo(bot))
13 |
--------------------------------------------------------------------------------
/monty/database/__init__.py:
--------------------------------------------------------------------------------
1 | from .feature import Feature
2 | from .guild import Guild
3 | from .guild_config import GuildConfig
4 | from .package import PackageInfo
5 | from .rollouts import Rollout
6 |
7 |
8 | __all__ = (
9 | "Feature",
10 | "Guild",
11 | "GuildConfig",
12 | "PackageInfo",
13 | "Rollout",
14 | )
15 |
--------------------------------------------------------------------------------
/monty/exts/filters/codeblock/__init__.py:
--------------------------------------------------------------------------------
1 | from monty.bot import Monty
2 |
3 |
4 | def setup(bot: Monty) -> None:
5 | """Load the CodeBlockCog cog."""
6 | # Defer import to reduce side effects from importing the codeblock package.
7 | from monty.exts.filters.codeblock._cog import CodeBlockCog
8 |
9 | bot.add_cog(CodeBlockCog(bot))
10 |
--------------------------------------------------------------------------------
/monty/github_client.py:
--------------------------------------------------------------------------------
1 | from githubkit import GitHub
2 |
3 |
4 | # This previously existed for overwriting async_transport, but now that exists in githubkit,
5 | # this exists because adding a new custom feature is easy if desired.
6 | class GitHubClient(GitHub):
7 | def __init__(self, *args, **kwargs):
8 | kwargs["http_cache"] = False
9 | super().__init__(*args, **kwargs)
10 |
--------------------------------------------------------------------------------
/monty/exts/__init__.py:
--------------------------------------------------------------------------------
1 | import pkgutil
2 | from collections.abc import Iterator
3 |
4 | from monty.log import get_logger
5 |
6 |
7 | __all__ = ("get_package_names",)
8 |
9 | log = get_logger(__name__)
10 |
11 |
12 | def get_package_names() -> Iterator[str]:
13 | """Iterate names of all packages located in /monty/exts/."""
14 | for package in pkgutil.iter_modules(__path__):
15 | if package.ispkg:
16 | yield package.name
17 |
--------------------------------------------------------------------------------
/docs/terms.md:
--------------------------------------------------------------------------------
1 | # Terms of Service
2 |
3 | Updated: March 13, 2022
Effective: March 13, 2022
4 |
5 | This ToS applies to the Discord bot Monty Python#1635 (872576125384147005).
6 |
7 | You may not use Monty Python to violate any applicable laws or regulations,
8 | [Discord's Terms of Service](https://discord.com/terms),or
9 | [Discord Community Guidelines](https://discord.com/guidelines).
10 |
11 | To contact the developer, please use the official support server:
12 | https://discord.gg/pGatU9MSXz
13 |
--------------------------------------------------------------------------------
/docs/privacy.md:
--------------------------------------------------------------------------------
1 | # Privacy Policy
2 |
3 | Updated: May 25, 2022
Effective: May 25, 2022
4 |
5 | This Privacy Policy applies to the Discord bot Monty Python#1635
6 | (872576125384147005).
7 |
8 | When a command is run, the author's ID, username, and discrimator are stored
9 | along with the ID of the channel the command was ran in, and the arguments to
10 | the command for up to 28 days.
11 |
12 | No other information is stored at this time.
13 |
14 | To contact the developer, please use the official support server:
15 | https://discord.gg/pGatU9MSXz
16 |
--------------------------------------------------------------------------------
/monty/exts/info/github/graphql_models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import githubkit
4 |
5 |
6 | class DiscussionCommentUser(githubkit.GitHubModel):
7 | """Response model for user discussion comments."""
8 |
9 | type: str
10 | login: str
11 | html_url: str
12 | avatar_url: str
13 | name: None = None
14 |
15 |
16 | class DiscussionComment(githubkit.GitHubModel):
17 | """Response model for a discussion comment."""
18 |
19 | id: str
20 | body: str
21 | created_at: datetime.datetime
22 | html_url: str
23 | user: DiscussionCommentUser | None
24 |
--------------------------------------------------------------------------------
/monty/exts/info/docs/__init__.py:
--------------------------------------------------------------------------------
1 | import cachingutils.redis
2 |
3 | from monty import constants
4 | from monty.bot import Monty
5 |
6 | from ._redis_cache import DocRedisCache
7 |
8 |
9 | MAX_SIGNATURE_AMOUNT = 3
10 | PRIORITY_PACKAGES = ("python",)
11 | NAMESPACE = "doc"
12 |
13 | _cache = cachingutils.redis.async_session(constants.Client.config_prefix)
14 | doc_cache = DocRedisCache(prefix=_cache._prefix + "docs", session=_cache._redis)
15 |
16 |
17 | def setup(bot: Monty) -> None:
18 | """Load the Doc cog."""
19 | from ._cog import DocCog
20 |
21 | bot.add_cog(DocCog(bot))
22 |
--------------------------------------------------------------------------------
/monty/alembic/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade() -> None:
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade() -> None:
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/monty/resources/repo_aliases.json:
--------------------------------------------------------------------------------
1 | {
2 | "k8s": {
3 | "owner": "kubernetes",
4 | "repo": "kubernetes"
5 | },
6 | "python": {
7 | "owner": "python",
8 | "repo": "cpython"
9 | },
10 | "monty": {
11 | "owner": "onerandomusername",
12 | "repo": "monty-python"
13 | },
14 | "community": {
15 | "owner": "community",
16 | "repo": "community"
17 | },
18 | "cloudflare": {
19 | "owner": "cloudflare",
20 | "repo": "cloudflare-docs"
21 | },
22 | "discord": {
23 | "owner": "discord",
24 | "repo": "discord-api-docs"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/docs/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # .readthedocs.yaml
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 |
5 | # Required
6 | version: 2
7 |
8 | # Set the version of Python and other tools you might need
9 | build:
10 | os: ubuntu-24.04
11 | tools:
12 | python: "3.10"
13 | jobs:
14 | create_environment:
15 | - asdf plugin add uv
16 | - asdf install uv 0.8.19
17 | - asdf global uv 0.8.19
18 | - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --no-default-groups --group docs
19 | install:
20 | - "true"
21 |
22 |
23 |
24 | mkdocs:
25 | configuration: mkdocs.yaml
26 |
--------------------------------------------------------------------------------
/monty/config/__init__.py:
--------------------------------------------------------------------------------
1 | from monty.config._validate_metadata import _check_config_metadata
2 | from monty.config.components import get_category_choices
3 | from monty.config.metadata import CATEGORY_TO_ATTR, GROUP_TO_ATTR, METADATA
4 | from monty.config.models import Category, ConfigAttrMetadata, FreeResponseMetadata, SelectGroup, SelectOptionMetadata
5 |
6 |
7 | __all__ = (
8 | "CATEGORY_TO_ATTR",
9 | "GROUP_TO_ATTR",
10 | "METADATA",
11 | "Category",
12 | "ConfigAttrMetadata",
13 | "FreeResponseMetadata",
14 | "SelectGroup",
15 | "SelectOptionMetadata",
16 | "get_category_choices",
17 | )
18 |
19 | _check_config_metadata(METADATA)
20 |
21 | del _check_config_metadata
22 |
--------------------------------------------------------------------------------
/monty/metadata.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 |
4 | @dataclass()
5 | class ExtMetadata:
6 | """Ext metadata class to determine if extension should load at runtime depending on bot configuration."""
7 |
8 | core: bool = False
9 | "Whether or not to always load by default."
10 | no_unload: bool = False
11 | "False to allow the cog to be unloaded, True to block."
12 | has_cog: bool = True
13 | "Whether or not the extension has a cog to check for load status."
14 |
15 | def __init__(
16 | self,
17 | *,
18 | core: bool = False,
19 | no_unload: bool = False,
20 | has_cog: bool = True,
21 | ) -> None:
22 | self.core = core
23 | self.no_unload = no_unload
24 | self.has_cog = has_cog
25 |
--------------------------------------------------------------------------------
/monty/database/guild.py:
--------------------------------------------------------------------------------
1 | import sqlalchemy as sa
2 | from sqlalchemy.ext.mutable import MutableList
3 | from sqlalchemy.orm import Mapped, mapped_column
4 |
5 | from .base import Base
6 |
7 |
8 | class Guild(Base):
9 | """Represents a Discord guild's enabled bot features."""
10 |
11 | __tablename__ = "guilds"
12 |
13 | id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True, autoincrement=False)
14 | # TODO: this should be a many to many relationship
15 | feature_ids: Mapped[list[str]] = mapped_column(
16 | MutableList.as_mutable(sa.ARRAY(sa.String(length=50))),
17 | name="features",
18 | nullable=False,
19 | default=[],
20 | server_default=r"{}",
21 | )
22 |
23 | # features: Mapped[List[Feature]] = relationship(Feature)
24 |
--------------------------------------------------------------------------------
/monty/command.py:
--------------------------------------------------------------------------------
1 | from disnake.ext import commands
2 |
3 |
4 | class Command(commands.Command):
5 | """
6 | A `discord.ext.commands.Command` subclass which supports root aliases.
7 |
8 | A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
9 | top-level commands rather than being aliases of the command's group. It's stored as an attribute
10 | also named `root_aliases`.
11 | """
12 |
13 | def __init__(self, *args, **kwargs) -> None:
14 | super().__init__(*args, **kwargs)
15 | self.root_aliases = kwargs.get("root_aliases", [])
16 |
17 | if not isinstance(self.root_aliases, (list, tuple)):
18 | msg = "Root aliases of a command must be a list or a tuple of strings."
19 | raise TypeError(msg)
20 |
--------------------------------------------------------------------------------
/monty/alembic/versions/d1f327f1548f_.py:
--------------------------------------------------------------------------------
1 | """Add hidden field to docs_inventory
2 |
3 | Revision ID: d1f327f1548f
4 | Revises: 6a57a6d8d400
5 | Create Date: 2022-05-20 04:59:09.975649
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = "d1f327f1548f"
15 | down_revision = "6a57a6d8d400"
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade() -> None:
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.add_column("docs_inventory", sa.Column("hidden", sa.Boolean(), server_default="false", nullable=False))
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade() -> None:
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_column("docs_inventory", "hidden")
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/monty/alembic/versions/7d2f79cf061c_add_per_guild_issue_linking_config.py:
--------------------------------------------------------------------------------
1 | """add per-guild issue linking config
2 |
3 | Revision ID: 7d2f79cf061c
4 | Revises: 50ddfc74e23c
5 | Create Date: 2022-05-22 04:25:19.100644
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = "7d2f79cf061c"
15 | down_revision = "50ddfc74e23c"
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade() -> None:
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.add_column("guild_config", sa.Column("github_issues_org", sa.String(length=39), nullable=True))
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade() -> None:
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_column("guild_config", "github_issues_org")
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates
2 |
3 | version: 2
4 | updates:
5 | - package-ecosystem: "github-actions"
6 | # Workflow files in .github/workflows will be checked
7 | # so will the actions in .github/actions/
8 | directories:
9 | - "/"
10 | - "/.github/actions/*"
11 | schedule:
12 | interval: "daily"
13 | open-pull-requests-limit: 2
14 | groups:
15 | github-actions:
16 | patterns:
17 | - "*"
18 |
19 | - package-ecosystem: "uv"
20 | directory: "/"
21 | schedule:
22 | interval: "daily"
23 | time: "00:00"
24 | timezone: "Etc/GMT+5"
25 | open-pull-requests-limit: 10
26 | groups:
27 | dev-dependencies:
28 | dependency-type: "development"
29 | patterns:
30 | - "*"
31 |
--------------------------------------------------------------------------------
/monty/config/_validate_metadata.py:
--------------------------------------------------------------------------------
1 | from monty import constants
2 | from monty.config.models import Category, ConfigAttrMetadata, SelectOptionMetadata
3 |
4 |
5 | __all__ = ()
6 |
7 |
8 | def _check_config_metadata(metadata: dict[str, ConfigAttrMetadata]) -> None:
9 | for m in metadata.values():
10 | assert 0 < len(m.description) < 100
11 | assert m.modal or m.select_option
12 | if m.select_option:
13 | assert isinstance(m.select_option, SelectOptionMetadata)
14 | assert m.type is bool
15 | if m.modal:
16 | assert m.description
17 | assert len(m.description) <= 45
18 | if m.depends_on_features:
19 | for feature in m.depends_on_features:
20 | assert feature in constants.Feature
21 | for c in Category:
22 | if not any(c in m.categories for m in metadata.values()):
23 | msg = f"Category {c} has no associated config attributes"
24 | raise ValueError(msg)
25 |
--------------------------------------------------------------------------------
/monty/alembic/versions/50ddfc74e23c_add_per_guild_configuration.py:
--------------------------------------------------------------------------------
1 | """add per-guild configuration
2 |
3 | Revision ID: 50ddfc74e23c
4 | Revises: d1f327f1548f
5 | Create Date: 2022-05-22 01:56:44.036037
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = "50ddfc74e23c"
15 | down_revision = "d1f327f1548f"
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade() -> None:
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.create_table(
23 | "guild_config",
24 | sa.Column("id", sa.BigInteger(), nullable=False),
25 | sa.Column("prefix", sa.String(length=50), nullable=True),
26 | sa.PrimaryKeyConstraint("id"),
27 | )
28 | # ### end Alembic commands ###
29 |
30 |
31 | def downgrade() -> None:
32 | # ### commands auto generated by Alembic - please adjust! ###
33 | op.drop_table("guild_config")
34 | # ### end Alembic commands ###
35 |
--------------------------------------------------------------------------------
/monty/alembic/versions/2023_06_08_1_add_github_comment_linking.py:
--------------------------------------------------------------------------------
1 | """add github comment linking
2 |
3 | Revision ID: 2023_06_08_1
4 | Revises: 2023_03_17_1
5 | Create Date: 2023-06-08 16:08:05.842221
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = "2023_06_08_1"
15 | down_revision = "2023_03_17_1"
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade() -> None:
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.add_column("guild_config", sa.Column("github_comment_linking", sa.Boolean(), nullable=True))
23 | # ### end Alembic commands ###
24 |
25 | op.execute("UPDATE guild_config SET github_comment_linking = true")
26 |
27 | op.alter_column("guild_config", "github_comment_linking", nullable=False)
28 |
29 |
30 | def downgrade() -> None:
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_column("guild_config", "github_comment_linking")
33 | # ### end Alembic commands ###
34 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ## Pre-commit setup
2 |
3 | repos:
4 | - repo: https://github.com/pre-commit/pre-commit-hooks
5 | rev: v6.0.0
6 | hooks:
7 | - id: check-case-conflict
8 | - id: check-json
9 | - id: check-toml
10 | - id: check-yaml
11 | # - id: pretty-format-json
12 | # args: [--indent=4, --autofix]
13 | - id: end-of-file-fixer
14 | exclude: ^(\.basedpyright/baseline\.json)$
15 | - id: mixed-line-ending
16 | args: [--fix=lf]
17 | - id: trailing-whitespace
18 | args: [--markdown-linebreak-ext=md]
19 |
20 | - repo: https://github.com/pre-commit/pygrep-hooks
21 | rev: v1.10.0
22 | hooks:
23 | - id: python-check-blanket-noqa
24 | - id: python-use-type-annotations
25 |
26 | - repo: https://github.com/astral-sh/uv-pre-commit
27 | rev: 0.8.19
28 | hooks:
29 | - id: uv-lock
30 |
31 | - repo: https://github.com/astral-sh/ruff-pre-commit
32 | rev: v0.14.5
33 | hooks:
34 | - id: ruff-check
35 | args: [--fix]
36 | - id: ruff-format
37 |
--------------------------------------------------------------------------------
/monty/migrations.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import alembic.command
4 | import alembic.config
5 | from sqlalchemy import Connection
6 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
7 |
8 | import monty.alembic
9 | from monty import constants
10 |
11 |
12 | def run_upgrade(connection: Connection, cfg: alembic.config.Config) -> None:
13 | """Run alembic upgrades."""
14 | cfg.attributes["connection"] = connection
15 | alembic.command.upgrade(cfg, "head")
16 |
17 |
18 | async def run_async_upgrade(engine: AsyncEngine) -> None:
19 | """Run alembic upgrades but async."""
20 | alembic_cfg = alembic.config.Config()
21 | alembic_cfg.set_main_option("script_location", os.path.dirname(monty.alembic.__file__)) # noqa: PTH120
22 | async with engine.connect() as conn:
23 | await conn.run_sync(run_upgrade, alembic_cfg)
24 |
25 |
26 | async def run_alembic(engine: AsyncEngine | None = None) -> None:
27 | """Run alembic migrations."""
28 | engine = engine or create_async_engine(str(constants.Database.postgres_bind))
29 | await run_async_upgrade(engine)
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 aru
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/monty/database/feature.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import sqlalchemy as sa
4 | from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship, validates
5 |
6 | from .base import Base
7 | from .rollouts import Rollout
8 |
9 |
10 | NAME_REGEX = re.compile(r"^[A-Z0-9_]+$")
11 |
12 |
13 | class Feature(MappedAsDataclass, Base):
14 | """Represents a bot feature."""
15 |
16 | __tablename__ = "features"
17 |
18 | name: Mapped[str] = mapped_column(sa.String(length=50), primary_key=True)
19 | enabled: Mapped[bool | None] = mapped_column(default=None, server_default=None, nullable=True)
20 | rollout_id: Mapped[int | None] = mapped_column(
21 | sa.ForeignKey("rollouts.id"), default=None, nullable=True, name="rollout"
22 | )
23 | rollout: Mapped[Rollout | None] = relationship(Rollout, default=None)
24 |
25 | @validates("name")
26 | def validate_name(self, key: str, name: str) -> str:
27 | """Validate the `name` attribute meets the regex requirement."""
28 | if not NAME_REGEX.fullmatch(name):
29 | err = f"The provided feature name '{name}' does not match the name regex {NAME_REGEX!s}"
30 | raise ValueError(err)
31 | return name
32 |
--------------------------------------------------------------------------------
/monty/database/rollouts.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import sqlalchemy as sa
4 | from sqlalchemy.orm import Mapped, mapped_column
5 |
6 | from .base import Base
7 |
8 |
9 | class Rollout(Base):
10 | """Represents a feature rollout."""
11 |
12 | __tablename__ = "rollouts"
13 |
14 | id: Mapped[int] = mapped_column(sa.Integer, autoincrement=True, primary_key=True)
15 | name: Mapped[str] = mapped_column(sa.String(length=100), unique=True)
16 | active: Mapped[bool] = mapped_column(sa.Boolean, default=False, nullable=False)
17 | rollout_by: Mapped[datetime.datetime | None] = mapped_column(sa.DateTime(timezone=True), nullable=True)
18 | rollout_to_percent: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False)
19 | rollout_hash_low: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False)
20 | rollout_hash_high: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False)
21 | update_every: Mapped[int] = mapped_column(sa.SmallInteger, nullable=False, default=15)
22 | hashes_last_updated: Mapped[datetime.datetime] = mapped_column(
23 | sa.DateTime(timezone=True),
24 | nullable=False,
25 | default=datetime.datetime.now,
26 | server_default=sa.func.now(),
27 | )
28 |
--------------------------------------------------------------------------------
/monty/config/validators.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import aiohttp
4 | import disnake
5 | import yarl
6 | from disnake.ext import commands
7 |
8 |
9 | AnyContext = disnake.ApplicationCommandInteraction | commands.Context
10 | GITHUB_ORG_REGEX = re.compile(r"[a-zA-Z0-9\-]{1,}")
11 |
12 |
13 | async def validate_github_org(ctx: AnyContext, arg: str) -> bool:
14 | """Validate all GitHub orgs meet GitHub's naming requirements."""
15 | if not arg:
16 | return True # optional support
17 | if not GITHUB_ORG_REGEX.fullmatch(arg):
18 | err = f"The GitHub org '{arg}' is not a valid GitHub organisation name."
19 | raise ValueError(err)
20 |
21 | url = yarl.URL("https://github.com").with_path(arg)
22 | # TODO: use the API rather than a HEAD request: eg /sponsors is not a user
23 | try:
24 | r = await ctx.bot.http_session.head(url, raise_for_status=True)
25 | except aiohttp.ClientResponseError:
26 | msg = (
27 | "Organisation must be a valid GitHub user or organisation. Please check the provided account exists on"
28 | " GitHub and try again."
29 | )
30 | raise commands.UserInputError(msg) from None
31 | else:
32 | r.close()
33 | return True
34 |
--------------------------------------------------------------------------------
/monty/alembic/versions/6a57a6d8d400_.py:
--------------------------------------------------------------------------------
1 | """Set up the documentation tables
2 |
3 | Revision ID: 6a57a6d8d400
4 | Revises:
5 | Create Date: 2022-05-20 03:06:27.335042
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 | from sqlalchemy.dialects import postgresql
12 |
13 |
14 | # revision identifiers, used by Alembic.
15 | revision = "6a57a6d8d400"
16 | down_revision = None
17 | branch_labels = None
18 | depends_on = None
19 |
20 |
21 | def upgrade() -> None:
22 | # ### commands auto generated by Alembic - please adjust! ###
23 | op.create_table(
24 | "docs_inventory",
25 | sa.Column("name", sa.String(length=50), nullable=False),
26 | sa.Column("inventory_url", sa.Text(), nullable=False),
27 | sa.Column("base_url", sa.Text(), nullable=True),
28 | sa.Column("guilds_whitelist", postgresql.ARRAY(sa.BigInteger()), nullable=True),
29 | sa.Column("guilds_blacklist", postgresql.ARRAY(sa.BigInteger()), nullable=True),
30 | sa.PrimaryKeyConstraint("name"),
31 | )
32 | # ### end Alembic commands ###
33 |
34 |
35 | def downgrade() -> None:
36 | # ### commands auto generated by Alembic - please adjust! ###
37 | op.drop_table("docs_inventory")
38 | # ### end Alembic commands ###
39 |
--------------------------------------------------------------------------------
/monty/exts/core/events.py:
--------------------------------------------------------------------------------
1 | import disnake
2 | from disnake.ext import commands
3 |
4 | from monty.bot import Monty
5 | from monty.events import MessageContext, MontyEvent
6 | from monty.log import get_logger
7 | from monty.metadata import ExtMetadata
8 |
9 |
10 | EXT_METADATA = ExtMetadata(core=True)
11 |
12 |
13 | logger = get_logger(__name__)
14 |
15 |
16 | class Events(commands.Cog):
17 | """Global checks for monty."""
18 |
19 | def __init__(self, bot: Monty) -> None:
20 | self.bot = bot
21 |
22 | @commands.Cog.listener(disnake.Event.message)
23 | async def on_message(self, message: disnake.Message) -> None:
24 | """Re-dispatch message listener events for on_message commands.
25 |
26 | There are multiple listeners that listen for urls and code content
27 | so we pre-process that information here before dispatching.
28 | """
29 | # Only care about messages from users.
30 | if message.author.bot:
31 | return
32 |
33 | context = MessageContext.from_message(message)
34 | self.bot.dispatch(MontyEvent.monty_message_processed.value, message, context)
35 |
36 |
37 | def setup(bot: Monty) -> None:
38 | """Add the events cog the bot."""
39 | bot.add_cog(Events(bot))
40 |
--------------------------------------------------------------------------------
/monty/database/guild_config.py:
--------------------------------------------------------------------------------
1 | import sqlalchemy as sa
2 | from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship
3 |
4 | from monty import constants
5 |
6 | from .base import Base
7 | from .guild import Guild
8 |
9 |
10 | # n.b. make sure the metadata in config_metadata stays synced to this file and vice versa
11 | class GuildConfig(MappedAsDataclass, Base):
12 | """Represents a per-guild config."""
13 |
14 | __tablename__ = "guild_config"
15 |
16 | id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True, autoincrement=False)
17 | guild_id: Mapped[int | None] = mapped_column(sa.ForeignKey("guilds.id"), name="guild", unique=True)
18 | guild: Mapped[Guild | None] = relationship(Guild, default=None)
19 | prefix: Mapped[str | None] = mapped_column(
20 | sa.String(length=50), nullable=True, default=constants.Client.default_command_prefix
21 | )
22 | github_issues_org: Mapped[str | None] = mapped_column(sa.String(length=39), nullable=True, default=None)
23 | git_file_expansions: Mapped[bool] = mapped_column(sa.Boolean, default=True)
24 | github_issue_linking: Mapped[bool] = mapped_column(sa.Boolean, default=True)
25 | github_comment_linking: Mapped[bool] = mapped_column(sa.Boolean, default=True)
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Monty Python
2 |
3 | _A Python and GitHub focused Discord bot to make collaborating on Python
4 | projects through Discord easier than ever._
5 |
6 | ## How to use Monty
7 |
8 | While Monty Python is open source, I'd appreciate it if you use the public
9 | instance rather than host your own. If you're looking for something that Monty
10 | doesn't have, consider contributing!
11 |
12 | Invites to the public instances are below.
13 |
14 | Monty Python#1635:
15 | \
16 | Monty Python Beta#8789:
17 |
18 |
19 | You can also use `/monty invite` in any server with Monty Python, which will
20 | provide an invite.
21 |
22 | ## Contributing
23 |
24 | Thank you for expressing an interest in contributing. Instructions to set up a
25 | developer environment, along with a list of bot owner commands and how to get
26 | running are documented in the
27 | [Contributing guide](https://monty.arielle.codes/contributing/).
28 |
29 | ## Contact
30 |
31 | For support or to contact the developer, please join the
32 | [Support Server](https://discord.gg/mPscM4FjWB) or
33 | [create an issue](https://github.com/onerandomusername/monty-python/issues/new/choose).
34 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10-slim
2 | COPY --from=ghcr.io/astral-sh/uv:0.8.19 /uv /uvx /bin/
3 |
4 | WORKDIR /bot
5 |
6 | # Enable bytecode compilation
7 | ENV UV_COMPILE_BYTECODE=1
8 |
9 | # Copy from the cache instead of linking since it's a mounted volume
10 | ENV UV_LINK_MODE=copy
11 |
12 | # Ensure installed tools can be executed out of the box
13 | ENV UV_TOOL_BIN_DIR=/usr/local/bin
14 |
15 | # Set SHA build argument
16 | ARG git_sha=""
17 | ENV GIT_SHA=$git_sha
18 |
19 | # as we have a git dep, install git
20 | RUN apt update && apt install git -y
21 |
22 |
23 | # Install the project's dependencies using the lockfile and settings
24 | RUN --mount=type=cache,target=/root/.cache/uv \
25 | --mount=type=bind,source=uv.lock,target=uv.lock \
26 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
27 | uv sync --locked --no-install-project --no-dev
28 |
29 | # Then, add the rest of the project source code and install it
30 | # Installing separately from its dependencies allows optimal layer caching
31 | COPY . /bot
32 | RUN --mount=type=cache,target=/root/.cache/uv \
33 | uv sync --locked --no-dev
34 |
35 | # Place executables in the environment at the front of the path
36 | ENV PATH="/bot/.venv/bin:$PATH"
37 |
38 |
39 | ENTRYPOINT ["python3", "-m", "monty"]
40 |
--------------------------------------------------------------------------------
/monty/alembic/versions/2023_03_17_1_allow_disabling_codesnippets.py:
--------------------------------------------------------------------------------
1 | """allow disabling codesnippets
2 |
3 | Revision ID: 2023_03_17_1
4 | Revises: 2022_06_28_1
5 | Create Date: 2023-03-17 21:01:37.638503
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = "2023_03_17_1"
15 | down_revision = "2022_06_28_1"
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade() -> None:
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.add_column("guild_config", sa.Column("git_file_expansions", sa.Boolean(), nullable=True))
23 | op.add_column("guild_config", sa.Column("github_issue_linking", sa.Boolean(), nullable=True))
24 | # ### end Alembic commands ###
25 |
26 | op.execute("UPDATE guild_config SET git_file_expansions = true")
27 | op.execute("UPDATE guild_config SET github_issue_linking = true")
28 |
29 | op.alter_column("guild_config", "git_file_expansions", nullable=False)
30 | op.alter_column("guild_config", "github_issue_linking", nullable=False)
31 |
32 |
33 | def downgrade() -> None:
34 | # ### commands auto generated by Alembic - please adjust! ###
35 | op.drop_column("guild_config", "git_file_expansions")
36 | op.drop_column("guild_config", "github_issue_linking")
37 | # ### end Alembic commands ###
38 |
--------------------------------------------------------------------------------
/LICENSE_THIRD_PARTY:
--------------------------------------------------------------------------------
1 | This project was initially based off of two projects by Python Discord.
2 | - https://github.com/python-discord/bot
3 | - https://github.com/python-discord/sir-lancebot
4 |
5 | ------------------------------------------------------------------------
6 |
7 | MIT License
8 |
9 | Copyright (c) 2018 Python Discord
10 |
11 | Permission is hereby granted, free of charge, to any person obtaining a copy
12 | of this software and associated documentation files (the "Software"), to deal
13 | in the Software without restriction, including without limitation the rights
14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15 | copies of the Software, and to permit persons to whom the Software is
16 | furnished to do so, subject to the following conditions:
17 |
18 | The above copyright notice and this permission notice shall be included in all
19 | copies or substantial portions of the Software.
20 |
21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27 | SOFTWARE.
28 |
--------------------------------------------------------------------------------
/monty/config/components.py:
--------------------------------------------------------------------------------
1 | from typing import cast
2 |
3 | import disnake
4 |
5 | from monty.config.models import Category, CategoryMetadata
6 |
7 |
8 | __all__ = ("get_category_choices",)
9 |
10 |
11 | def get_category_choices() -> list[disnake.OptionChoice]:
12 | """Get a list of category choices for use in slash command options."""
13 | options = []
14 | for cat in Category:
15 | metadata: CategoryMetadata = cat.value
16 | default_name = (
17 | metadata.autocomplete_text
18 | if isinstance(metadata.autocomplete_text, str)
19 | else (metadata.autocomplete_text.get("_") or metadata.name)
20 | )
21 | assert isinstance(default_name, str)
22 | localised: disnake.Localized | str
23 | if isinstance(metadata.autocomplete_text, dict):
24 | data = metadata.autocomplete_text.copy()
25 | data.pop("_", default_name)
26 | data = cast("dict[disnake.Locale, str]", data)
27 | for opt, val in data.items():
28 | data[opt] = str(metadata.emoji) + " " + val
29 | localised = disnake.Localized(
30 | string=default_name,
31 | data=data,
32 | )
33 | else:
34 | localised = str(metadata.emoji) + " " + default_name
35 | options.append(disnake.OptionChoice(name=localised, value=cat.name))
36 | return options
37 |
--------------------------------------------------------------------------------
/monty/statsd.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import socket
3 | from typing import cast
4 |
5 | from statsd.client.base import StatsClientBase
6 |
7 | from monty.utils import scheduling
8 |
9 |
10 | class AsyncStatsClient(StatsClientBase):
11 | """An async transport method for statsd communication."""
12 |
13 | def __init__(self, *, host: str, port: int, prefix: str | None = None) -> None:
14 | """Create a new client."""
15 | self._addr = (socket.gethostbyname(host), port)
16 | self._prefix = prefix
17 | self._transport = None
18 | self._loop = asyncio.get_running_loop()
19 | scheduling.create_task(self.create_socket())
20 |
21 | async def create_socket(self) -> None:
22 | """Use the loop.create_datagram_endpoint method to create a socket."""
23 | transport, _ = await self._loop.create_datagram_endpoint(
24 | asyncio.DatagramProtocol, family=socket.AF_INET, remote_addr=self._addr
25 | )
26 | self._transport = cast("asyncio.DatagramTransport", transport)
27 |
28 | def _send(self, data: str) -> None:
29 | """Start an async task to send data to statsd."""
30 | scheduling.create_task(self._async_send(data))
31 |
32 | async def _async_send(self, data: str) -> None:
33 | """Send data to the statsd server using the async transport."""
34 | assert self._transport
35 | self._transport.sendto(data.encode("utf-8"), self._addr)
36 |
--------------------------------------------------------------------------------
/monty/utils/features.py:
--------------------------------------------------------------------------------
1 | """
2 | Utils for managing features.
3 |
4 | This provides a util for a feature in the database to be created representing a specific local feature.
5 | """
6 |
7 | from __future__ import annotations
8 |
9 | from typing import TYPE_CHECKING, TypeVar
10 |
11 | import disnake
12 | from disnake.ext import commands
13 |
14 | from monty import constants
15 | from monty.database.feature import NAME_REGEX
16 | from monty.errors import FeatureDisabled
17 |
18 |
19 | if TYPE_CHECKING:
20 | from collections.abc import Callable
21 |
22 | from monty.bot import Monty
23 |
24 |
25 | AnyContext = disnake.ApplicationCommandInteraction | commands.Context
26 | T = TypeVar("T")
27 |
28 |
29 | def require_feature(name: constants.Feature) -> Callable[[T], T]:
30 | """Require the specified feature for this command."""
31 | # validate the name meets the regex
32 | assert name in constants.Feature
33 | match = NAME_REGEX.fullmatch(name.value)
34 | if not match:
35 | msg = f"Feature value must match regex '{NAME_REGEX.pattern}'"
36 | raise RuntimeError(msg)
37 |
38 | async def predicate(ctx: AnyContext) -> bool:
39 | bot: Monty = ctx.bot # this will be a Monty instance
40 |
41 | guild_id: int | None = getattr(ctx, "guild_id", None) or (ctx.guild and ctx.guild.id)
42 |
43 | is_enabled = await bot.guild_has_feature(guild_id, name)
44 | if is_enabled:
45 | return True
46 |
47 | raise FeatureDisabled
48 |
49 | return commands.check(predicate)
50 |
--------------------------------------------------------------------------------
/.github/actions/setup-env/action.yaml:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: MIT
2 |
3 | name: Set up environment
4 | description: .
5 | inputs:
6 | python-version:
7 | description: The python version to install
8 | required: false
9 | default: '3.10'
10 | outputs:
11 | python-version:
12 | description: The python version that was installed.
13 | value: ${{ steps.python-version.outputs.python-version }}
14 |
15 | runs:
16 | using: composite
17 | steps:
18 | - name: Set up uv with Python ${{ inputs.python-version }}
19 | id: setup-python
20 | uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
21 | with:
22 | version: '0.8.14'
23 | enable-cache: true
24 | # this doesn't install python but pins the uv version; its the same as providing UV_PYTHON
25 | python-version: ${{ inputs.python-version }}
26 |
27 | - name: Install Python ${{ inputs.python-version }}
28 | shell: bash
29 | env:
30 | UV_PYTHON_DOWNLOADS: automatic
31 | PYTHON_VERSION: ${{ inputs.python-version }}
32 | run: |
33 | uv python install ${PYTHON_VERSION}
34 |
35 | - name: Install nox
36 | shell: bash
37 | run: |
38 | uv tool install nox
39 |
40 | - name: Set Python version
41 | id: python-version
42 | shell: bash
43 | env:
44 | UV_VENV_CLEAR: 1
45 | run: |
46 | uv venv .venv
47 | echo "python-version=$(uv run python -c 'import sys; print(".".join(map(str,sys.version_info[:2])))')" >> $GITHUB_OUTPUT
48 |
--------------------------------------------------------------------------------
/scripts/fetch_octicons.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import pathlib
3 |
4 | from wand.color import Color
5 | from wand.image import Image
6 |
7 | from monty.constants import AppEmojisCls, Client, GHColour, Octicon
8 |
9 |
10 | DIRECTORY = pathlib.Path(Client.app_emoji_directory.lstrip("\\/"))
11 |
12 | log = logging.getLogger(__name__)
13 |
14 |
15 | def convert_svg_to_png(data: bytes, colour: GHColour) -> bytes:
16 | """Convert SVG data to PNG format with the specified colour."""
17 | with Image(
18 | blob=data,
19 | format="svg",
20 | height=128,
21 | width=128,
22 | background=Color("transparent"),
23 | ) as img:
24 | img.opaque_paint(
25 | target=Color("black"),
26 | fill=Color(f"#{colour.value:06X}"),
27 | fuzz=img.quantum_range * 0.05,
28 | )
29 | img.format = "apng"
30 | img.strip()
31 | return img.make_blob()
32 |
33 |
34 | def fetch_octicons() -> dict[str, bytes]:
35 | """Fetch the octicons from GitHub and convert them to PNG format."""
36 | octicons_data: dict[str, bytes] = {}
37 | for field in AppEmojisCls.model_fields.values():
38 | octicon = field.default
39 | if not isinstance(octicon, Octicon):
40 | continue
41 | png_data = convert_svg_to_png(octicon.icon().svg.encode(), octicon.color)
42 | octicons_data[octicon.name] = png_data
43 | log.info("Fetched and converted octicon %s as %s", octicon.slug, octicon.name)
44 | return octicons_data
45 |
46 |
47 | if __name__ == "__main__":
48 | octicons = fetch_octicons()
49 | DIRECTORY.mkdir(parents=True, exist_ok=True)
50 | for name, data in octicons.items():
51 | _ = (DIRECTORY / f"{name}.png").write_bytes(data)
52 |
--------------------------------------------------------------------------------
/docs/future-plans.md:
--------------------------------------------------------------------------------
1 | ---
2 | description: What's next in Monty's future?
3 | ---
4 |
5 | # Future plans
6 |
7 | These are a list of future plans and integrations for Monty. If you want to help
8 | with one, please open an issue to flesh out the idea a bit more before
9 | contributing.
10 |
11 | There is also a list of future plans and integrations @
12 |
13 |
14 | ## Todo
15 |
16 | ### Core
17 |
18 | - [ ] Speed up boot time
19 | - [ ] Restructure deployments to be able to use RESUMING when deploying a new
20 | version of Monty (don't lose any events between deployments)
21 | - [ ] Use `monty.utils.responses` in more places.
22 | - [ ] Type safety
23 | - [ ] Enforce `DeleteButton` on *every. single. reply.*
24 |
25 | ### Database management
26 |
27 | - [ ] Rewrite guild configuration to be eager upon guild join
28 | - [ ] Per-user configuration (see below)
29 |
30 | ### Dependencies
31 |
32 | - [ ] Drop some of the additional markdown parsers
33 |
34 | ### GitHub Parsing
35 |
36 | - [ ] Migrate to Mistune v3
37 | - [ ] Rewrite GitHub processing to be more reconfigurable
38 | - [ ] Support components v2
39 | - [ ] Add image support to replies
40 | - [ ] Per user configuration of settings, see below
41 |
42 | ### User Support
43 |
44 | - [ ] Per-user configuration
45 | - [ ] documentation objects
46 | - [ ] github org
47 | - [ ] github expand configuration
48 | - [ ] discourse expand
49 | - [ ] Per-user Feature system for deployments
50 | - [ ] Admin support for user blacklist
51 |
52 | ## Completed
53 |
54 | - [x] Rewrite the developer-side feature view command
55 | - [x] Add contributing documentation
56 | - [x] Migrate to uv
57 | - [x] Add an autodoc of all app commands to the documentation website.
58 |
--------------------------------------------------------------------------------
/monty/events.py:
--------------------------------------------------------------------------------
1 | # from pydantic import dataclasses
2 | import dataclasses
3 | import enum
4 | import functools
5 |
6 | import disnake
7 |
8 | from monty.utils.code import prepare_input
9 | from monty.utils.markdown import remove_codeblocks
10 | from monty.utils.messages import extract_urls
11 |
12 |
13 | class MontyEvent(enum.Enum):
14 | monty_message_processed = "monty_message_processed"
15 |
16 |
17 | @dataclasses.dataclass(frozen=True)
18 | class MessageContext:
19 | """A context for a message event."""
20 |
21 | content: str
22 |
23 | # Lazily initialize attributes
24 | @functools.cached_property
25 | def code(self) -> str | None:
26 | """Return the code blocks found in the message."""
27 | return prepare_input(self.content, require_fenced=True)
28 |
29 | @functools.cached_property
30 | def text(self) -> str:
31 | """Return the message content with code blocks removed."""
32 | return remove_codeblocks(self.content)
33 |
34 | @functools.cached_property
35 | def urls(self) -> list[str]:
36 | """Return the URLs found in the message."""
37 | return list(extract_urls(self.text))
38 |
39 | @classmethod
40 | def from_message_inter(
41 | cls, inter: disnake.MessageInteraction | disnake.MessageCommandInteraction, /
42 | ) -> "MessageContext":
43 | """Create a MessageContext from an interaction."""
44 | if isinstance(inter, disnake.MessageCommandInteraction):
45 | return cls(inter.target.content)
46 | return cls(inter.message.content)
47 |
48 | @classmethod
49 | def from_message(cls, message: disnake.Message) -> "MessageContext":
50 | """Create a MessageContext from a message."""
51 | return cls(content=message.content)
52 |
--------------------------------------------------------------------------------
/monty/exts/core/global_checks.py:
--------------------------------------------------------------------------------
1 | import disnake
2 | from disnake.ext import commands
3 |
4 | from monty.bot import Monty
5 | from monty.log import get_logger
6 | from monty.metadata import ExtMetadata
7 |
8 |
9 | EXT_METADATA = ExtMetadata(core=True)
10 |
11 |
12 | logger = get_logger(__name__)
13 |
14 |
15 | class GlobalCheck(commands.Cog):
16 | """Global checks for monty."""
17 |
18 | def __init__(self, bot: Monty) -> None:
19 | self.bot = bot
20 | self._bot_invite_link: str = ""
21 |
22 | async def cog_load(self) -> None:
23 | """Run set_invite_link after the bot is ready."""
24 | await self.bot.wait_until_ready()
25 | await self.set_invite_link()
26 |
27 | async def set_invite_link(self) -> None:
28 | """Set the invite link for the bot."""
29 | if self._bot_invite_link:
30 | return
31 |
32 | # TODO: don't require a fake guild object
33 | class FakeGuild:
34 | id: str = "{guild_id}"
35 |
36 | guild = FakeGuild
37 | self._bot_invite_link = disnake.utils.oauth_url(
38 | self.bot.user.id,
39 | disable_guild_select=True,
40 | guild=guild, # type: ignore # this is totally wrong
41 | scopes={"applications.commands", "bot"},
42 | permissions=self.bot.invite_permissions,
43 | )
44 |
45 | async def bot_check_once(self, ctx: commands.Context) -> bool:
46 | """Require all commands be in guild."""
47 | if ctx.guild:
48 | return True
49 | if await self.bot.is_owner(ctx.author):
50 | return True
51 | raise commands.NoPrivateMessage
52 |
53 |
54 | def setup(bot: Monty) -> None:
55 | """Add the global checks to the bot."""
56 | bot.add_cog(GlobalCheck(bot))
57 |
--------------------------------------------------------------------------------
/monty/exts/meta/timed.py:
--------------------------------------------------------------------------------
1 | from copy import copy
2 | from time import perf_counter
3 | from typing import TYPE_CHECKING
4 |
5 | from disnake.ext import commands
6 |
7 | from monty.bot import Monty
8 |
9 |
10 | if TYPE_CHECKING:
11 | import disnake
12 |
13 |
14 | class TimedCommands(commands.Cog, name="Timed Commands"):
15 | """Time the command execution of a command."""
16 |
17 | @staticmethod
18 | async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context:
19 | """Get a new execution context for a command."""
20 | msg: disnake.Message = copy(ctx.message)
21 | msg.content = f"{ctx.prefix}{command}"
22 |
23 | return await ctx.bot.get_context(msg)
24 |
25 | @commands.command(name="timed", aliases=("time", "t"))
26 | async def timed(self, ctx: commands.Context, *, command: str) -> None:
27 | """Time the command execution of a command."""
28 | new_ctx = await self.create_execution_context(ctx, command)
29 |
30 | if not new_ctx.command:
31 | help_command = f"{ctx.prefix}help"
32 | error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands."
33 |
34 | await ctx.send(error)
35 | return
36 |
37 | if new_ctx.command.qualified_name == "timed":
38 | await ctx.send("You are not allowed to time the execution of the `timed` command.")
39 | return
40 |
41 | t_start = perf_counter()
42 | await new_ctx.command.invoke(new_ctx)
43 | t_end = perf_counter()
44 |
45 | await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.")
46 |
47 |
48 | def setup(bot: Monty) -> None:
49 | """Load the Timed cog."""
50 | bot.add_cog(TimedCommands())
51 |
--------------------------------------------------------------------------------
/monty/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | import os
4 | from functools import partial, partialmethod
5 |
6 | import disnake
7 | import sentry_sdk
8 | from disnake.ext import commands
9 | from sentry_sdk.integrations.logging import LoggingIntegration
10 | from sentry_sdk.integrations.redis import RedisIntegration
11 |
12 |
13 | try:
14 | import rich.traceback
15 | except ModuleNotFoundError:
16 | pass
17 | else:
18 | rich.traceback.install(show_locals=True, word_wrap=True, suppress=[disnake])
19 |
20 | ####################
21 | # NOTE: do not import any other modules from monty before the `log.setup()` call
22 | ####################
23 | from monty import log
24 |
25 |
26 | sentry_logging = LoggingIntegration(
27 | level=5, # this is the same as logging.TRACE
28 | event_level=logging.WARNING,
29 | )
30 |
31 | sentry_sdk.init(
32 | dsn=os.environ.get("SENTRY_DSN"),
33 | integrations=[
34 | sentry_logging,
35 | RedisIntegration(),
36 | ],
37 | release=f"monty@{os.environ.get('GIT_SHA', 'dev')}",
38 | )
39 |
40 | log.setup()
41 |
42 |
43 | from monty import monkey_patches # noqa: E402 # we need to set up logging before importing anything else
44 |
45 |
46 | # On Windows, the selector event loop is required for aiodns.
47 | if os.name == "nt":
48 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
49 |
50 | # Monkey-patch discord.py decorators to use the both the Command and Group subclasses which supports root aliases.
51 | # Must be patched before any cogs are added.
52 | commands.command = partial(commands.command, cls=monkey_patches.Command)
53 | commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=monkey_patches.Command) # type: ignore
54 |
55 | commands.group = partial(commands.group, cls=monkey_patches.Group)
56 | commands.GroupMixin.group = partialmethod(commands.GroupMixin.group, cls=monkey_patches.Group) # type: ignore
57 |
--------------------------------------------------------------------------------
/monty/alembic/versions/2022_06_28_feature_rollouts.py:
--------------------------------------------------------------------------------
1 | """feature rollouts
2 |
3 | Revision ID: 2022_06_28_1
4 | Revises: 19e4f2aee642
5 | Create Date: 2022-06-28 10:46:16.693218
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = "2022_06_28_1"
15 | down_revision = "19e4f2aee642"
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade() -> None:
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.create_table(
23 | "rollouts",
24 | sa.Column("id", sa.Integer(), nullable=False),
25 | sa.Column("name", sa.String(length=100), nullable=False),
26 | sa.Column("active", sa.Boolean(), nullable=False),
27 | sa.Column("rollout_by", sa.DateTime(timezone=True), nullable=True),
28 | sa.Column("rollout_to_percent", sa.SmallInteger(), nullable=False),
29 | sa.Column("rollout_hash_low", sa.SmallInteger(), nullable=False),
30 | sa.Column("rollout_hash_high", sa.SmallInteger(), nullable=False),
31 | sa.Column("update_every", sa.SmallInteger(), nullable=False),
32 | sa.Column(
33 | "hashes_last_updated",
34 | sa.DateTime(timezone=True),
35 | server_default=sa.text("now()"),
36 | nullable=False,
37 | ),
38 | sa.PrimaryKeyConstraint("id"),
39 | sa.UniqueConstraint("name"),
40 | )
41 | op.add_column("features", sa.Column("rollout", sa.Integer(), nullable=True))
42 | op.create_foreign_key("fk_features_rollouts_id_rollout", "features", "rollouts", ["rollout"], ["id"])
43 | # ### end Alembic commands ###
44 |
45 |
46 | def downgrade() -> None:
47 | # ### commands auto generated by Alembic - please adjust! ###
48 | op.drop_constraint("fk_features_rollouts_id_rollout", "features", type_="foreignkey")
49 | op.drop_column("features", "rollout")
50 | op.drop_table("rollouts")
51 | # ### end Alembic commands ###
52 |
--------------------------------------------------------------------------------
/monty/exts/core/uptime_ping.py:
--------------------------------------------------------------------------------
1 | import yarl
2 | from disnake.ext import commands, tasks
3 |
4 | from monty import constants
5 | from monty.bot import Monty
6 | from monty.log import get_logger
7 | from monty.metadata import ExtMetadata
8 |
9 |
10 | EXT_METADATA = ExtMetadata(core=True)
11 | logger = get_logger(__name__)
12 |
13 |
14 | class UptimePing(commands.Cog):
15 | """Pong a remote server for uptime monitoring."""
16 |
17 | def __init__(self, bot: Monty) -> None:
18 | assert constants.Monitoring.ping_url is not None
19 |
20 | self.bot = bot
21 | self._url = yarl.URL(constants.Monitoring.ping_url)
22 | if self._url:
23 | self.uptime_monitor.start()
24 |
25 | def cog_unload(self) -> None:
26 | """Stop existing tasks on cog unload."""
27 | self.uptime_monitor.cancel()
28 |
29 | def get_url(self) -> str:
30 | """Get the uptime URL with proper formatting. The result of this method should not be cached."""
31 | queries = {}
32 | for param, value in constants.Monitoring.ping_query_params.items():
33 | if callable(value):
34 | value = value(self.bot)
35 | queries[param] = value
36 |
37 | return str(self._url.update_query(**queries))
38 |
39 | @tasks.loop(seconds=constants.Monitoring.ping_interval)
40 | async def uptime_monitor(self) -> None:
41 | """Send an uptime ack if uptime monitoring is enabled."""
42 | url = self.get_url()
43 | async with self.bot.http_session.disabled(), self.bot.http_session.get(url):
44 | pass
45 |
46 | @uptime_monitor.before_loop
47 | async def before_uptime_monitor(self) -> None:
48 | """Wait until the bot is ready to send an uptime ack."""
49 | await self.bot.wait_until_ready()
50 |
51 |
52 | def setup(bot: Monty) -> None:
53 | """Add the Uptime Ping cog to the bot."""
54 | if not constants.Monitoring.ping_url:
55 | logger.info("Uptime monitoring is not enabled, skipping loading the cog.")
56 | return
57 | bot.add_cog(UptimePing(bot))
58 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *.cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 | local_settings.py
55 |
56 | # Flask stuff:
57 | instance/
58 | .webassets-cache
59 |
60 | # Scrapy stuff:
61 | .scrapy
62 |
63 | # Sphinx documentation
64 | docs/_build/
65 |
66 | # PyBuilder
67 | target/
68 |
69 | # Jupyter Notebook
70 | .ipynb_checkpoints
71 |
72 | # pyenv
73 | .python-version
74 |
75 | # celery beat schedule file
76 | celerybeat-schedule
77 |
78 | # SageMath parsed files
79 | *.sage.py
80 |
81 | # dotenv
82 | .env
83 |
84 | # virtualenv
85 | .venv
86 | venv/
87 | ENV/
88 |
89 | # Spyder project settings
90 | .spyderproject
91 | .spyproject
92 |
93 | # Rope project settings
94 | .ropeproject
95 |
96 | # mkdocs documentation
97 | /site
98 |
99 | # mypy
100 | .mypy_cache/
101 |
102 | # PyCharm
103 | .idea/
104 |
105 | # VSCode
106 | .vscode/
107 |
108 | # Vagrant
109 | .vagrant
110 |
111 | # Logfiles
112 | log.*
113 | *.log.*
114 | !log.py
115 |
116 | # Custom user configuration
117 | config.yml
118 | docker-compose.override.yml
119 |
120 | # xmlrunner unittest XML reports
121 | TEST-**.xml
122 |
123 | # Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder
124 | .DS_Store
125 |
--------------------------------------------------------------------------------
/monty/alembic/versions/19e4f2aee642_guild_features.py:
--------------------------------------------------------------------------------
1 | """guild features
2 |
3 | Revision ID: 19e4f2aee642
4 | Revises: 7d2f79cf061c
5 | Create Date: 2022-06-25 02:20:19.273814
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 | from sqlalchemy.dialects import postgresql
12 |
13 |
14 | # revision identifiers, used by Alembic.
15 | revision = "19e4f2aee642"
16 | down_revision = "7d2f79cf061c"
17 | branch_labels = None
18 | depends_on = None
19 |
20 |
21 | def upgrade() -> None:
22 | # ### commands auto generated by Alembic - please adjust! ###
23 | op.create_table(
24 | "features",
25 | sa.Column("name", sa.String(length=50), nullable=False),
26 | sa.Column("enabled", sa.Boolean(), nullable=True),
27 | sa.PrimaryKeyConstraint("name"),
28 | )
29 | op.create_table(
30 | "guilds",
31 | sa.Column("id", sa.BigInteger(), nullable=False),
32 | sa.Column("features", postgresql.ARRAY(sa.String(length=50)), server_default="{}", nullable=False),
33 | sa.PrimaryKeyConstraint("id"),
34 | )
35 | op.add_column("guild_config", sa.Column("guild", sa.BigInteger(), nullable=True))
36 | op.create_unique_constraint("guild_config_guild_key", "guild_config", ["guild"])
37 | op.create_foreign_key("fk_guild_config_guilds_id_guild", "guild_config", "guilds", ["guild"], ["id"])
38 | # ### end Alembic commands ###
39 |
40 | # we want to migrate to the guilds table, so this means creating guilds data
41 | op.execute("INSERT INTO guilds (id) SELECT id FROM guild_config")
42 |
43 | # then we want to update the guild_config guild column with the guilds we just created
44 | op.execute("UPDATE ONLY guild_config SET guild = (SELECT id FROM guilds WHERE guild_config.id = guilds.id)")
45 |
46 |
47 | def downgrade() -> None:
48 | # ### commands auto generated by Alembic - please adjust! ###
49 | op.drop_constraint("fk_guild_config_guilds_id_guild", "guild_config", type_="foreignkey")
50 | op.drop_constraint("guild_config_guild_key", "guild_config", type_="unique")
51 | op.drop_column("guild_config", "guild")
52 | op.drop_table("guilds")
53 | op.drop_table("features")
54 | # ### end Alembic commands ###
55 |
--------------------------------------------------------------------------------
/monty/database/package.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import TYPE_CHECKING
3 |
4 | import sqlalchemy as sa
5 | from sqlalchemy.ext.mutable import MutableList
6 | from sqlalchemy.orm import Mapped, mapped_column, validates
7 |
8 | from .base import Base
9 |
10 |
11 | NAME_REGEX = re.compile(r"^[a-z0-9_]+$")
12 |
13 | if TYPE_CHECKING:
14 | hybrid_property = property
15 | else:
16 | from sqlalchemy.ext.hybrid import hybrid_property
17 |
18 |
19 | class PackageInfo(Base):
20 | """Represents the package information for a documentation inventory."""
21 |
22 | __tablename__ = "docs_inventory"
23 |
24 | name: Mapped[str] = mapped_column(
25 | sa.String(length=50),
26 | primary_key=True,
27 | )
28 | inventory_url: Mapped[str] = mapped_column(sa.Text)
29 | _base_url: Mapped[str | None] = mapped_column(sa.Text, nullable=True, default=None, name="base_url")
30 | hidden: Mapped[bool] = mapped_column(sa.Boolean, default=False, server_default="false", nullable=False)
31 | guilds_whitelist: Mapped[list[int] | None] = mapped_column(
32 | MutableList.as_mutable(sa.ARRAY(sa.BigInteger)),
33 | nullable=True,
34 | default=[],
35 | server_default=sa.text("ARRAY[]::bigint[]"),
36 | )
37 | guilds_blacklist: Mapped[list[int] | None] = mapped_column(
38 | MutableList.as_mutable(sa.ARRAY(sa.BigInteger)),
39 | nullable=True,
40 | default=[],
41 | server_default=sa.text("ARRAY[]::bigint[]"),
42 | )
43 |
44 | @hybrid_property
45 | def base_url(self) -> str: # noqa: D102
46 | if self._base_url:
47 | return self._base_url
48 | return self.inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/"
49 |
50 | @base_url.setter
51 | def base_url(self, value: str | None) -> None:
52 | self._base_url = value
53 |
54 | @validates("name")
55 | def validate_name(self, key: str, name: str) -> str:
56 | """Validate all names are of the format of valid python package names."""
57 | if not NAME_REGEX.fullmatch(name):
58 | err = f"The provided package name '{name}' does not match the name regex {NAME_REGEX!s}"
59 | raise ValueError(err)
60 | return name
61 |
--------------------------------------------------------------------------------
/docs/assets/css/extra_admonitions.css:
--------------------------------------------------------------------------------
1 | /*Template taken from mkdocs-material docs, and svg and color taken from github icons*/
2 | :root {
3 | --md-admonition-icon--important: url('data:image/svg+xml;charset=utf-8,');
4 | --md-admonition-icon--caution: url('data:image/svg+xml;charset=utf-8,');
5 | }
6 | .md-typeset .admonition.important,
7 | .md-typeset details.important {
8 | border-color: rgb(130, 80, 223);
9 | }
10 | .md-typeset .important > .admonition-title,
11 | .md-typeset .important > summary {
12 | background-color: rgba(130, 80, 223, 0.1);
13 | }
14 | .md-typeset .important > .admonition-title::before,
15 | .md-typeset .important > summary::before {
16 | background-color: rgb(130, 80, 223);
17 | -webkit-mask-image: var(--md-admonition-icon--important);
18 | mask-image: var(--md-admonition-icon--important);
19 | }
20 | .md-typeset .admonition.caution,
21 | .md-typeset details.caution {
22 | border-color: rgb(207, 34, 46);
23 | }
24 | .md-typeset .caution > .admonition-title,
25 | .md-typeset .caution > summary {
26 | background-color: rgba(207, 34, 46, 0.1);
27 | }
28 | .md-typeset .caution > .admonition-title::before,
29 | .md-typeset .caution > summary::before {
30 | background-color: rgb(207, 34, 46);
31 | -webkit-mask-image: var(--md-admonition-icon--caution);
32 | mask-image: var(--md-admonition-icon--caution);
33 | }
34 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | x-restart-policy: &restart_policy
2 | restart: unless-stopped
3 |
4 | services:
5 | postgres:
6 | << : *restart_policy
7 | image: postgres:13-alpine
8 | ports:
9 | - "127.0.0.1:5432:5432"
10 | environment:
11 | POSTGRES_DB: monty
12 | POSTGRES_PASSWORD: monty
13 | POSTGRES_USER: monty
14 | healthcheck:
15 | test: ["CMD-SHELL", "pg_isready -U monty -d monty"]
16 | interval: 1s
17 | timeout: 1s
18 | retries: 10
19 |
20 | redis:
21 | << : *restart_policy
22 | image: redis:latest
23 | ports:
24 | - "127.0.0.1:6379:6379"
25 | command:
26 | --save 60 1
27 | volumes:
28 | - redis:/data
29 | healthcheck:
30 | test: ["CMD-SHELL", "[ $$(redis-cli ping) = 'PONG' ]"]
31 | interval: 1s
32 | timeout: 1s
33 | retries: 5
34 |
35 | mitmproxy:
36 | << : *restart_policy
37 | image: mitmproxy/mitmproxy:latest
38 | hostname: mitmproxy
39 | command: ["mitmweb", "--web-host", "0.0.0.0", "--set", "web_password=monty-proxy"]
40 | ports:
41 | - "127.0.0.1:8080:8080"
42 | - "127.0.0.1:8081:8081"
43 |
44 | snekbox:
45 | << : *restart_policy
46 | image: ghcr.io/onerandomusername/snekbox:latest
47 | hostname: snekbox
48 | privileged: true
49 | ports:
50 | - "127.0.0.1:8060:8060"
51 | init: true
52 | ipc: none
53 | environment:
54 | PYTHONDONTWRITEBYTECODE: 1
55 | volumes:
56 | - user-base:/snekbox/user_base
57 |
58 | monty:
59 | << : *restart_policy
60 | container_name: monty-python
61 | image: ghcr.io/onerandomusername/monty-python:latest
62 | build:
63 | context: .
64 | dockerfile: Dockerfile
65 | tty: true
66 |
67 | depends_on:
68 | postgres:
69 | condition: service_healthy
70 | restart: true
71 | redis:
72 | condition: service_healthy
73 | mitmproxy:
74 | condition: service_started
75 |
76 | environment:
77 | - REDIS_URI=redis://redis:6379
78 | - DB_BIND=postgresql+asyncpg://monty:monty@postgres:5432/monty
79 | - USE_FAKEREDIS=false
80 | - SNEKBOX_URL=http://snekbox:8060/
81 | - BOT_PROXY_URL=http://mitmproxy:8080/
82 |
83 | env_file:
84 | - .env
85 |
86 | volumes:
87 | - ./monty:/bot/monty
88 |
89 | volumes:
90 | user-base:
91 | redis:
92 |
--------------------------------------------------------------------------------
/monty/exts/core/gateway.py:
--------------------------------------------------------------------------------
1 | import collections
2 | from typing import TYPE_CHECKING
3 |
4 | import disnake
5 | from disnake.ext import commands
6 |
7 | from monty.bot import get_logger
8 | from monty.metadata import ExtMetadata
9 | from monty.utils.messages import DeleteButton
10 |
11 |
12 | if TYPE_CHECKING:
13 | from monty.bot import Monty
14 |
15 | EXT_METADATA = ExtMetadata(core=True)
16 |
17 | log = get_logger(__name__)
18 |
19 |
20 | class GatewayLog(commands.Cog):
21 | """Logs gateway events."""
22 |
23 | def __init__(self, bot: "Monty") -> None:
24 | self.bot = bot
25 | self.log = get_logger("monty.gateway")
26 |
27 | @property
28 | def socket_events(self) -> collections.Counter[str]:
29 | """A counter of all logged events."""
30 | return self.bot.socket_events
31 |
32 | @commands.Cog.listener()
33 | async def on_socket_event_type(self, event_type: str) -> None:
34 | """Logs all socket events."""
35 | self.log.debug(f"Socket event: {event_type}")
36 | self.socket_events[event_type] += 1
37 |
38 | @commands.command(name="gw", hidden=True)
39 | async def gateway(self, ctx: commands.Context) -> None:
40 | """Displays gateway event statistics."""
41 | name_padding = max(map(len, self.socket_events))
42 | count_padding = max(len(f"{count:,}") for count in self.socket_events.values()) if self.socket_events else 0
43 | output = (
44 | "\n".join(
45 | f"**`{event: <{name_padding}}`**`{count: >{count_padding},}`"
46 | for event, count in sorted(self.socket_events.items(), key=lambda x: x[1], reverse=True)
47 | )
48 | or "No events have been logged yet."
49 | )
50 | components = [
51 | disnake.ui.TextDisplay(output),
52 | DeleteButton(
53 | ctx.author.id,
54 | allow_manage_messages=False,
55 | initial_message=(ctx.message if ctx.app_permissions.manage_messages else None),
56 | ),
57 | ]
58 | await ctx.send(components=components, allowed_mentions=disnake.AllowedMentions.none())
59 |
60 | async def cog_check(self, ctx: commands.Context) -> bool:
61 | """Enforce only the bot owner can use these commands."""
62 | if not await self.bot.is_owner(ctx.author):
63 | msg = "This command can only be used by the bot owner(s)."
64 | raise commands.NotOwner(msg)
65 | return True
66 |
67 |
68 | def setup(bot: "Monty") -> None:
69 | """Loads the GatewayLog cog."""
70 | bot.add_cog(GatewayLog(bot))
71 |
--------------------------------------------------------------------------------
/monty/errors.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import random
4 | from typing import TYPE_CHECKING
5 |
6 | from disnake.ext import commands
7 |
8 | from monty.utils.responses import FAILURE_HEADERS
9 |
10 |
11 | if TYPE_CHECKING:
12 | from collections.abc import Hashable
13 |
14 |
15 | class APIError(commands.CommandError):
16 | """Raised when an external API (eg. Wikipedia) returns an error response."""
17 |
18 | def __init__(
19 | self,
20 | error_msg: str | None = None,
21 | *,
22 | api: str,
23 | status_code: int,
24 | ) -> None:
25 | super().__init__(error_msg)
26 | self.api = api
27 | self.status_code = status_code
28 | self.error_msg = error_msg
29 |
30 | @property
31 | def title(self) -> str:
32 | """Return a title embed."""
33 | return f"Something went wrong with {self.api}"
34 |
35 |
36 | class BotAccountRequired(commands.CheckFailure):
37 | """Raised when the bot needs to be in the guild."""
38 |
39 | def __init__(self, msg: str) -> None:
40 | self._error_title = "Bot Account Required"
41 | self.msg = msg
42 |
43 | def __str__(self) -> str:
44 | return self.msg
45 |
46 |
47 | class FeatureDisabled(commands.CheckFailure):
48 | """Raised when a feature is attempted to be used that is currently disabled for that guild."""
49 |
50 | def __init__(self) -> None:
51 | super().__init__("This feature is currently disabled.")
52 |
53 |
54 | class LockedResourceError(RuntimeError):
55 | """
56 | Exception raised when an operation is attempted on a locked resource.
57 |
58 | Attributes:
59 | `type` -- name of the locked resource's type
60 | `id` -- ID of the locked resource
61 | """
62 |
63 | def __init__(self, resource_type: str, resource_id: Hashable) -> None:
64 | self.type = resource_type
65 | self.id = resource_id
66 |
67 | super().__init__(
68 | f"Cannot operate on {self.type.lower()} `{self.id}`; "
69 | "it is currently locked and in use by another operation."
70 | )
71 |
72 |
73 | class MontyCommandError(commands.CommandError):
74 | def __init__(self, message: str, *, title: str | None = None) -> None:
75 | if not title:
76 | title = random.choice(FAILURE_HEADERS)
77 | self.title = title
78 | super().__init__(message)
79 |
80 |
81 | class OpenDMsRequired(commands.UserInputError):
82 | def __init__(self, message: str | None = None, *args) -> None:
83 | self.title = "Open DMs Required"
84 | if message is None:
85 | message = "I must be able to DM you to run this command. Please open your DMs"
86 | super().__init__(message, *args)
87 |
--------------------------------------------------------------------------------
/mkdocs.yaml:
--------------------------------------------------------------------------------
1 | site_name: Monty Python
2 | site_url: https://monty.arielle.codes/
3 | site_description: A Python and GitHub focused Discord bot to make collaborating on Python projects through Discord easier than ever.
4 | repo_name: onerandomusername/monty-python
5 | repo_url: https://github.com/onerandomusername/monty-python/
6 | edit_uri: edit/main/docs/
7 | copyright: >
8 | Copyright © 2025+ onerandomusername ·
9 | Terms of Service ·
10 | Privacy Policy
11 | theme:
12 | name: material
13 | logo: assets/images/logo.png
14 | palette:
15 | - media: "(prefers-color-scheme)"
16 | toggle:
17 | icon: material/brightness-auto
18 | name: Switch to light mode
19 | primary: blue
20 | accent: yellow
21 | - media: "(prefers-color-scheme: light)"
22 | scheme: default
23 | toggle:
24 | icon: material/brightness-7
25 | name: Switch to dark mode
26 | primary: blue
27 | accent: yellow
28 | - media: "(prefers-color-scheme: dark)"
29 | scheme: slate
30 | toggle:
31 | icon: material/brightness-4
32 | name: Switch to system preference
33 | primary: blue
34 | accent: yellow
35 | features:
36 | - navigation.instant
37 | - navigation.instant.progress
38 | - navigation.indexes
39 | - navigation.sections
40 | - navigation.tracking
41 | - toc.follow
42 | - navigation.top
43 | - search
44 | - search.suggest
45 | - search.highlight
46 | - content.action.edit
47 | - content.action.view
48 | - content.code.copy
49 | - content.code.annotate
50 |
51 |
52 | plugins:
53 | - privacy
54 | - git-revision-date-localized
55 |
56 | markdown_extensions:
57 | - admonition
58 | - pymdownx.details
59 | - pymdownx.tasklist:
60 | custom_checkbox: true
61 | - pymdownx.superfences
62 | - markdown_gfm_admonition
63 | - pymdownx.highlight:
64 | anchor_linenums: true
65 | line_spans: __span
66 | pygments_lang_class: true
67 | - pymdownx.inlinehilite
68 | - pymdownx.snippets
69 | - pymdownx.superfences
70 | - pymdownx.betterem
71 | - tables
72 | - toc:
73 | permalink: true
74 |
75 | extra_css:
76 | - assets/css/extra_admonitions.css
77 |
78 | nav:
79 | - Home: README.md
80 | - Future Plans: future-plans.md
81 | - Commands:
82 | - App Commands: commands/app-commands.md
83 | - Prefix Commands: commands/prefix-commands.md
84 | - Contributing:
85 | - Getting Started: contributing/README.md
86 | - Design:
87 | - contributing/design/index.md
88 | - Configuration System: contributing/design/config.md
89 | - Developer Commands: contributing/developer-commands.md
90 | - Privacy: privacy.md
91 | - Terms of Service: terms.md
92 | extra:
93 | social:
94 | - icon: fontawesome/brands/github
95 | link: https://github.com/onerandomusername/monty-python
96 | - icon: fontawesome/brands/discord
97 | link: https://discord.gg/mPscM4FjWB
98 |
--------------------------------------------------------------------------------
/monty/utils/code.py:
--------------------------------------------------------------------------------
1 | import re
2 | import textwrap
3 | from typing import overload
4 |
5 | from monty.log import get_logger
6 |
7 |
8 | FORMATTED_CODE_REGEX = re.compile(
9 | r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block
10 | r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline)
11 | r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
12 | r"(?P.*?)" # extract all code inside the markup
13 | r"\s*" # any more whitespace before the end of the code markup
14 | r"(?P=delim)", # match the exact same delimiter from the start again
15 | re.DOTALL | re.IGNORECASE, # "." also matches newlines, case insensitive
16 | )
17 | RAW_CODE_REGEX = re.compile(
18 | r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
19 | r"(?P.*?)" # extract all the rest as code
20 | r"\s*$", # any trailing whitespace until the end of the string
21 | re.DOTALL, # "." also matches newlines
22 | )
23 | log = get_logger(__name__)
24 |
25 |
26 | @overload
27 | def prepare_input(code: str, *, require_fenced: bool = False) -> str: ...
28 |
29 |
30 | @overload
31 | def prepare_input(code: str, *, require_fenced: bool = True) -> str | None: ...
32 |
33 |
34 | def prepare_input(code: str, *, require_fenced: bool = False) -> str | None:
35 | """
36 | Extract code from the Markdown, format it, and insert it into the code template.
37 |
38 | If there is any code block, ignore text outside the code block.
39 | Use the first code block, but prefer a fenced code block.
40 | If there are several fenced code blocks, concatenate only the fenced code blocks.
41 | """
42 | if match := list(FORMATTED_CODE_REGEX.finditer(code)):
43 | blocks = [block for block in match if block.group("block")]
44 |
45 | if len(blocks) > 1:
46 | code = "\n".join(block.group("code") for block in blocks)
47 | info = "several code blocks"
48 | elif len(match) > 1:
49 | code = "\n".join(block.group("code") for block in match)
50 | info = "several code blocks"
51 | else:
52 | match = match[0] if len(blocks) == 0 else blocks[0]
53 | code, block, lang, delim = match.group("code", "block", "lang", "delim")
54 | if block:
55 | info = (f"'{lang}' highlighted" if lang else "plain") + " code block"
56 | else:
57 | info = f"{delim}-enclosed inline code"
58 | elif require_fenced:
59 | return None
60 | elif match := RAW_CODE_REGEX.fullmatch(code):
61 | code = match.group("code")
62 | info = "unformatted or badly formatted code"
63 | else:
64 | return None
65 |
66 | code = textwrap.dedent(code)
67 | log.trace("Extracted %s for evaluation:\n%s", info, code)
68 | return code
69 |
--------------------------------------------------------------------------------
/docs/contributing/developer-commands.md:
--------------------------------------------------------------------------------
1 | ---
2 | description: Admin only commands for managing Monty
3 | ---
4 |
5 | # Developer Commands
6 |
7 | Monty Python has multiple developer specific commands that can be used within
8 | Discord to manage certain aspects of Monty.
9 |
10 | > [!NOTE]
11 | > The following commands are used **within Discord** and should NOT be put into
12 | > a terminal.
13 |
14 | ### Feature Flags
15 |
16 | Some features are locked behind Feature Flags. Monty implements per-server
17 | Feature Flags in order to test new features in production without affecting
18 | other servers. These flags can be enabled on a per-guild basis, or globally.
19 |
20 | The dashboard for features is accessible under the `features` command.
21 | Generally, each feature is named with what it corresponds to, but the list of
22 | corresponding features is located in
23 | [`constants.py`](https://github.com/onerandomusername/monty-python/blob/main/monty/constants.py)
24 |
25 | ### Extension Management
26 |
27 | Monty has commands to manage its extension modules in a strong manner, and to
28 | provide developer friendly entrypoints to those working on new features or
29 | bugfixes.
30 |
31 | The extension management commands can be used to load or unload specific
32 | extensions or modules for development.
33 |
34 | - `ext list`
35 | - Get a list of all extensions, including their loaded status.
36 | - `ext load [extensions...]`
37 | - Load extensions given their fully qualified or unqualified names.
38 | - `ext reload [extensions...]`
39 | - Reload extensions given their fully qualified or unqualified names.
40 | - `ext unload [extensions...]`
41 | - Unload currently loaded extensions given their fully qualified or
42 | unqualified names.
43 | - `ext autoreload`
44 | - Autoreload of modified extensions. This watches for file edits, and will
45 | reload modified extensions automatically.
46 |
47 | > [!TIP]
48 | > The library that is required for the autoreload command is only installed for
49 | > local development and is not installed within the docker container.
50 |
51 | ### Eval command
52 |
53 | What good would developing be without a developer locked evaluation command?
54 | Monty has a first-class asyncio-compatible evaluation command for the bot owner
55 | only. This command runs within the event loop, and **within the bot context**.
56 |
57 | > [!CAUTION]
58 | > Do not run any untrusted data with this command. You could do terrible things
59 | > to your bot and computer.
60 |
61 | This command is named `ieval`, short for internal evaluation. It processes with
62 | the same parsing rules as the `eval` command, which uses the snekbox backend,
63 | but this command is run within the bot. This is a developer-only command, as you
64 | can easily print the bot token or do other nefarious things.
65 |
66 | ```py
67 | -ieval await ctx.send("this is from the bot context!")
68 | ```
69 |
70 | This command was recently rewritten, and has a few known bugs, see
71 | for more details.
72 | If you'd like to fix these, please do!
73 |
--------------------------------------------------------------------------------
/monty/exts/python/realpython.py:
--------------------------------------------------------------------------------
1 | from html import unescape
2 | from urllib.parse import quote_plus
3 |
4 | import disnake
5 | from disnake.ext import commands
6 |
7 | from monty import bot
8 | from monty.constants import Colours
9 | from monty.errors import APIError, MontyCommandError
10 | from monty.log import get_logger
11 | from monty.utils import responses
12 |
13 |
14 | logger = get_logger(__name__)
15 |
16 |
17 | API_ROOT = "https://realpython.com/search/api/v1/"
18 | ARTICLE_URL = "https://realpython.com{article_url}"
19 | SEARCH_URL = "https://realpython.com/search?q={user_search}"
20 |
21 |
22 | ERROR_EMBED = disnake.Embed(
23 | title="Error while searching Real Python",
24 | description="There was an error while trying to reach Real Python. Please try again shortly.",
25 | color=responses.DEFAULT_FAILURE_COLOUR,
26 | )
27 |
28 |
29 | class RealPython(commands.Cog, name="Real Python"):
30 | """User initiated command to search for a Real Python article."""
31 |
32 | def __init__(self, bot: bot.Monty) -> None:
33 | self.bot = bot
34 |
35 | @commands.command(aliases=["rp"])
36 | @commands.cooldown(1, 10, commands.cooldowns.BucketType.user)
37 | async def realpython(self, ctx: commands.Context, *, user_search: str) -> None:
38 | """Send 5 articles that match the user's search terms."""
39 | params = {"q": user_search, "limit": 5}
40 | async with self.bot.http_session.get(url=API_ROOT, params=params) as response:
41 | if response.status != 200:
42 | logger.error(f"Unexpected status code {response.status} from Real Python")
43 | msg = (
44 | "Sorry, there was an error while trying to fetch data from the Real Python website. "
45 | "Please try again in some time. "
46 | "If this issue persists, please report this issue in our support server, see link below."
47 | )
48 | raise APIError(msg, status_code=response.status, api="Real Python")
49 |
50 | data = await response.json()
51 |
52 | articles = data["results"]
53 |
54 | if len(articles) == 0:
55 | raise MontyCommandError(
56 | title=f"No articles found for '{user_search}'",
57 | message="Try broadening your search to show more results.",
58 | )
59 |
60 | article_embed = disnake.Embed(
61 | title="Search results - Real Python",
62 | url=SEARCH_URL.format(user_search=quote_plus(user_search)),
63 | description="Here are the top 5 results:",
64 | color=Colours.orange,
65 | )
66 |
67 | for article in articles:
68 | article_embed.add_field(
69 | name=unescape(article["title"]),
70 | value=ARTICLE_URL.format(article_url=article["url"]),
71 | inline=False,
72 | )
73 | article_embed.set_footer(text="Click the links to go to the articles.")
74 |
75 | await ctx.send(embed=article_embed)
76 |
77 |
78 | def setup(bot: bot.Monty) -> None:
79 | """Load the Real Python Cog."""
80 | bot.add_cog(RealPython(bot))
81 |
--------------------------------------------------------------------------------
/monty/alembic/env.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from logging.config import fileConfig
3 |
4 | from alembic import context
5 | from sqlalchemy import pool
6 | from sqlalchemy.engine import Connection
7 | from sqlalchemy.ext.asyncio import async_engine_from_config
8 |
9 | from monty import constants
10 | from monty.database.base import Base
11 |
12 |
13 | # this is the Alembic Config object, which provides
14 | # access to the values within the .ini file in use.
15 | config = context.config
16 |
17 | # Interpret the config file for Python logging.
18 | # This line sets up loggers basically.
19 | if config.config_file_name is not None:
20 | fileConfig(config.config_file_name)
21 |
22 | # add your model's MetaData object here
23 | # for 'autogenerate' support
24 | # from myapp import mymodel
25 | # target_metadata = mymodel.Base.metadata
26 | target_metadata = Base.metadata
27 |
28 | # other values from the config, defined by the needs of env.py,
29 | # can be acquired:
30 | # my_important_option = config.get_main_option("my_important_option")
31 | # ... etc.
32 | config.set_main_option("sqlalchemy.url", str(constants.Database.postgres_bind))
33 |
34 |
35 | def run_migrations_offline() -> None:
36 | """Run migrations in 'offline' mode.
37 |
38 | This configures the context with just a URL
39 | and not an Engine, though an Engine is acceptable
40 | here as well. By skipping the Engine creation
41 | we don't even need a DBAPI to be available.
42 |
43 | Calls to context.execute() here emit the given string to the
44 | script output.
45 |
46 | """
47 | url = config.get_main_option("sqlalchemy.url")
48 | context.configure(
49 | url=url,
50 | target_metadata=target_metadata,
51 | literal_binds=True,
52 | dialect_opts={"paramstyle": "named"},
53 | )
54 |
55 | with context.begin_transaction():
56 | context.run_migrations()
57 |
58 |
59 | def do_run_migrations(connection: Connection) -> None:
60 | context.configure(connection=connection, target_metadata=target_metadata)
61 |
62 | with context.begin_transaction():
63 | context.run_migrations()
64 |
65 |
66 | async def run_async_migrations() -> None:
67 | """Run migrations in 'online' mode.
68 |
69 | In this scenario we need to create an Engine
70 | and associate a connection with the context.
71 |
72 | """
73 |
74 | connectable = async_engine_from_config(
75 | config.get_section(config.config_ini_section, {}),
76 | prefix="sqlalchemy.",
77 | poolclass=pool.NullPool,
78 | )
79 |
80 | async with connectable.connect() as connection:
81 | await connection.run_sync(do_run_migrations)
82 |
83 | await connectable.dispose()
84 |
85 |
86 | def run_migrations_online() -> None:
87 | """Run migrations in 'online' mode."""
88 | connectable = config.attributes.get("connection", None)
89 |
90 | if connectable is None:
91 | asyncio.run(run_async_migrations())
92 | else:
93 | do_run_migrations(connectable)
94 |
95 |
96 | if context.is_offline_mode():
97 | run_migrations_offline()
98 | else:
99 | run_migrations_online()
100 |
--------------------------------------------------------------------------------
/monty/utils/extensions.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import inspect
3 | import pkgutil
4 | from collections.abc import Generator
5 | from typing import TYPE_CHECKING, NoReturn
6 |
7 | from disnake.ext import commands
8 |
9 | from monty import exts
10 | from monty.log import get_logger
11 |
12 |
13 | if TYPE_CHECKING:
14 | from monty.metadata import ExtMetadata
15 |
16 | log = get_logger(__name__)
17 |
18 |
19 | def unqualify(name: str) -> str:
20 | """Return an unqualified name given a qualified module/package `name`."""
21 | return name.rsplit(".", maxsplit=1)[-1]
22 |
23 |
24 | def walk_extensions() -> Generator[tuple[str, "ExtMetadata"], None, None]:
25 | """Yield extension names from monty.exts subpackage."""
26 | from monty.metadata import ExtMetadata
27 |
28 | def on_error(name: str) -> NoReturn:
29 | raise ImportError(name=name) # pragma: no cover
30 |
31 | skip_modules: set[str] = set()
32 |
33 | for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error):
34 | if unqualify(module.name).startswith("_") or module.name in skip_modules:
35 | # Ignore module/package names starting with an underscore.
36 | continue
37 |
38 | imported = importlib.import_module(module.name)
39 | if not inspect.isfunction(getattr(imported, "setup", None)):
40 | # If it lacks a setup function, it's not an extension.
41 | continue
42 |
43 | # This check only excludes init files which add an extension.
44 | if module.name.endswith(".__init__"):
45 | # Add all submodules to skip list to avoid re-processing.
46 | skip_modules.add(module.name)
47 |
48 | ext_metadata = getattr(imported, "EXT_METADATA", None)
49 | if ext_metadata is not None:
50 | if not isinstance(ext_metadata, ExtMetadata):
51 | if ext_metadata == ExtMetadata:
52 | log.info(
53 | f"{module.name!r} seems to have passed the ExtMetadata class directly to "
54 | "EXT_METADATA. Using defaults."
55 | )
56 | else:
57 | log.error(
58 | f"Extension {module.name!r} contains an invalid EXT_METADATA variable. "
59 | "Loading with metadata defaults."
60 | )
61 | yield module.name, ExtMetadata()
62 | continue
63 |
64 | yield module.name, ext_metadata
65 | continue
66 |
67 | log.trace(f"Extension {module.name!r} is missing an EXT_METADATA variable. Assuming its a normal extension.")
68 |
69 | # Presume Production Mode/Metadata defaults if metadata var does not exist.
70 | yield module.name, ExtMetadata()
71 |
72 |
73 | async def invoke_help_command(ctx: commands.Context) -> None:
74 | """Invoke the help command or default help command if help extensions is not loaded."""
75 | if ctx.bot.get_cog("Help"):
76 | help_command = ctx.bot.get_command("help")
77 | await ctx.invoke(help_command, ctx.command.qualified_name) # type: ignore
78 | return
79 | await ctx.send_help(ctx.command)
80 |
81 |
82 | EXTENSIONS: dict[str, "ExtMetadata"] = {}
83 |
--------------------------------------------------------------------------------
/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = monty/alembic
6 |
7 | # template used to generate migration files
8 | # file_template = %%(rev)s_%%(slug)s
9 |
10 | # sys.path path, will be prepended to sys.path if present.
11 | # defaults to the current working directory.
12 | prepend_sys_path = .
13 |
14 | # timezone to use when rendering the date within the migration file
15 | # as well as the filename.
16 | # If specified, requires the python-dateutil library that can be
17 | # installed by adding `alembic[tz]` to the pip requirements
18 | # string value is passed to dateutil.tz.gettz()
19 | # leave blank for localtime
20 | # timezone =
21 |
22 | # max length of characters to apply to the
23 | # "slug" field
24 | # truncate_slug_length = 40
25 |
26 | # set to 'true' to run the environment during
27 | # the 'revision' command, regardless of autogenerate
28 | # revision_environment = false
29 |
30 | # set to 'true' to allow .pyc and .pyo files without
31 | # a source .py file to be detected as revisions in the
32 | # versions/ directory
33 | # sourceless = false
34 |
35 | # version location specification; This defaults
36 | # to alembic/versions. When using multiple version
37 | # directories, initial revisions must be specified with --version-path.
38 | # The path separator used here should be the separator specified by "version_path_separator" below.
39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
40 |
41 | # version path separator; As mentioned above, this is the character used to split
42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
44 | # Valid values for version_path_separator are:
45 | #
46 | # version_path_separator = :
47 | # version_path_separator = ;
48 | # version_path_separator = space
49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
50 |
51 | # the output encoding used when revision files
52 | # are written from script.py.mako
53 | # output_encoding = utf-8
54 |
55 | [post_write_hooks]
56 | # post_write_hooks defines scripts or Python functions that are run
57 | # on newly generated revision scripts. See the documentation for further
58 | # detail and examples
59 |
60 | # format using "prek"
61 | hooks = pre_commit
62 | pre_commit.type = exec
63 | pre_commit.executable = prek
64 | pre_commit.options = run --files REVISION_SCRIPT_FILENAME
65 |
66 | # Logging configuration
67 | [loggers]
68 | keys = root,sqlalchemy,alembic
69 |
70 | [handlers]
71 | keys = console
72 |
73 | [formatters]
74 | keys = generic
75 |
76 | [logger_root]
77 | level = WARN
78 | handlers = console
79 | qualname =
80 |
81 | [logger_sqlalchemy]
82 | level = WARN
83 | handlers =
84 | qualname = sqlalchemy.engine
85 |
86 | [logger_alembic]
87 | level = INFO
88 | handlers =
89 | qualname = alembic
90 |
91 | [handler_console]
92 | class = StreamHandler
93 | args = (sys.stderr,)
94 | level = NOTSET
95 | formatter = generic
96 |
97 | [formatter_generic]
98 | format = %(levelname)-5.5s [%(name)s] %(message)s
99 | datefmt = %H:%M:%S
100 |
--------------------------------------------------------------------------------
/.github/workflows/status_embed.yaml:
--------------------------------------------------------------------------------
1 | # Sends a status embed to a discord webhook
2 |
3 | name: Status Embed
4 |
5 | on:
6 | workflow_run:
7 | workflows:
8 | - CI
9 | types:
10 | - completed
11 |
12 | permissions: {}
13 |
14 | jobs:
15 | status_embed:
16 | name: Send Status Embed to Discord
17 | runs-on: ubuntu-latest
18 | if: ${{ !endsWith(github.actor, '[bot]') }}
19 | permissions:
20 | actions: read
21 | steps:
22 | # Process the artifact uploaded in the `pull_request`-triggered workflow:
23 | - name: Get Pull Request Information
24 | id: pr_info
25 | if: github.event.workflow_run.event == 'pull_request'
26 | run: |
27 | curl -s -H "Authorization: token $GITHUB_TOKEN" ${GITHUB_EVENT_WORKFLOW_RUN_ARTIFACTS_URL} > artifacts.json
28 | DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url')
29 | [ -z "$DOWNLOAD_URL" ] && exit 1
30 | curl -sSL -H "Authorization: token $GITHUB_TOKEN" -o pull_request_payload.zip $DOWNLOAD_URL || exit 2
31 | unzip -p pull_request_payload.zip > pull_request_payload.json
32 | [ -s pull_request_payload.json ] || exit 3
33 | echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT
34 | echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT
35 | echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT
36 | echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT
37 | env:
38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39 | GITHUB_EVENT_WORKFLOW_RUN_ARTIFACTS_URL: ${{ github.event.workflow_run.artifacts_url }}
40 |
41 | # Send an informational status embed to Discord instead of the
42 | # standard embeds that Discord sends. This embed will contain
43 | # more information and we can fine tune when we actually want
44 | # to send an embed.
45 | - name: GitHub Actions Status Embed for Discord
46 | uses: SebastiaanZ/github-status-embed-for-discord@67f67a60934c0254efd1ed741b5ce04250ebd508 # v0.3.0
47 | with:
48 | # Webhook token
49 | webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
50 | webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
51 |
52 | # We need to provide the information of the workflow that
53 | # triggered this workflow instead of this workflow.
54 | workflow_name: ${{ github.event.workflow_run.name }}
55 | run_id: ${{ github.event.workflow_run.id }}
56 | run_number: ${{ github.event.workflow_run.run_number }}
57 | status: ${{ github.event.workflow_run.conclusion }}
58 | actor: ${{ github.actor }}
59 | repository: ${{ github.repository }}
60 | ref: ${{ github.ref }}
61 | sha: ${{ github.event.workflow_run.head_sha }}
62 |
63 | # Now we can use the information extracted in the previous step:
64 | pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }}
65 | pr_number: ${{ steps.pr_info.outputs.pr_number }}
66 | pr_title: ${{ steps.pr_info.outputs.pr_title }}
67 | pr_source: ${{ steps.pr_info.outputs.pr_source }}
68 |
--------------------------------------------------------------------------------
/monty/exts/python/wikipedia.py:
--------------------------------------------------------------------------------
1 | import re
2 | from html import unescape
3 |
4 | import disnake
5 | from disnake.ext import commands
6 |
7 | from monty.bot import Monty
8 | from monty.errors import APIError
9 | from monty.log import get_logger
10 | from monty.utils import LinePaginator
11 | from monty.utils.helpers import utcnow
12 |
13 |
14 | log = get_logger(__name__)
15 |
16 | SEARCH_API = "https://en.wikipedia.org/w/api.php"
17 | WIKI_PARAMS = {
18 | "action": "query",
19 | "list": "search",
20 | "prop": "info",
21 | "inprop": "url",
22 | "utf8": "",
23 | "format": "json",
24 | "origin": "*",
25 | }
26 | WIKI_THUMBNAIL = (
27 | "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/330px-Wikipedia-logo-v2.svg.png"
28 | )
29 | WIKI_SNIPPET_REGEX = r"(|<[^>]*>)"
30 | WIKI_SEARCH_RESULT = "**[{name}]({url})**\n{description}\n"
31 |
32 |
33 | class WikipediaSearch(commands.Cog, name="Wikipedia Search"):
34 | """Get info from wikipedia."""
35 |
36 | def __init__(self, bot: Monty) -> None:
37 | self.bot = bot
38 |
39 | async def wiki_request(self, channel: disnake.abc.Messageable, search: str) -> list[str]:
40 | """Search wikipedia search string and return formatted first 10 pages found."""
41 | params = WIKI_PARAMS | {"srlimit": 10, "srsearch": search}
42 | async with self.bot.http_session.get(url=SEARCH_API, params=params) as resp:
43 | if resp.status != 200:
44 | log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`")
45 | raise APIError(status_code=resp.status, api="Wikipedia API")
46 |
47 | raw_data = await resp.json()
48 |
49 | if not raw_data.get("query"):
50 | if error := raw_data.get("errors"):
51 | log.error(f"There was an error while communicating with the Wikipedia API: {error}")
52 | raise APIError(status_code=resp.status, api="Wikipedia API")
53 |
54 | lines: list[str] = []
55 | if raw_data["query"]["searchinfo"]["totalhits"]:
56 | for article in raw_data["query"]["search"]:
57 | line = WIKI_SEARCH_RESULT.format(
58 | name=article["title"],
59 | description=unescape(re.sub(WIKI_SNIPPET_REGEX, "", article["snippet"])),
60 | url=f"https://en.wikipedia.org/?curid={article['pageid']}",
61 | )
62 | lines.append(line)
63 |
64 | return lines
65 |
66 | @commands.cooldown(1, 10, commands.BucketType.user)
67 | @commands.command(name="wikipedia", aliases=("wiki",))
68 | async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None:
69 | """Sends paginated top 10 results of Wikipedia search.."""
70 | contents = await self.wiki_request(ctx.channel, search)
71 |
72 | if contents:
73 | embed = disnake.Embed(title="Wikipedia Search Results", colour=disnake.Color.blurple())
74 | embed.set_thumbnail(url=WIKI_THUMBNAIL)
75 | embed.timestamp = utcnow()
76 | await LinePaginator.paginate(contents, ctx, embed)
77 | else:
78 | await ctx.send("Sorry, we could not find a wikipedia article using that search term.")
79 |
80 |
81 | def setup(bot: Monty) -> None:
82 | """Load the WikipediaSearch cog."""
83 | bot.add_cog(WikipediaSearch(bot))
84 |
--------------------------------------------------------------------------------
/monty/exts/filters/webhook_remover.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import disnake
4 | from disnake.ext import commands
5 |
6 | from monty.bot import Monty
7 | from monty.constants import Feature
8 | from monty.log import get_logger
9 |
10 |
11 | WEBHOOK_URL_RE = re.compile(
12 | r"((?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/)\S+\/?", re.IGNORECASE
13 | )
14 |
15 | ALERT_MESSAGE_TEMPLATE = (
16 | "{user}, looks like you posted a Discord webhook URL. Therefore "
17 | "your webhook has been deleted. "
18 | "You can re-create it if you wish to. If you believe this was a "
19 | "mistake, please let us know."
20 | )
21 |
22 |
23 | log = get_logger(__name__)
24 |
25 |
26 | class WebhookRemover(commands.Cog, name="Webhook Remover"):
27 | """Scan messages to detect Discord webhooks links."""
28 |
29 | def __init__(self, bot: Monty) -> None:
30 | self.bot = bot
31 |
32 | async def maybe_delete(self, msg: disnake.Message) -> bool:
33 | """
34 | Maybe delete a message, if we have perms.
35 |
36 | Returns True on success.
37 | """
38 | if not msg.guild:
39 | return False
40 | can_delete = msg.author == msg.guild.me or msg.channel.permissions_for(msg.guild.me).manage_messages
41 | if not can_delete:
42 | return False
43 |
44 | await msg.delete()
45 | return True
46 |
47 | async def delete_and_respond(self, msg: disnake.Message, redacted_url: str, *, webhook_deleted: bool) -> None:
48 | """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`."""
49 | if webhook_deleted:
50 | await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))
51 | delete_state = "The webhook was successfully deleted."
52 | else:
53 | delete_state = "There was an error when deleting the webhook, it might have already been removed."
54 | message = (
55 | f"{msg.author} ({msg.author.id!s}) posted a Discord webhook URL to {msg.channel.id}. {delete_state} "
56 | f"Webhook URL was `{redacted_url}`"
57 | )
58 | log.debug(message)
59 |
60 | @commands.Cog.listener()
61 | async def on_message(self, msg: disnake.Message) -> None:
62 | """Check if a Discord webhook URL is in `message`."""
63 | # Ignore DMs; can't delete messages in there anyway.
64 | if not msg.guild or msg.author.bot:
65 | return
66 | if not await self.bot.guild_has_feature(msg.guild, Feature.DISCORD_WEBHOOK_REMOVER):
67 | return
68 |
69 | matches = WEBHOOK_URL_RE.search(msg.content)
70 | if matches:
71 | async with self.bot.http_session.delete(matches[0]) as resp:
72 | # The Discord API Returns a 204 NO CONTENT response on success.
73 | deleted_successfully = resp.status == 204
74 | await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully)
75 |
76 | @commands.Cog.listener()
77 | async def on_message_edit(self, before: disnake.Message, after: disnake.Message) -> None:
78 | """Check if a Discord webhook URL is in the edited message `after`."""
79 | if before.content == after.content:
80 | return
81 |
82 | await self.on_message(after)
83 |
84 |
85 | def setup(bot: Monty) -> None:
86 | """Load `WebhookRemover` cog."""
87 | bot.add_cog(WebhookRemover(bot))
88 |
--------------------------------------------------------------------------------
/monty/monkey_patches.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | import disnake
4 | import disnake.http
5 | from disnake.ext import commands
6 |
7 | from monty.log import get_logger
8 | from monty.utils.helpers import utcnow
9 |
10 |
11 | log = get_logger(__name__)
12 |
13 |
14 | class Command(commands.Command):
15 | """
16 | A `disnake.ext.commands.Command` subclass which supports root aliases.
17 |
18 | A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
19 | top-level commands rather than being aliases of the command's group. It's stored as an attribute
20 | also named `root_aliases`.
21 | """
22 |
23 | def __init__(self, *args, **kwargs) -> None:
24 | super().__init__(*args, **kwargs)
25 | self.root_aliases = kwargs.get("root_aliases", [])
26 |
27 | if not isinstance(self.root_aliases, (list, tuple)):
28 | msg = "Root aliases of a command must be a list or a tuple of strings."
29 | raise TypeError(msg)
30 |
31 |
32 | class Group(commands.Group):
33 | """
34 | A `disnake.ext.commands.Group` subclass which supports root aliases.
35 |
36 | A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
37 | top-level groups rather than being aliases of the command's group. It's stored as an attribute
38 | also named `root_aliases`.
39 | """
40 |
41 | def __init__(self, *args, **kwargs) -> None:
42 | super().__init__(*args, **kwargs)
43 | self.root_aliases = kwargs.get("root_aliases", [])
44 |
45 | if not isinstance(self.root_aliases, (list, tuple)):
46 | msg = "Root aliases of a group must be a list or a tuple of strings."
47 | raise TypeError(msg)
48 |
49 |
50 | def patch_typing() -> None:
51 | """
52 | Sometimes Discord turns off typing events by throwing 403's.
53 |
54 | Handle those issues by patching the trigger_typing method so it ignores 403's in general.
55 | """
56 | log.debug("Patching send_typing, which should fix things breaking when Discord disables typing events. Stay safe!")
57 |
58 | original = disnake.http.HTTPClient.send_typing
59 | last_403 = None
60 |
61 | async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001
62 | nonlocal last_403
63 | if last_403 and (utcnow() - last_403) < timedelta(minutes=5):
64 | log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.")
65 | return
66 | try:
67 | await original(self, channel_id)
68 | except disnake.Forbidden:
69 | last_403 = utcnow()
70 | log.warning("Got a 403 from typing event!")
71 |
72 | disnake.http.HTTPClient.send_typing = honeybadger_type # type: ignore
73 |
74 |
75 | original_inter_send = disnake.Interaction.send
76 |
77 |
78 | def patch_inter_send() -> None:
79 | """Patch disnake.Interaction.send to always send a message, even if we encounter a race condition."""
80 | log.debug("Patching disnake.Interaction.send before a fix is submitted to the upstream version.")
81 |
82 | async def always_send(self: disnake.Interaction, *args, **kwargs) -> None:
83 | try:
84 | return await original_inter_send(self, *args, **kwargs)
85 | except disnake.HTTPException as e:
86 | if e.code != 40060: # interaction already responded
87 | raise
88 | return await self.followup.send(*args, **kwargs) # type: ignore
89 |
90 | disnake.Interaction.send = always_send
91 |
--------------------------------------------------------------------------------
/monty/exts/core/delete.py:
--------------------------------------------------------------------------------
1 | import disnake
2 | from disnake.ext import commands
3 |
4 | from monty.bot import Monty
5 | from monty.log import get_logger
6 | from monty.metadata import ExtMetadata
7 | from monty.utils.messages import DELETE_ID_V2
8 |
9 |
10 | EXT_METADATA = ExtMetadata(core=True)
11 |
12 | VIEW_DELETE_ID_V1 = "wait_for_deletion_interaction_trash"
13 |
14 | logger = get_logger(__name__)
15 |
16 |
17 | class DeleteManager(commands.Cog):
18 | """Handle delete buttons being pressed."""
19 |
20 | def __init__(self, bot: Monty) -> None:
21 | self.bot = bot
22 |
23 | # button schema
24 | # prefix:PERMS:USERID
25 | # optional :MSGID
26 | @commands.Cog.listener("on_button_click")
27 | async def handle_v2_button(self, inter: disnake.MessageInteraction) -> None:
28 | """Delete a message if the user is authorized to delete the message."""
29 | assert inter.component.custom_id
30 | if not inter.component.custom_id.startswith(DELETE_ID_V2):
31 | return
32 |
33 | custom_id = inter.component.custom_id.removeprefix(DELETE_ID_V2)
34 |
35 | perms, user_id, *extra = custom_id.split(":")
36 | delete_msg = None
37 | if extra and extra[0]:
38 | delete_msg = int(extra[0])
39 |
40 | perms, user_id = int(perms), int(user_id)
41 |
42 | # check if the user id is the allowed user OR check if the user has any of the permissions allowed
43 | if not (is_orig_author := inter.author.id == user_id):
44 | needed_permissions = disnake.Permissions(perms)
45 | user_permissions = inter.permissions
46 | if not needed_permissions.value & user_permissions.value:
47 | await inter.response.send_message("Sorry, this delete button is not for you!", ephemeral=True)
48 | return
49 |
50 | if not hasattr(inter.channel, "guild") or not inter.app_permissions.read_messages:
51 | await inter.response.defer()
52 | await inter.delete_original_message()
53 | return
54 |
55 | await inter.message.delete()
56 | if not delete_msg or not inter.app_permissions.manage_messages or not is_orig_author:
57 | return
58 | if msg := inter.bot.get_message(delete_msg):
59 | if msg.edited_at:
60 | return
61 | else:
62 | msg = inter.channel.get_partial_message(delete_msg)
63 | try:
64 | await msg.delete()
65 | except disnake.NotFound:
66 | pass
67 | except disnake.Forbidden:
68 | logger.warning("Cache is unreliable, or something weird occured.")
69 |
70 | @commands.Cog.listener("on_button_click")
71 | async def handle_v1_buttons(self, inter: disnake.MessageInteraction) -> None:
72 | """Handle old, legacy, buggy v1 deletion buttons that still may exist."""
73 | if inter.component.custom_id != VIEW_DELETE_ID_V1:
74 | return
75 |
76 | # get the button from the view
77 | components = disnake.ui.components_from_message(inter.message)
78 | for comp in disnake.ui.walk_components(components):
79 | if getattr(comp, "custom_id", None) == VIEW_DELETE_ID_V1 and isinstance(comp, disnake.ui.Button):
80 | break
81 | else:
82 | msg = "view doesn't contain the button that was clicked."
83 | raise RuntimeError(msg)
84 |
85 | comp.disabled = True
86 | await inter.response.edit_message(components=components)
87 | await inter.followup.send("This button should not have been enabled, and no longer works.", ephemeral=True)
88 |
89 |
90 | def setup(bot: Monty) -> None:
91 | """Add the DeleteManager to the bot."""
92 | bot.add_cog(DeleteManager(bot))
93 |
--------------------------------------------------------------------------------
/monty/utils/rollouts.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import hashlib
3 | import math
4 | import random
5 |
6 | from monty.database import Rollout
7 |
8 |
9 | def update_counts_to_time(rollout: Rollout, current_time: datetime.datetime) -> tuple[int, int]:
10 | """Calculate the new rollout hash levels for the current time, in relation the scheduled time."""
11 | if rollout.rollout_by is None:
12 | msg = "rollout must have rollout_by set."
13 | raise RuntimeError(msg)
14 |
15 | # if the current time is after rollout_by, return the current values
16 | if rollout.rollout_by < current_time:
17 | return rollout.rollout_hash_low, rollout.rollout_hash_high
18 |
19 | # if we're within a 5 minute range complete the rollout
20 | if abs(rollout.rollout_by - current_time) < datetime.timedelta(minutes=5):
21 | return find_new_hash_levels(rollout, goal_percent=rollout.rollout_to_percent)
22 |
23 | old_level = compute_current_percent(rollout) * 100
24 | goal = rollout.rollout_to_percent
25 | seconds_elapsed = (current_time - rollout.hashes_last_updated).total_seconds()
26 | total_seconds = (rollout.rollout_by - rollout.hashes_last_updated).total_seconds()
27 |
28 | new_diff = round(seconds_elapsed / (total_seconds) * (goal - old_level), 2)
29 | return find_new_hash_levels(rollout, new_diff + old_level)
30 |
31 |
32 | def compute_current_percent(rollout: Rollout) -> float:
33 | """Computes the current rollout percentage."""
34 | return (rollout.rollout_hash_high - rollout.rollout_hash_low) / 10_000
35 |
36 |
37 | def find_new_hash_levels(rollout: Rollout, goal_percent: float) -> tuple[int, int]:
38 | """Calcuate the new hash levels from the provided goal percentage."""
39 | # the goal_percent comes as 0 to 100, instead of 0 to 1.
40 | goal_percent = round(goal_percent / 100, 5)
41 | high: float = rollout.rollout_hash_high
42 | low: float = rollout.rollout_hash_low
43 |
44 | # this is the goal result of hash_high minus hash_low
45 | needed_difference = math.floor(goal_percent * 10_000)
46 | current_difference = high - low
47 |
48 | if current_difference > needed_difference:
49 | msg = "the current percent is above the new goal percent."
50 | raise RuntimeError(msg)
51 |
52 | if current_difference == needed_difference:
53 | # shortcut and return the existing values
54 | return low, high
55 |
56 | # difference is the total amount that needs to be added to the range right now
57 | difference = needed_difference - current_difference
58 |
59 | if low == 0:
60 | # can't change the low hash at all, so we just change the high hash
61 | high += difference
62 | return low, high
63 |
64 | if high == 10_000:
65 | # can't change the high hash at all, so we just change the low hash
66 | low -= difference
67 | return low, high
68 |
69 | # do some math to compute adding a random amount to each
70 | add_to_low = min(random.choice(range(0, difference, 50)), low)
71 |
72 | difference -= add_to_low
73 | low -= add_to_low
74 | high += difference
75 |
76 | return low, high
77 |
78 |
79 | def is_rolled_out_to(id: int, *, rollout: Rollout, include_rollout_id: bool = True) -> bool:
80 | """
81 | Check if the provided rollout is rolled out to the provided Discord ID.
82 |
83 | This method hashes the rollout name with the ID and checks if the result
84 | is within hash_low and hash_high.
85 | """
86 | to_hash = rollout.name + ":" + str(id)
87 | if include_rollout_id:
88 | to_hash = str(rollout.id) + ":" + to_hash
89 |
90 | rollout_hash = hashlib.sha256(to_hash.encode()).hexdigest()
91 | hash_int = int(rollout_hash, 16)
92 | return (hash_int % 10_000) in range(rollout.rollout_hash_low, rollout.rollout_hash_high)
93 |
--------------------------------------------------------------------------------
/monty/exts/info/stackoverflow.py:
--------------------------------------------------------------------------------
1 | from html import unescape
2 | from urllib.parse import quote_plus
3 |
4 | import disnake
5 | from disnake.ext import commands
6 |
7 | from monty import bot
8 | from monty.constants import Colours, Emojis
9 | from monty.errors import APIError, MontyCommandError
10 | from monty.log import get_logger
11 |
12 |
13 | logger = get_logger(__name__)
14 |
15 | BASE_URL = "https://api.stackexchange.com/2.2/search/advanced"
16 | SO_PARAMS = {"order": "desc", "sort": "activity", "site": "stackoverflow"}
17 | SEARCH_URL = "https://stackoverflow.com/search?q={query}"
18 |
19 |
20 | class Stackoverflow(commands.Cog, name="Stack Overflow"):
21 | """Contains command to interact with stackoverflow from disnake."""
22 |
23 | def __init__(self, bot: bot.Monty) -> None:
24 | self.bot = bot
25 |
26 | @commands.command(aliases=["so"])
27 | @commands.cooldown(1, 15, commands.cooldowns.BucketType.user)
28 | async def stackoverflow(self, ctx: commands.Context, *, search_query: str) -> None:
29 | """Sends the top 5 results of a search query from stackoverflow."""
30 | params = SO_PARAMS | {"q": search_query}
31 | async with ctx.typing():
32 | async with self.bot.http_session.get(url=BASE_URL, params=params) as response:
33 | if response.status == 200:
34 | data = await response.json()
35 | else:
36 | logger.error(f"Status code is not 200, it is {response.status}")
37 | msg = (
38 | "Sorry, there was an error while trying to fetch data from the StackOverflow website. "
39 | "Please try again in some time. "
40 | "If this issue persists, please report this issue in our support server, see link below."
41 | )
42 | raise APIError(
43 | msg,
44 | status_code=response.status,
45 | api="Stack Overflow",
46 | )
47 | if not data["items"]:
48 | raise MontyCommandError(
49 | title="No results found",
50 | message=f"No search results found for `{search_query}`. "
51 | "Try adjusting your search or searching for fewer terms.",
52 | )
53 |
54 | top5 = data["items"][:5]
55 | encoded_search_query = quote_plus(search_query)
56 | embed = disnake.Embed(
57 | title="Search results - Stackoverflow",
58 | url=SEARCH_URL.format(query=encoded_search_query),
59 | description=f"Here are the top {len(top5)} results:",
60 | color=Colours.orange,
61 | )
62 | embed.check_limits()
63 |
64 | for item in top5:
65 | embed.add_field(
66 | name=unescape(item["title"]),
67 | value=(
68 | f"[{Emojis.reddit_upvote} {item['score']} "
69 | f"{Emojis.stackoverflow_views} {item['view_count']} "
70 | f"{Emojis.reddit_comments} {item['answer_count']} "
71 | f"{Emojis.stackoverflow_tag} {', '.join(item['tags'][:3])}]"
72 | f"({item['link']})"
73 | ),
74 | inline=False,
75 | )
76 | try:
77 | embed.check_limits()
78 | except ValueError:
79 | embed.remove_field(-1)
80 | break
81 |
82 | embed.set_footer(text="View the original link for more results.")
83 |
84 | await ctx.send(embed=embed)
85 |
86 |
87 | def setup(bot: bot.Monty) -> None:
88 | """Load the Stackoverflow Cog."""
89 | bot.add_cog(Stackoverflow(bot))
90 |
--------------------------------------------------------------------------------
/monty/exts/info/docs/_redis_cache.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import TYPE_CHECKING, Any, cast
3 |
4 | import cachingutils
5 | import cachingutils.redis
6 |
7 |
8 | if TYPE_CHECKING:
9 | from collections.abc import Awaitable
10 |
11 | import redis.asyncio
12 |
13 | from ._cog import DocItem
14 |
15 |
16 | WEEK_SECONDS = datetime.timedelta(weeks=1)
17 |
18 |
19 | def item_key(item: "DocItem") -> str:
20 | """Get the redis redis key string from `item`."""
21 | return f"{item.package}:{item.relative_url_path.removesuffix('.html')}"
22 |
23 |
24 | class DocRedisCache(cachingutils.redis.AsyncRedisCache):
25 | """Interface for redis functionality needed by the Doc cog."""
26 |
27 | def __init__(self, *args, **kwargs) -> None:
28 | super().__init__(*args, **kwargs)
29 | self._set_expires = set()
30 | self.namespace = self._prefix
31 | self._redis: redis.asyncio.Redis
32 |
33 | async def set(self, item: "DocItem", value: str) -> None:
34 | """
35 | Set the Markdown `value` for the symbol `item`.
36 |
37 | All keys from a single page are stored together, expiring a week after the first set.
38 | """
39 | redis_key = f"{self.namespace}:{item_key(item)}"
40 | needs_expire = False
41 | if redis_key not in self._set_expires:
42 | # An expire is only set if the key didn't exist before.
43 | # If this is the first time setting values for this key check if it exists and add it to
44 | # `_set_expires` to prevent redundant checks for subsequent uses with items from the same page.
45 | self._set_expires.add(redis_key)
46 | needs_expire = not await self._redis.exists(redis_key)
47 |
48 | await cast("Awaitable[int]", self._redis.hset(redis_key, item.symbol_id, value))
49 | if needs_expire:
50 | await self._redis.expire(redis_key, WEEK_SECONDS)
51 |
52 | async def get(self, item: "DocItem", default: Any = None) -> str | None:
53 | """Return the Markdown content of the symbol `item` if it exists."""
54 | res = await cast(
55 | "Awaitable[bytes | None]", self._redis.hget(f"{self.namespace}:{item_key(item)}", item.symbol_id)
56 | )
57 | if res:
58 | return res.decode()
59 | return default
60 |
61 | async def delete(self, package: str) -> bool:
62 | """Remove all values for `package`; return True if at least one key was deleted, False otherwise."""
63 | connection = self._redis
64 | package_keys = [
65 | package_key async for package_key in connection.scan_iter(match=f"{self.namespace}:{package}:*")
66 | ]
67 | if package_keys:
68 | await connection.delete(*package_keys)
69 | return True
70 | return False
71 |
72 |
73 | class StaleItemCounter(DocRedisCache):
74 | """Manage increment counters for stale `"DocItem"`s."""
75 |
76 | async def increment_for(self, item: "DocItem") -> int:
77 | """
78 | Increment the counter for `item` by 1, set it to expire in 3 weeks and return the new value.
79 |
80 | If the counter didn't exist, initialize it with 1.
81 | """
82 | key = f"{self.namespace}:{item_key(item)}:{item.symbol_id}"
83 | connection = self._redis
84 | await connection.expire(key, WEEK_SECONDS * 3)
85 | return int(await connection.incr(key))
86 |
87 | async def delete(self, package: str) -> bool:
88 | """Remove all values for `package`; return True if at least one key was deleted, False otherwise."""
89 | connection = self._redis
90 | package_keys = [
91 | package_key async for package_key in connection.scan_iter(match=f"{self.namespace}:{package}:*")
92 | ]
93 | if package_keys:
94 | await connection.delete(*package_keys)
95 | return True
96 | return False
97 |
--------------------------------------------------------------------------------
/monty/exts/utils/status_codes.py:
--------------------------------------------------------------------------------
1 | from http import HTTPStatus
2 | from random import choice
3 | from typing import Literal
4 |
5 | import aiohttp
6 | import disnake
7 | from disnake.ext import commands
8 |
9 | from monty.bot import Monty
10 |
11 |
12 | # why must every single 304 be a large penis
13 | HTTP_URLS: dict[str, list[tuple[str, tuple[int, ...]]]] = {
14 | "cat": [
15 | ("https://http.cat/{code}.jpg", ()),
16 | ("https://httpcats.com/{code}.jpg", ()),
17 | ],
18 | "dog": [
19 | ("https://http.dog/{code}.jpg", (304,)),
20 | ("https://httpstatusdogs.com/img/{code}.jpg", (304, 308, 422)),
21 | ],
22 | "goat": [
23 | ("https://httpgoats.com/{code}.jpg", (304, 422)),
24 | ],
25 | }
26 |
27 |
28 | class HTTPStatusCodes(commands.Cog, name="HTTP Status Codes"):
29 | """
30 | Fetch an image depicting HTTP status codes as a dog or a cat or as goat.
31 |
32 | If neither animal is selected a cat or dog or goat is chosen randomly for the given status code.
33 | """
34 |
35 | def __init__(self, bot: Monty) -> None:
36 | self.bot = bot
37 |
38 | @commands.group(
39 | name="http_status",
40 | aliases=("status", "httpstatus", "http"),
41 | invoke_without_command=True,
42 | )
43 | async def http_status_group(self, ctx: commands.Context, code: int) -> None:
44 | """Choose an animal randomly for the given status code."""
45 | subcmd = choice((self.http_cat, self.http_dog, self.http_goat))
46 | await subcmd(ctx, code)
47 |
48 | async def _fetcher(
49 | self,
50 | ctx: commands.Context,
51 | animal: Literal["cat", "dog", "goat"],
52 | code: int,
53 | ) -> None:
54 | url, ignored_codes = choice(HTTP_URLS[animal])
55 | if code in ignored_codes:
56 | # check the other urls for the animal
57 | for url, ignored_codes in HTTP_URLS[animal]:
58 | if code not in ignored_codes:
59 | url = url
60 | break
61 | else:
62 | await ctx.send(f"The {animal} does not have an image for status code {code}.")
63 | return
64 |
65 | embed = disnake.Embed(title=f"**Status: {code}**")
66 | url = url.format(code=code)
67 | try:
68 | HTTPStatus(code)
69 | async with self.bot.http_session.get(url, allow_redirects=False) as response:
70 | response.raise_for_status()
71 | embed.set_image(url=url)
72 |
73 | embed.set_footer(text=f"Powered by {response.url.host}")
74 |
75 | except ValueError:
76 | embed.set_footer(text="Inputted status code does not exist.")
77 |
78 | except aiohttp.ClientResponseError as e:
79 | embed.set_footer(text=f"Inputted status code is not implemented by {e.request_info.url.host} yet.")
80 |
81 | await ctx.send(embed=embed)
82 |
83 | @http_status_group.command(name="cat")
84 | async def http_cat(self, ctx: commands.Context, code: int) -> None:
85 | """Sends an embed with an image of a cat, portraying the status code."""
86 | await self._fetcher(ctx, "cat", code)
87 |
88 | @http_status_group.command(name="dog")
89 | async def http_dog(self, ctx: commands.Context, code: int) -> None:
90 | """Sends an embed with an image of a dog, portraying the status code."""
91 | await self._fetcher(ctx, "dog", code)
92 |
93 | @http_status_group.command(name="goat")
94 | async def http_goat(self, ctx: commands.Context, code: int) -> None:
95 | """Sends an embed with an image of a goat, portraying the status code."""
96 | await self._fetcher(ctx, "goat", code)
97 |
98 |
99 | def setup(bot: Monty) -> None:
100 | """Load the HTTPStatusCodes cog."""
101 | bot.add_cog(HTTPStatusCodes(bot))
102 |
--------------------------------------------------------------------------------
/monty/components/app_emoji_syncing.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import datetime
4 | import pathlib
5 | from typing import TYPE_CHECKING, Protocol
6 |
7 | import githubkit.exception
8 |
9 |
10 | if TYPE_CHECKING:
11 | from collections.abc import Generator
12 |
13 | from monty.github_client import GitHubClient
14 |
15 |
16 | class EmojiContentNotFoundError(Exception):
17 | """Exception raised when the content of an emoji is not found."""
18 |
19 |
20 | class AppEmojiSyncer(Protocol):
21 | async def get_last_changed_date(self) -> datetime.datetime:
22 | """Provide the timestamp of the most recent change for the emoji directory."""
23 | ...
24 |
25 | async def get_emoji_content(self, emoji_name: str) -> bytes:
26 | """Provide the content of the specified emoji."""
27 | ...
28 |
29 |
30 | class LocalBackend:
31 | def __init__(self, emoji_directory: str):
32 | self.repo_path = pathlib.Path.cwd()
33 | emoji_directory_path = pathlib.Path(emoji_directory)
34 | if emoji_directory_path.anchor:
35 | emoji_directory_path = pathlib.Path(*emoji_directory_path.parts[1:])
36 | self.emoji_directory = (self.repo_path / emoji_directory_path.resolve()).relative_to(self.repo_path)
37 |
38 | def _list_local_files(self) -> Generator[pathlib.Path, None, None]:
39 | """List all local emoji files."""
40 | yield from (self.repo_path / self.emoji_directory).rglob("*.png")
41 |
42 | async def get_last_changed_date(self) -> datetime.datetime:
43 | """Get the time of the last commit for the emoji directory."""
44 | # check modified files with pathlib
45 | last_changed: datetime.datetime = datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
46 | for file in self._list_local_files():
47 | file_mtime = file.stat().st_mtime
48 | if file_mtime > last_changed.timestamp():
49 | last_changed = datetime.datetime.fromtimestamp(file_mtime, tz=datetime.timezone.utc)
50 | return last_changed
51 |
52 | async def get_emoji_content(self, emoji_name: str) -> bytes:
53 | """Read the emoji at the specified path."""
54 | file_path = self.repo_path / self.emoji_directory / f"{emoji_name}.png"
55 | if not file_path.exists():
56 | msg = f"Emoji file not found: {file_path}"
57 | raise EmojiContentNotFoundError(msg)
58 | with file_path.open("rb") as f:
59 | return f.read()
60 |
61 |
62 | class GitHubBackend(LocalBackend):
63 | def __init__(self, github_client: GitHubClient, *, user: str, repo: str, emoji_directory: str, sha: str):
64 | self.github = github_client
65 | self.user = user
66 | self.repo = repo
67 | self.emoji_directory = emoji_directory
68 | self.sha = sha
69 | super().__init__(emoji_directory=emoji_directory)
70 |
71 | async def get_last_changed_date(self) -> datetime.datetime:
72 | """Get the time of the last commit for the emoji directory."""
73 | try:
74 | resp = await self.github.rest.repos.async_list_commits(
75 | owner=self.user,
76 | repo=self.repo,
77 | per_page=1,
78 | path=self.emoji_directory,
79 | sha=self.sha,
80 | )
81 | commits = resp.parsed_data
82 | except githubkit.exception.GitHubException:
83 | commits = None
84 |
85 | if not commits:
86 | return datetime.datetime.now(tz=datetime.timezone.utc)
87 | commit = commits[0]
88 |
89 | committer = commit.commit.committer or commit.commit.author
90 | last_changed = None
91 | if committer:
92 | last_changed = committer.date
93 | if not last_changed:
94 | # assume it was just changed and do a full sync
95 | last_changed = datetime.datetime.now(tz=datetime.timezone.utc)
96 | return last_changed
97 |
--------------------------------------------------------------------------------
/docs/contributing/design/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | description: Design language and best practices
3 | ---
4 |
5 | # Design
6 |
7 | ## Language
8 |
9 | Monty is intended to have a design language that looks cohesive from one end to
10 | another. All commands and interactions should be consistent between one another.
11 | There are a couple aspects that work together to make this happen, but the core
12 | is:
13 |
14 | - Every error message SHOULD be descriptive.
15 | - A user should be able to figure out what we wrong from the provided error.
16 | - Tracebacks should ideally not be provided to the end user, though they
17 | currently are.
18 | - Every reply or message MUST be deletable.
19 | - A button should be attached to every reply to allow the user to simply press
20 | it to delete the message, and optionally the command that invoked it too.
21 | - But they should not delete without the user asking for it to be deleted
22 | - Every message MUST be end-user-friendly.
23 | - This means using embeds when possible, and providing detailed, grammatically
24 | correct error messaging.
25 | - Reactions SHOULD never be used as a way for a user to interact with Monty.
26 | - (there are still some legacy places that use emojis as triggers, please
27 | submit a fix!)
28 |
29 | ## Implementations
30 |
31 | ### Deleting every reply
32 |
33 | There are two aspects that work together in order to provide a button to delete
34 | every reply: the button itself, and a listener. All of the buttons are processed
35 | in the
36 | [`monty.exts.core.delete`](https://github.com/onerandomusername/monty-python/blob/main/monty/exts/core/delete.py)
37 | extension.
38 |
39 | The DeleteButton itself is defined in `monty.utils.responses`
40 |
41 | There is a DeleteButton defined in
42 | [`monty.utils.messages`](https://github.com/onerandomusername/monty-python/blob/main/monty/utils/messages.py),
43 | it should be attached to **all** replies from the bot. If it is not attached to
44 | a message, this is considered a bug and should be fixed. The exception is
45 | ephemeral messages in response to an interaction, as those can be deleted with
46 | the dismiss button provided by Discord.
47 |
48 | Known bugs: In some cases the `bookmark` suite of commands does not include
49 | Delete Buttons.
50 |
51 | ### `monty.utils.responses`
52 |
53 | A lot of time and effort has gone into a theoretical unified response system,
54 | but it is not used in very many places yet.
55 |
56 | This module is intended to wrap most message replies and be used to send a
57 | response, but it was not implemented in very many places. The proper way to do
58 | this would be to return items in a dictionary form that can then be modified if
59 | needed. Pulls are accepted if you can improve the design language across the
60 | bot.
61 |
62 | ## Repo layout
63 |
64 | ### monty
65 |
66 | Core source for the bot
67 |
68 | #### monty/alembic
69 |
70 | Database migrations.
71 |
72 | #### monty/database
73 |
74 | Model definitions for each database table.
75 |
76 | #### monty/exts
77 |
78 | Where all of Monty's extension live. They are written with
79 | [disnake](https://docs.disnake.dev/en/stable/) cogs, and follow a typical plan.
80 |
81 | - `monty.exts.core`
82 | - Extensions that run core bot processes.
83 | - `monty.exts.filters`
84 | - Extensions that listen for messages and provide messaging in reply. Eg a
85 | token_remover or a webhook deleter, or an unfenced codeblock message.
86 | - `monty.exts.info`
87 | - Extensions that provide info about *something*, often calling out to an
88 | external API and performing a search on the user's behalf.
89 | - `monty.exts.meta`
90 | - Sort of like core, but these are meta commands that allow *end users* to
91 | interact with the bot. They provide methods or commands for
92 |
93 | #### monty/resources
94 |
95 | Vendored or other file that needs to be hosted locally and cannot live at an
96 | API.
97 |
98 | #### monty/utils
99 |
100 | Utility functions that are used by more than one module.
101 |
--------------------------------------------------------------------------------
/monty/config/metadata.py:
--------------------------------------------------------------------------------
1 | from typing import Final
2 |
3 | import disnake
4 | from disnake import Locale
5 |
6 | from monty import constants
7 | from monty.config import validators
8 | from monty.config.models import Category, ConfigAttrMetadata, FreeResponseMetadata, SelectGroup, SelectOptionMetadata
9 |
10 |
11 | __all__ = (
12 | "CATEGORY_TO_ATTR",
13 | "GROUP_TO_ATTR",
14 | "METADATA",
15 | )
16 |
17 |
18 | METADATA: Final[dict[str, ConfigAttrMetadata]] = dict( # noqa: C408
19 | prefix=ConfigAttrMetadata(
20 | type=str,
21 | name="Command Prefix",
22 | description="The prefix used for text based commands.",
23 | requires_bot=True,
24 | categories={Category.General},
25 | modal=FreeResponseMetadata(button_label="Set Prefix", button_style=lambda x: disnake.ButtonStyle.green),
26 | ),
27 | github_issues_org=ConfigAttrMetadata(
28 | type=str,
29 | name={
30 | Locale.en_US: "GitHub Issue Organization",
31 | Locale.en_GB: "GitHub Issue Organisation",
32 | },
33 | description={
34 | Locale.en_US: "A specific organization or user to use as the default org for GitHub related commands.",
35 | Locale.en_GB: "A specific organisation or user to use as the default org for GitHub related commands.",
36 | },
37 | validator=validators.validate_github_org,
38 | category=Category.GitHub,
39 | modal=FreeResponseMetadata(
40 | button_label="Edit Org",
41 | max_length=39,
42 | min_length=2,
43 | ),
44 | ),
45 | git_file_expansions=ConfigAttrMetadata(
46 | type=bool,
47 | category=Category.GitHub,
48 | name="GitHub/GitLab/BitBucket File Expansions",
49 | description="Whether to automatically expand links to specific lines for GitHub, GitLab, and BitBucket",
50 | long_description=(
51 | "Automatically expand links to specific lines for GitHub, GitLab, and BitBucket when possible."
52 | ),
53 | requires_bot=True,
54 | select_option=SelectOptionMetadata(
55 | group=SelectGroup.GITHUB_EXPANSIONS,
56 | description="github.com///blob//#L",
57 | ),
58 | emoji="📄",
59 | ),
60 | github_issue_linking=ConfigAttrMetadata(
61 | type=bool,
62 | category=Category.GitHub,
63 | name="Issue Linking",
64 | description="Automatically link GitHub issues if they match the inline markdown syntax on GitHub.",
65 | long_description=(
66 | "Automatically link GitHub issues if they match the inline markdown syntax on GitHub. "
67 | "For example, `onerandomusername/monty-python#223` will provide a link to issue 223."
68 | ),
69 | select_option=SelectOptionMetadata(
70 | group=SelectGroup.GITHUB_EXPANSIONS,
71 | description="github.com///issues/",
72 | ),
73 | requires_bot=True,
74 | emoji="🐛",
75 | ),
76 | github_comment_linking=ConfigAttrMetadata(
77 | type=bool,
78 | category=Category.GitHub,
79 | name="Comment Linking",
80 | depends_on_features=(constants.Feature.GITHUB_COMMENT_LINKS,),
81 | description="Automatically expand a GitHub comment link. Requires GitHub Issue Linking to have an effect.",
82 | requires_bot=True,
83 | select_option=SelectOptionMetadata(
84 | group=SelectGroup.GITHUB_EXPANSIONS,
85 | description="github.com///issues//#issuecomment-",
86 | ),
87 | emoji="💬",
88 | ),
89 | )
90 |
91 |
92 | def _populate_group_to_attr() -> dict[SelectGroup, list[str]]:
93 | """Populate the GROUP_TO_ATTR mapping."""
94 | result = {}
95 | for attr, meta in METADATA.items():
96 | if meta.select_option:
97 | result.setdefault(meta.select_option.group, []).append(attr)
98 | return result
99 |
100 |
101 | GROUP_TO_ATTR: Final[dict[SelectGroup, list[str]]] = _populate_group_to_attr()
102 |
103 | CATEGORY_TO_ATTR: Final[dict[Category, list[str]]] = {
104 | cat: [attr for attr, meta in METADATA.items() if cat in meta.categories] for cat in Category
105 | }
106 |
--------------------------------------------------------------------------------
/monty/__main__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | import signal
4 | import sys
5 |
6 | import cachingutils
7 | import cachingutils.redis
8 | import disnake
9 | import redis
10 | import redis.asyncio
11 | from sqlalchemy.ext.asyncio import create_async_engine
12 |
13 | from monty import constants, monkey_patches
14 | from monty.bot import Monty
15 | from monty.migrations import run_alembic
16 |
17 |
18 | log = logging.getLogger(__name__)
19 |
20 | try:
21 | import uvloop # type: ignore
22 |
23 | uvloop.install()
24 | log.info("Using uvloop as event loop.")
25 | except ImportError:
26 | log.info("Using default asyncio event loop.")
27 |
28 |
29 | def get_redis_session(*, use_fakeredis: bool = False) -> redis.asyncio.Redis:
30 | """Create the redis session, either fakeredis or a real one based on env vars."""
31 | if use_fakeredis:
32 | try:
33 | import fakeredis
34 | import fakeredis.aioredis
35 | except ImportError as e:
36 | msg = "fakeredis must be installed to use fake redis"
37 | raise RuntimeError(msg) from e
38 | redis_session = fakeredis.aioredis.FakeRedis.from_url(str(constants.Redis.uri))
39 | else:
40 | pool = redis.asyncio.BlockingConnectionPool.from_url(
41 | str(constants.Redis.uri),
42 | max_connections=20,
43 | timeout=300,
44 | )
45 | redis_session = redis.asyncio.Redis(connection_pool=pool)
46 | return redis_session
47 |
48 |
49 | async def main() -> None:
50 | """Create and run the bot."""
51 | # we make our redis session here and pass it to cachingutils
52 | if constants.Redis.use_fakeredis:
53 | log.warning("Using fakeredis for Redis session. This is not suitable for production use.")
54 | redis_session = get_redis_session(use_fakeredis=constants.Redis.use_fakeredis)
55 |
56 | cachingutils.redis.async_session(
57 | constants.Client.config_prefix, session=redis_session, prefix=constants.Redis.prefix
58 | )
59 |
60 | database_engine = create_async_engine(str(constants.Database.postgres_bind))
61 | # run alembic migrations
62 | if constants.Database.run_migrations:
63 | log.info(f"Running database migrations to target {constants.Database.migration_target}")
64 | await run_alembic(database_engine)
65 | else:
66 | log.info("Skipping database migrations per environment settings.")
67 | # we still need to connect to the database to verify connection info is correct
68 | async with database_engine.connect():
69 | pass
70 |
71 | # ping redis
72 | await redis_session.ping()
73 | log.debug("Successfully pinged redis server.")
74 |
75 | bot = Monty(
76 | redis_session=redis_session,
77 | database_engine=database_engine,
78 | command_prefix=constants.Client.default_command_prefix,
79 | activity=constants.Client.activity,
80 | allowed_mentions=constants.Client.allowed_mentions,
81 | intents=constants.Client.intents,
82 | command_sync_flags=constants.Client.command_sync_flags,
83 | proxy=constants.Client.proxy,
84 | )
85 |
86 | await bot.login(constants.Client.token or "")
87 |
88 | # library doesn't set this until it connects to the gateway with connect()
89 | appinfo = await bot.application_info()
90 | bot._connection.application_id = appinfo.id
91 |
92 | await bot.sync_app_emojis()
93 |
94 | try:
95 | bot.load_extensions()
96 | except Exception:
97 | log.exception("Failed to load extensions. Shutting down.")
98 | await bot.close()
99 | raise
100 |
101 | loop = asyncio.get_running_loop()
102 |
103 | future: asyncio.Future = asyncio.ensure_future(bot.connect(), loop=loop)
104 |
105 | try:
106 | loop.add_signal_handler(signal.SIGINT, lambda: future.cancel())
107 | loop.add_signal_handler(signal.SIGTERM, lambda: future.cancel())
108 | except NotImplementedError:
109 | # Signal handlers are not implemented on some platforms (e.g., Windows)
110 | pass
111 | try:
112 | await future
113 | except asyncio.CancelledError:
114 | log.info("Received signal to terminate bot and event loop.")
115 | finally:
116 | if not bot.is_closed():
117 | await bot.close()
118 |
119 |
120 | if __name__ == "__main__":
121 | constants.validate_config()
122 | disnake.Embed.set_default_colour(constants.Colours.python_yellow)
123 | monkey_patches.patch_typing()
124 | monkey_patches.patch_inter_send()
125 | sys.exit(asyncio.run(main()))
126 |
--------------------------------------------------------------------------------
/monty/exts/core/logging.py:
--------------------------------------------------------------------------------
1 | import traceback
2 | from typing import Any
3 |
4 | import disnake
5 | from disnake.ext import commands
6 |
7 | from monty.bot import Monty
8 | from monty.log import get_logger
9 | from monty.metadata import ExtMetadata
10 |
11 |
12 | EXT_METADATA = ExtMetadata(core=True)
13 | logger = get_logger(__name__)
14 |
15 | COMMAND_TYPES = {
16 | disnake.ApplicationCommandType.chat_input: "slash",
17 | disnake.ApplicationCommandType.user: "user",
18 | disnake.ApplicationCommandType.message: "message",
19 | }
20 |
21 |
22 | class InternalLogger(commands.Cog):
23 | """Internal logging for debug and abuse handling."""
24 |
25 | def __init__(self, bot: Monty) -> None:
26 | self.bot = bot
27 |
28 | @commands.Cog.listener()
29 | async def on_command(
30 | self, ctx: commands.Context, command: commands.Command | None = None, content: str | None = None
31 | ) -> None:
32 | """Log a command invoke."""
33 | if not isinstance(content, str):
34 | content = ctx.message.content
35 | spl = content.split("\n")
36 | if not command:
37 | command = ctx.command
38 | qualname = command.qualified_name if command else "unknown"
39 | self.bot.stats.incr("prefix_commands." + qualname.replace(".", "_") + ".uses")
40 | logger.info(
41 | "command %s by %s (%s) in channel %s (%s) in guild %s: %s",
42 | qualname,
43 | ctx.author,
44 | ctx.author.id,
45 | ctx.channel,
46 | ctx.channel.id,
47 | ctx.guild and ctx.guild.id,
48 | spl[0] + (" ..." if len(spl) > 1 else ""),
49 | )
50 |
51 | @commands.Cog.listener()
52 | async def on_error(self, event_method: Any, *args, **kwargs) -> None:
53 | """Log all errors without other listeners."""
54 | logger.error(f"Ignoring exception in {event_method}:\n{traceback.format_exc()}")
55 |
56 | @commands.Cog.listener()
57 | async def on_command_completion(self, ctx: commands.Context) -> None:
58 | """Log a successful command completion."""
59 | qualname = ctx.command.qualified_name if ctx.command else "unknown"
60 | logger.info(
61 | "command %s by %s (%s) in channel %s (%s) in guild %s has completed!",
62 | qualname,
63 | ctx.author,
64 | ctx.author.id,
65 | ctx.channel,
66 | ctx.channel.id,
67 | ctx.guild and ctx.guild.id,
68 | )
69 |
70 | self.bot.socket_events["COMMAND_COMPLETION"] += 1
71 | self.bot.stats.incr("prefix_commands." + qualname.replace(".", "_") + ".completion")
72 |
73 | @commands.Cog.listener()
74 | async def on_application_command(self, inter: disnake.ApplicationCommandInteraction) -> None:
75 | """Log the start of an application command."""
76 | spl = str(inter.filled_options).replace("\n", " ")
77 | spl = spl.split("\n")
78 | # TODO: fix this in disnake
79 | if inter.application_command is disnake.utils.MISSING:
80 | return
81 | qualname = inter.application_command.qualified_name
82 |
83 | logger.info(
84 | "slash command `%s` by %s (%s) in channel %s (%s) in guild %s: %s",
85 | inter.application_command.qualified_name,
86 | inter.author,
87 | inter.author.id,
88 | inter.channel,
89 | inter.channel_id,
90 | inter.guild_id,
91 | spl[0] + (" ..." if len(spl) > 1 else ""),
92 | )
93 |
94 | self.bot.stats.incr("slash_commands." + qualname.replace(".", "_") + ".uses")
95 |
96 | @commands.Cog.listener(disnake.Event.message_command_completion)
97 | @commands.Cog.listener(disnake.Event.user_command_completion)
98 | @commands.Cog.listener(disnake.Event.slash_command_completion)
99 | async def on_application_command_completion(self, inter: disnake.ApplicationCommandInteraction) -> None:
100 | """Log application command completion."""
101 | qualname = inter.application_command.qualified_name
102 | try:
103 | command_type = COMMAND_TYPES[inter.application_command.body.type] + " command"
104 | except KeyError:
105 | command_type = "unknown command type"
106 | logger.info(
107 | "{%s} `%s` by %s (%s) in channel %s (%s) in guild %s has completed!",
108 | command_type,
109 | qualname,
110 | inter.author,
111 | inter.author.id,
112 | inter.channel,
113 | inter.channel_id,
114 | inter.guild_id,
115 | )
116 |
117 | self.bot.socket_events[command_type.replace(" ", "_").upper() + "_COMPLETION"] += 1
118 | self.bot.stats.incr("slash_commands." + qualname.replace(".", "_") + ".completion")
119 |
120 |
121 | def setup(bot: Monty) -> None:
122 | """Add the internal logger cog to the bot."""
123 | bot.add_cog(InternalLogger(bot))
124 |
--------------------------------------------------------------------------------
/monty/exts/utils/misc.py:
--------------------------------------------------------------------------------
1 | import re
2 | import unicodedata
3 |
4 | import disnake
5 | from disnake.ext import commands
6 |
7 | from monty.bot import Monty
8 | from monty.log import get_logger
9 | from monty.utils.messages import DeleteButton
10 | from monty.utils.pagination import LinePaginator
11 |
12 |
13 | log = get_logger(__name__)
14 |
15 |
16 | class Misc(
17 | commands.Cog,
18 | slash_command_attrs={
19 | "contexts": disnake.InteractionContextTypes(guild=True),
20 | "install_types": disnake.ApplicationInstallTypes(guild=True),
21 | },
22 | ):
23 | """A selection of utilities which don't have a clear category."""
24 |
25 | def __init__(self, bot: Monty) -> None:
26 | self.bot = bot
27 |
28 | def _format_snowflake(self, snowflake: disnake.Object) -> str:
29 | """Return a formatted Snowflake form."""
30 | timestamp = int(snowflake.created_at.timestamp())
31 | return (
32 | f"**{snowflake.id}** ({timestamp})\n"
33 | f" ()."
34 | f"`{snowflake.created_at.isoformat().replace('+00:00', 'Z')}`\n"
35 | )
36 |
37 | @commands.slash_command(name="char-info")
38 | async def charinfo(
39 | self, ctx: disnake.ApplicationCommandInteraction[Monty], characters: commands.String[str, ..., 50]
40 | ) -> None:
41 | """
42 | Shows you information on up to 50 unicode characters.
43 |
44 | Parameters
45 | ----------
46 | characters: The characters to display information on.
47 | """
48 | match = re.match(r"<(a?):(\w+):(\d+)>", characters)
49 | if match:
50 | await ctx.send(
51 | "**Non-Character Detected**\n"
52 | "Only unicode characters can be processed, but a custom Discord emoji "
53 | "was found. Please remove it and try again."
54 | )
55 | return
56 |
57 | if len(characters) > 50:
58 | await ctx.send(f"Too many characters ({len(characters)}/50)")
59 | return
60 |
61 | def get_info(char: str) -> tuple[str, str]:
62 | digit = f"{ord(char):x}"
63 | if len(digit) <= 4:
64 | u_code = f"\\u{digit:>04}"
65 | else:
66 | u_code = f"\\U{digit:>08}"
67 | url = f"https://www.compart.com/en/unicode/U+{digit:>04}"
68 | name = f"[{unicodedata.name(char, '')}]({url})"
69 | info = f"`{u_code.ljust(10)}`: {name} - {disnake.utils.escape_markdown(char)}"
70 | return (info, u_code)
71 |
72 | (char_list, raw_list) = zip(*(get_info(c) for c in characters), strict=False)
73 | embed = disnake.Embed().set_author(name="Character Info")
74 |
75 | if len(characters) > 1:
76 | # Maximum length possible is 502 out of 1024, so there's no need to truncate.
77 | embed.add_field(name="Full Raw Text", value=f"`{''.join(raw_list)}`", inline=False)
78 | embed.description = "\n".join(char_list)
79 | await ctx.send(embed=embed, components=DeleteButton(ctx.author))
80 |
81 | @commands.command(aliases=("snf", "snfl", "sf"))
82 | async def snowflake(self, ctx: commands.Context[Monty], *snowflakes: disnake.Object) -> None:
83 | """Get Discord snowflake creation time."""
84 | if not snowflakes:
85 | msg = "At least one snowflake must be provided."
86 | raise commands.BadArgument(msg)
87 |
88 | # clear any duplicated keys
89 | snowflakes = tuple(set(snowflakes))
90 |
91 | embed = disnake.Embed(colour=disnake.Colour.blue())
92 | embed.set_author(
93 | name=f"Snowflake{'s'[: len(snowflakes) ^ 1]}", # Deals with pluralisation
94 | icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true",
95 | )
96 |
97 | lines: list[str] = [self._format_snowflake(snowflake) for snowflake in snowflakes]
98 |
99 | await LinePaginator.paginate(lines, ctx=ctx, embed=embed, max_lines=5, max_size=1000)
100 |
101 | @commands.slash_command(name="snowflake")
102 | async def slash_snowflake(
103 | self,
104 | inter: disnake.AppCommandInteraction[Monty],
105 | snowflake: disnake.Object,
106 | ) -> None:
107 | """
108 | [BETA] Get creation date of a snowflake.
109 |
110 | Parameters
111 | ----------
112 | snowflake: The snowflake.
113 | """
114 | embed = disnake.Embed(colour=disnake.Colour.blue())
115 | embed.set_author(
116 | name="Snowflake",
117 | icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true",
118 | )
119 |
120 | embed.description = self._format_snowflake(snowflake)
121 | components = DeleteButton(inter.author)
122 | await inter.send(embed=embed, components=components)
123 |
124 |
125 | def setup(bot: Monty) -> None:
126 | """Load the Misc cog."""
127 | bot.add_cog(Misc(bot))
128 |
--------------------------------------------------------------------------------
/monty/aiohttp_session.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import datetime
4 | import socket
5 | import sys
6 | from typing import TYPE_CHECKING, Any, TypedDict
7 |
8 | import aiohttp
9 | import httpx_aiohttp
10 | from aiohttp_client_cache.backends.redis import RedisBackend
11 | from aiohttp_client_cache.response import CachedResponse
12 | from aiohttp_client_cache.session import CachedSession
13 |
14 | from monty import constants
15 | from monty.log import get_logger
16 | from monty.utils import helpers
17 | from monty.utils.services import update_github_ratelimits_on_request
18 |
19 |
20 | if TYPE_CHECKING:
21 | from types import SimpleNamespace
22 |
23 | import redis.asyncio
24 | from aiohttp.tracing import TraceConfig
25 |
26 | aiohttp_log = get_logger("monty.http")
27 | cache_logger = get_logger("monty.http.caching")
28 |
29 |
30 | async def _on_request_end(
31 | session: aiohttp.ClientSession,
32 | trace_config_ctx: SimpleNamespace,
33 | params: aiohttp.TraceRequestEndParams,
34 | ) -> None:
35 | """Log all aiohttp requests on request end.
36 |
37 | If the request was to api.github.com, update the github headers.
38 | """
39 | resp = params.response
40 | aiohttp_log.info(
41 | "[{status!s} {reason!s}] {method!s} {url!s} ({content_type!s})".format(
42 | status=resp.status,
43 | reason=resp.reason or "None",
44 | method=params.method.upper(),
45 | url=params.url,
46 | content_type=resp.content_type,
47 | )
48 | )
49 | if resp.url.host == "api.github.com":
50 | update_github_ratelimits_on_request(resp)
51 |
52 |
53 | class SessionArgs(TypedDict):
54 | proxy: str | None
55 | connector: aiohttp.BaseConnector
56 |
57 |
58 | def session_args_for_proxy(proxy: str | None) -> SessionArgs:
59 | """Create a dict with `proxy` and `connector` items, to be passed to aiohttp.ClientSession."""
60 | connector = aiohttp.TCPConnector(
61 | resolver=aiohttp.AsyncResolver(),
62 | family=socket.AF_INET,
63 | ssl=(
64 | helpers._SSL_CONTEXT_UNVERIFIED
65 | if (proxy and proxy.startswith("http://"))
66 | else helpers._SSL_CONTEXT_VERIFIED
67 | ),
68 | )
69 | return {"proxy": proxy or None, "connector": connector}
70 |
71 |
72 | def filter_caching(response: CachedResponse | aiohttp.ClientResponse) -> bool:
73 | """Filter function for aiohttp_client_cache to determine if a response should be cached."""
74 | # cache 404 and 410 for only two hours
75 | if not isinstance(response, CachedResponse):
76 | return True
77 | if response.status == 200:
78 | return True
79 | delta = datetime.datetime.utcnow() - response.created_at # noqa: DTZ003
80 | match response.status:
81 | case 404:
82 | return delta <= datetime.timedelta(minutes=30)
83 | case 410:
84 | return delta <= datetime.timedelta(days=1)
85 | return True
86 |
87 |
88 | def get_cache_backend(redis: redis.asyncio.Redis) -> RedisBackend:
89 | """Get the cache backend for aiohttp_client_cache."""
90 | return RedisBackend(
91 | constants.Client.config_prefix,
92 | "aiohttp_requests",
93 | # We default to revalidating, so this just prevents ballooning.
94 | expire_after=60 * 60 * 24 * 7, # hold requests for one week.
95 | cache_control=True,
96 | allowed_codes=(200, 404, 410),
97 | connection=redis,
98 | filter_fn=filter_caching,
99 | )
100 |
101 |
102 | class CachingClientSession(CachedSession):
103 | def __init__(self, *args: Any, **kwargs: Any) -> None:
104 | """Create the aiohttp session and set the trace logger, if desired."""
105 | kwargs.update(session_args_for_proxy(kwargs.get("proxy")))
106 |
107 | if "trace_configs" not in kwargs:
108 | trace_configs: list[TraceConfig] = []
109 | trace_config = aiohttp.TraceConfig()
110 | trace_config.on_request_end.append(_on_request_end)
111 | trace_configs.append(trace_config)
112 | kwargs["trace_configs"] = trace_configs
113 | if "headers" not in kwargs:
114 | kwargs["headers"] = {
115 | "User-Agent": (
116 | f"Python/{sys.version_info[0]}.{sys.version_info[1]} Monty-Python/{constants.Client.git_ref} "
117 | f"({constants.Client.git_repo})"
118 | ),
119 | }
120 | super().__init__(*args, **kwargs)
121 |
122 | async def _request(self, *args, **kwargs) -> CachedResponse:
123 | if "refresh" not in kwargs:
124 | kwargs["refresh"] = True
125 | return await super()._request(*args, **kwargs)
126 |
127 |
128 | class AiohttpTransport(httpx_aiohttp.AiohttpTransport):
129 | async def aclose(self) -> None:
130 | """Override aclose to not do anything since we manage the underlying transport elsewhere."""
131 |
132 | async def close(self) -> None:
133 | """Override close to not do anything since we manage the underlying transport elsewhere."""
134 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | # Github Action Workflow enforcing our code style and running tests.
2 |
3 | name: CI
4 |
5 | # Trigger the workflow on both push (to the main repository)
6 | # and pull requests (against the main repository, but from any repo).
7 | on:
8 | push:
9 | branches:
10 | - main
11 | pull_request:
12 |
13 | concurrency:
14 | group: ${{ github.workflow }}-${{ github.repository }}-${{ github.ref }}
15 | cancel-in-progress: false
16 |
17 | defaults:
18 | run:
19 | shell: bash
20 |
21 | env:
22 | # https://docs.astral.sh/uv/reference/environment/
23 | UV_LOCKED: 1
24 | UV_NO_SYNC: 1
25 | UV_PYTHON_DOWNLOADS: never
26 |
27 | permissions:
28 | contents: read
29 | jobs:
30 | lint:
31 | name: lint
32 | runs-on: ubuntu-latest
33 | permissions:
34 | contents: read
35 | steps:
36 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
37 | with:
38 | persist-credentials: false
39 | - uses: j178/prek-action@91fd7d7cf70ae1dee9f4f44e7dfa5d1073fe6623 # v1.0.11
40 | env:
41 | RUFF_OUTPUT_FORMAT: "github"
42 |
43 | sessions:
44 | name: nox sessions
45 | runs-on: ubuntu-latest
46 | outputs:
47 | sessions: ${{ steps.set-sessions.outputs.sessions }}
48 | steps:
49 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
50 | with:
51 | persist-credentials: false
52 |
53 | - name: set up environment
54 | id: setup-env
55 | uses: ./.github/actions/setup-env
56 | with:
57 | python-version: "3.10"
58 |
59 | - name: export nox sessions
60 | id: set-sessions
61 | run: |
62 | echo "sessions=$(nox --list -t ci --json | jq -c '[.[].session]')" >> $GITHUB_OUTPUT
63 |
64 | ci:
65 | name: ${{ matrix.session }}
66 | runs-on: ubuntu-latest
67 | needs: [sessions]
68 | strategy:
69 | matrix:
70 | session: ${{ fromJson(needs.sessions.outputs.sessions) }}
71 | env:
72 | NOXSESSION: ${{ matrix.session }}
73 | steps:
74 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
75 | with:
76 | persist-credentials: false
77 |
78 | - name: set up environment
79 | id: setup-env
80 | uses: ./.github/actions/setup-env
81 | with:
82 | python-version: "3.10"
83 |
84 | - name: Install dependencies
85 | run: |
86 | nox --install-only
87 |
88 | - name: Run nox -s ${{ matrix.session }}
89 | run:
90 | nox
91 |
92 | - name: check diff
93 | run:
94 | git diff --exit-code
95 |
96 | check:
97 | name: Check CI passed
98 | if: always()
99 | needs:
100 | - lint
101 | - ci
102 |
103 | runs-on: ubuntu-latest
104 |
105 | steps:
106 | - name: Decide whether the needed jobs succeeded or failed
107 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
108 | with:
109 | jobs: ${{ toJSON(needs) }}
110 |
111 |
112 | build:
113 | if: github.ref == 'refs/heads/main' && github.event_name == 'push'
114 | name: Build & Push
115 | needs: [check]
116 | runs-on: ubuntu-latest
117 | permissions:
118 | contents: read
119 | packages: write # used to publish to GHCR
120 |
121 | steps:
122 | # Create a commit SHA-based tag for the container repositories
123 | - name: Create SHA Container Tag
124 | id: sha_tag
125 | run: |
126 | tag=$(cut -c 1-7 <<< $GITHUB_SHA)
127 | echo "tag=$tag" >> $GITHUB_OUTPUT
128 | # Check out the current repository in the `monty` subdirectory
129 | - name: Checkout code
130 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
131 | with:
132 | path: monty
133 | persist-credentials: false
134 |
135 | - name: Set up Docker Buildx
136 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
137 |
138 | - name: Login to Github Container Registry
139 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
140 | with:
141 | registry: ghcr.io
142 | username: ${{ github.repository_owner }}
143 | password: ${{ secrets.GITHUB_TOKEN }}
144 |
145 | # Build and push the container to the GitHub Container
146 | # Repository. The container will be tagged as "latest"
147 | # and with the short SHA of the commit.
148 | - name: Build and push
149 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
150 | with:
151 | context: monty/
152 | file: monty/Dockerfile
153 | push: true
154 | cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/monty-python:latest
155 | cache-to: type=inline
156 | tags: |
157 | ghcr.io/${{ github.repository_owner }}/monty-python:latest
158 | ghcr.io/${{ github.repository_owner }}/monty-python:${{ steps.sha_tag.outputs.tag }}
159 | build-args: |
160 | git_sha=${{ github.sha }}
161 |
--------------------------------------------------------------------------------
/monty/log.py:
--------------------------------------------------------------------------------
1 | # pyright: strict
2 | from __future__ import annotations
3 |
4 | import logging
5 | import logging.handlers
6 | import sys
7 | from pathlib import Path
8 | from typing import TYPE_CHECKING, TypedDict, cast
9 |
10 | from rich.console import Console
11 | from rich.logging import RichHandler
12 |
13 | from monty import constants
14 |
15 |
16 | if TYPE_CHECKING:
17 | from collections.abc import Mapping
18 |
19 | from typing_extensions import Unpack
20 |
21 |
22 | TRACE = 5
23 |
24 |
25 | def get_logger(name: str) -> MontyLogger:
26 | """Stub method for logging.getLogger."""
27 | return cast("MontyLogger", logging.getLogger(name))
28 |
29 |
30 | class LoggingParams(TypedDict, total=False):
31 | """Parameters for logging setup."""
32 |
33 | exc_info: logging._ExcInfoType # type: ignore
34 | stack_info: bool
35 | stacklevel: int
36 | extra: Mapping[str, object] | None
37 |
38 |
39 | class MontyLogger(logging.Logger):
40 | """Custom logger which implements the trace level."""
41 |
42 | def trace(self, msg: object, *args: object, **kwargs: Unpack[LoggingParams]) -> None:
43 | """
44 | Log 'msg % args' with severity 'TRACE'.
45 |
46 | To pass exception information, use the keyword argument exc_info with a true value, e.g.
47 | logger.trace("Houston, we have a %s", "tiny detail.", exc_info=1)
48 | """
49 | if self.isEnabledFor(TRACE):
50 | self._log(TRACE, msg, args, **kwargs)
51 |
52 |
53 | def setup() -> None:
54 | """Set up loggers."""
55 | # Configure the "TRACE" logging level (e.g. "log.trace(message)")
56 | logging.TRACE = TRACE # type: ignore
57 | logging.addLevelName(TRACE, "TRACE")
58 | logging.setLoggerClass(MontyLogger)
59 |
60 | format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
61 | log_format = logging.Formatter(format_string)
62 | root_logger = logging.getLogger()
63 |
64 | # Set up file logging
65 | log_file = Path("logs/monty-python.log")
66 | log_file.parent.mkdir(exist_ok=True)
67 |
68 | # we use a rotating sized log handler for local development.
69 | # in production, we log each day's logs to a new file and delete it after 14 days
70 | if constants.Monitoring.log_mode == "daily":
71 | file_handler = logging.handlers.TimedRotatingFileHandler(
72 | log_file,
73 | "midnight",
74 | utc=True,
75 | backupCount=14,
76 | encoding="utf-8",
77 | )
78 | else:
79 | # File handler rotates logs every 5 MB
80 | file_handler = logging.handlers.RotatingFileHandler(
81 | log_file,
82 | maxBytes=5 * (2**20),
83 | backupCount=10,
84 | encoding="utf-8",
85 | )
86 | file_handler.setFormatter(log_format)
87 | root_logger.addHandler(file_handler)
88 |
89 | if RichHandler and Console:
90 | rich_handler = RichHandler(rich_tracebacks=True, console=Console(stderr=True))
91 | # rich_handler.setFormatter(log_format)
92 | root_logger.addHandler(rich_handler)
93 | else:
94 | console_handler = logging.StreamHandler(sys.stderr)
95 | console_handler.setFormatter(log_format)
96 | root_logger.addHandler(console_handler)
97 |
98 | root_logger.setLevel(logging.DEBUG if constants.Monitoring.debug_logging else logging.INFO)
99 | # Silence irrelevant loggers
100 | logging.getLogger("disnake").setLevel(logging.WARNING)
101 | logging.getLogger("websockets").setLevel(logging.WARNING)
102 | logging.getLogger("cachingutils").setLevel(logging.INFO)
103 | logging.getLogger("sqlalchemy.engine").setLevel(logging.DEBUG)
104 | logging.getLogger("sqlalchemy.pool").setLevel(logging.INFO)
105 | logging.getLogger("gql.dsl").setLevel(logging.INFO)
106 | logging.getLogger("gql.transport.aiohttp").setLevel(logging.INFO)
107 | logging.getLogger("watchfiles").setLevel(logging.INFO)
108 | _set_trace_loggers()
109 |
110 | root_logger.info("Logging initialization complete")
111 |
112 |
113 | def _set_trace_loggers() -> None:
114 | """
115 | Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var.
116 |
117 | When the env var is a list of logger names delimited by a comma,
118 | each of the listed loggers will be set to the trace level.
119 |
120 | If this list is prefixed with a "!", all of the loggers except the listed ones will be set to the trace level.
121 |
122 | Otherwise if the env var begins with a "*",
123 | the root logger is set to the trace level and other contents are ignored.
124 | """
125 | level_filter = constants.Monitoring.trace_loggers
126 | if level_filter:
127 | if level_filter.startswith("*"):
128 | logging.getLogger().setLevel(TRACE)
129 |
130 | elif level_filter.startswith("!"):
131 | logging.getLogger().setLevel(TRACE)
132 | for logger_name in level_filter.strip("!,").split(","):
133 | logging.getLogger(logger_name.strip()).setLevel(logging.DEBUG)
134 |
135 | else:
136 | for logger_name in level_filter.strip(",").split(","):
137 | logging.getLogger(logger_name.strip()).setLevel(TRACE)
138 |
--------------------------------------------------------------------------------
/docs/contributing/design/config.md:
--------------------------------------------------------------------------------
1 | # The Configuration System
2 |
3 | > [!CAUTION]
4 | > This file describes the implementation for a module that may or may not exist.
5 | > It is akin to an RFC.
6 |
7 | End-user configuration is a complex topic, especially when the interface is
8 | through Discord. While one can make a webpage for this, it is beyond the scope
9 | of Monty, to have a web dashboard.
10 |
11 | There are multiple sources for configuration, not necessarily in any specific
12 | order.
13 |
14 | - Default values
15 | - Local environment configuration
16 | - Guild configuration
17 | - User configuration (not yet implemented)
18 |
19 | There is also the matter of mixing in the Features system, which adds another
20 | layer of complexity.
21 |
22 | ## Requirements
23 |
24 | ### Configuration Sources
25 |
26 | > [!CAUTION]
27 | > User configuration is not yet implemented.
28 |
29 | - **Default Values**: These are the built-in defaults that Monty uses if no
30 | other configuration is provided.
31 | - **Local Environment Configuration**: This includes settings defined in
32 | environment variables or local config files. These don't really override any
33 | of the default values, but they may affect those settings and those options.
34 | For example, GitHub settings and configuration won't be enabled if the token
35 | is not set for GitHub.
36 | - **Guild Configuration**: Guild specific configuration. These can override
37 | default values and provide defaults for a guild and for user commands within
38 | that guild.
39 | - **User Configuration**: User specific configuration. These can override guild
40 | configuration and default values for a specific user.
41 |
42 | The last two have five options for each boolean setting: `always`, `never`,
43 | `default`, `true`, and `false`. In the event that both guild and user
44 | configuration are present, the user configuration takes precedence, with some
45 | exceptions. This is best illustrated in the table below:
46 |
47 | | Guild | User | Result |
48 | | ------- | ------- | ----------- |
49 | | default | default | bot default |
50 | | always | never | never |
51 | | always | true | always |
52 | | always | false | always |
53 | | true | never | never |
54 | | false | never | never |
55 | | false | true | true |
56 |
57 | Always means the guild will always win, except if the user chose always or
58 | never.
59 |
60 | A better way to look at this is that "always" is a "force true", and "never" is
61 | a "force false".
62 |
63 | ### Feature Integration
64 |
65 | Monty has a feature system that allows for enabling or disabling specific
66 | features by the bot owner. This adds another layer of complexity to the
67 | configuration system.
68 |
69 | Each feature can be enabled or disabled at the guild or user level, much like
70 | the above. The important aspect about the feature system is that it can lock-out
71 | configuration values and disable commands, options, and even entire extensions.
72 | This is important for stability and ensuring that buggy or experimental features
73 | do not affect the entire bot, while still being available for testing in
74 | production.
75 |
76 | ### UI Options
77 |
78 | The UI of the configuration is a major pain point for this system. The goal is
79 | to have a user-friendly interface while still being entirely in Discord, and
80 | being usable if the slash command system is turned off for whatever reason.
81 |
82 | One possible way to implement this is with an app command that provides a slash
83 | command interface, and a field for the configuration option.
84 |
85 | However, if a user wants to change multiple options, this would require multiple
86 | commands, which is not ideal. Another option is to use a modal, but this has the
87 | downside of being limited in the number of fields that can be displayed. That
88 | said, as some configuration options are free-response, a modal may be the only
89 | option for those, if we don't want to lock ourselves to the rigid structure of
90 | slash commands.
91 |
92 | We are also able to make a sub command for each slash option, but that limits us
93 | to only 25 configuration options.
94 |
95 | Another concept idea is a slash command for each configuration section, which
96 | then provides a list of options that can be changed. This would allow for a more
97 | organized interface, but would still require multiple commands to change
98 | multiple options. This can be used, for example, to open a modal with several
99 | selects and a free response for updating a group of values in one go.
100 |
101 | Another option, and perhaps the most user-friendly, is to use a message and
102 | interaction based interface. An entry point with both a prefix and slash command
103 | could be implemented to allow users to easily access the configuration options.
104 | This launches a message with both buttons and selects in order to navigate
105 | through the configuration option. Much like the features interface for admins,
106 | this system would allow for easy navigation and configuration of options.
107 |
108 | With a button to go-back, and buttons to access each specific section, this
109 | would allow us 8 sections per page, before pagination is required.
110 |
111 | ## Implementation
112 |
113 | See the source code in monty/configuration/schema.py
114 |
--------------------------------------------------------------------------------
/monty/utils/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import re
3 | import string
4 |
5 | import disnake
6 | from disnake.ext import commands
7 |
8 | from monty.utils.pagination import LinePaginator
9 |
10 | from .helpers import pad_base64
11 |
12 |
13 | __all__ = [
14 | "disambiguate",
15 | "pad_base64",
16 | "replace_many",
17 | ]
18 |
19 |
20 | async def disambiguate(
21 | ctx: commands.Context,
22 | entries: list[str],
23 | *,
24 | timeout: float = 30,
25 | entries_per_page: int = 20,
26 | empty: bool = False,
27 | embed: disnake.Embed | None = None,
28 | ) -> str:
29 | """
30 | Has the user choose between multiple entries in case one could not be chosen automatically.
31 |
32 | Disambiguation will be canceled after `timeout` seconds.
33 |
34 | This will raise a commands.BadArgument if entries is empty, if the disambiguation event times out,
35 | or if the user makes an invalid choice.
36 | """
37 | if len(entries) == 0:
38 | msg = "No matches found."
39 | raise commands.BadArgument(msg)
40 |
41 | if len(entries) == 1:
42 | return entries[0]
43 |
44 | choices = (f"{index}: {entry}" for index, entry in enumerate(entries, start=1))
45 |
46 | def check(message: disnake.Message) -> bool:
47 | return message.content.isdecimal() and message.author == ctx.author and message.channel == ctx.channel
48 |
49 | try:
50 | if embed is None:
51 | embed = disnake.Embed()
52 |
53 | coro1 = ctx.bot.wait_for("message", check=check, timeout=timeout)
54 | coro2 = LinePaginator.paginate(
55 | choices,
56 | ctx,
57 | embed=embed,
58 | max_lines=entries_per_page,
59 | empty=empty,
60 | max_size=6000,
61 | timeout=9000,
62 | )
63 |
64 | # wait_for timeout will go to except instead of the wait_for thing as I expected
65 | futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)]
66 | done, pending = await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED)
67 |
68 | # :yert:
69 | result = next(iter(done)).result()
70 |
71 | # Pagination was canceled - result is None
72 | if result is None:
73 | for coro in pending:
74 | coro.cancel()
75 | msg = "Canceled."
76 | raise commands.BadArgument(msg)
77 |
78 | # Pagination was not initiated, only one page
79 | if result.author == ctx.bot.user:
80 | # Continue the wait_for
81 | result = await next(iter(pending))
82 |
83 | # Love that duplicate code
84 | for coro in pending:
85 | coro.cancel()
86 | except asyncio.TimeoutError:
87 | msg = "Timed out."
88 | raise commands.BadArgument(msg) from None
89 |
90 | # Guaranteed to not error because of isdecimal() in check
91 | index = int(result.content)
92 |
93 | try:
94 | return entries[index - 1]
95 | except IndexError:
96 | msg = "Invalid choice."
97 | raise commands.BadArgument(msg) from None
98 |
99 |
100 | def replace_many(
101 | sentence: str,
102 | replacements: dict,
103 | *,
104 | ignore_case: bool = False,
105 | match_case: bool = False,
106 | ) -> str:
107 | """
108 | Replaces multiple substrings in a string given a mapping of strings.
109 |
110 | By default replaces long strings before short strings, and lowercase before uppercase.
111 | Example:
112 | var = replace_many("This is a sentence", {"is": "was", "This": "That"})
113 | assert var == "That was a sentence"
114 |
115 | If `ignore_case` is given, does a case insensitive match.
116 | Example:
117 | var = replace_many("THIS is a sentence", {"IS": "was", "tHiS": "That"}, ignore_case=True)
118 | assert var == "That was a sentence"
119 |
120 | If `match_case` is given, matches the case of the replacement with the replaced word.
121 | Example:
122 | var = replace_many(
123 | "This IS a sentence", {"is": "was", "this": "that"}, ignore_case=True, match_case=True
124 | )
125 | assert var == "That WAS a sentence"
126 | """
127 | if ignore_case:
128 | replacements = {word.lower(): replacement for word, replacement in replacements.items()}
129 |
130 | words_to_replace = sorted(replacements, key=lambda s: (-len(s), s))
131 |
132 | # Join and compile words to replace into a regex
133 | pattern = "|".join(re.escape(word) for word in words_to_replace)
134 | regex = re.compile(pattern, re.IGNORECASE if ignore_case else 0)
135 |
136 | def _repl(match: re.Match) -> str:
137 | """Returns replacement depending on `ignore_case` and `match_case`."""
138 | word = match.group(0)
139 | replacement = replacements[word.lower() if ignore_case else word]
140 |
141 | if not match_case:
142 | return replacement
143 |
144 | # Clean punctuation from word so string methods work
145 | cleaned_word = word.translate(str.maketrans("", "", string.punctuation))
146 | if cleaned_word.isupper():
147 | return replacement.upper()
148 | elif cleaned_word[0].isupper():
149 | return replacement.capitalize()
150 | else:
151 | return replacement.lower()
152 |
153 | return regex.sub(_repl, sentence)
154 |
--------------------------------------------------------------------------------
/monty/utils/services.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | from aiohttp import ClientConnectorError
4 | from attrs import define
5 |
6 | from monty import constants
7 | from monty.errors import APIError
8 | from monty.log import get_logger
9 |
10 |
11 | if typing.TYPE_CHECKING:
12 | import aiohttp
13 | from githubkit.versions.latest import types as github_types
14 |
15 | from monty.bot import Monty
16 |
17 | log = get_logger(__name__)
18 |
19 | FAILED_REQUEST_ATTEMPTS = 3
20 |
21 | ## deprecated in favour of githubkut
22 | GITHUB_REQUEST_HEADERS = {
23 | "Accept": "application/vnd.github.v3+json",
24 | "X-GitHub-Api-Version": "2022-11-28",
25 | }
26 | if constants.Auth.github:
27 | GITHUB_REQUEST_HEADERS["Authorization"] = f"token {constants.Auth.github}"
28 |
29 |
30 | @define()
31 | class GitHubRateLimit:
32 | limit: int
33 | remaining: int
34 | reset: int
35 | used: int
36 |
37 |
38 | # TODO: Implement this with GithubClient
39 | GITHUB_RATELIMITS: dict[str, GitHubRateLimit] = {}
40 |
41 |
42 | async def send_to_paste_service(bot: "Monty", contents: str, *, extension: str = "") -> str | None:
43 | """
44 | Upload `contents` to the paste service.
45 |
46 | `extension` is added to the output URL
47 |
48 | When an error occurs, `None` is returned, otherwise the generated URL with the suffix.
49 | """
50 | if not constants.Endpoints.paste_service:
51 | return "Sorry, paste isn't configured!"
52 |
53 | log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.")
54 | paste_url = constants.Endpoints.paste_service.format(key="api/new")
55 | json: dict[str, str] = {
56 | "content": contents,
57 | }
58 | if extension:
59 | json["language"] = extension
60 | response = None
61 | for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1):
62 | response_json = {}
63 | try:
64 | async with bot.http_session.post(paste_url, json=json) as response:
65 | response_json = await response.json()
66 | if not 200 <= response.status < 300 and attempt == FAILED_REQUEST_ATTEMPTS:
67 | msg = "The paste service could not be used at this time."
68 | raise APIError(msg, api="workbin", status_code=response.status)
69 | except ClientConnectorError:
70 | log.warning(
71 | f"Failed to connect to paste service at url {paste_url}, "
72 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
73 | )
74 | continue
75 | except Exception:
76 | log.exception(
77 | "An unexpected error has occurred during handling of the request, "
78 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
79 | )
80 | continue
81 |
82 | if "message" in response_json:
83 | log.warning(
84 | f"Paste service returned error {response_json['message']} with status code {response.status}, "
85 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
86 | )
87 | continue
88 | if "key" in response_json:
89 | log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.")
90 |
91 | paste_link = constants.Endpoints.paste_service.format(key=f"?id={response_json['key']}")
92 | if extension:
93 | paste_link += f"&language={extension}"
94 |
95 | return paste_link
96 |
97 | log.warning(
98 | f"Got unexpected JSON response from paste service: {response_json}\n"
99 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
100 | )
101 |
102 | msg = "The paste service could not be used at this time."
103 | raise APIError(
104 | msg,
105 | api="Workbin",
106 | status_code=response.status if response else 0,
107 | )
108 |
109 |
110 | # https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#checking-the-status-of-your-rate-limit
111 | def update_github_ratelimits_on_request(resp: "aiohttp.ClientResponse") -> None:
112 | """Given a ClientResponse, update the stored GitHub Ratelimits."""
113 | resource_name = resp.headers.get("x-ratelimit-resource")
114 | if not resource_name:
115 | # there's nothing to update as the resource name does not exist
116 | return
117 | GITHUB_RATELIMITS[resource_name] = GitHubRateLimit(
118 | limit=int(resp.headers["x-ratelimit-limit"]),
119 | remaining=int(resp.headers["x-ratelimit-remaining"]),
120 | reset=int(resp.headers["x-ratelimit-reset"]),
121 | used=int(resp.headers["x-ratelimit-used"]),
122 | )
123 |
124 |
125 | # https://docs.github.com/en/rest/rate-limit/rate-limit?apiVersion=2022-11-28
126 | def update_github_ratelimits_from_ratelimit_page(json: "github_types.RateLimitOverviewType") -> None:
127 | """Given the response from GitHub's rate_limit API page, update the stored GitHub Ratelimits."""
128 | ratelimits = json["resources"]
129 | for name in ratelimits:
130 | resource = ratelimits[name]
131 | GITHUB_RATELIMITS[name] = GitHubRateLimit(
132 | limit=resource["limit"],
133 | remaining=resource["remaining"],
134 | reset=resource["reset"],
135 | used=resource["used"],
136 | )
137 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["flit_core >=3.11,<4"]
3 | build-backend = "flit_core.buildapi"
4 |
5 | [project]
6 | authors = [{ name = "aru", email = "me@arielle.codes" }]
7 | license = "MIT"
8 | license-files = ["LICENSE", "LICENSE_THIRD_PARTY"]
9 | requires-python = ">=3.10"
10 | name = "monty"
11 | version = "0.0.1"
12 | description = "Helpful bot for python, github, and discord things."
13 | dependencies = [
14 | "aiohttp-client-cache>=0.14.1",
15 | "aiohttp~=3.13.0",
16 | "alembic<2.0.0,>=1.15.2",
17 | "arrow~=1.4.0",
18 | "asyncpg~=0.31.0",
19 | "attrs<25.0.0,>=24.2.0",
20 | "base65536~=0.1.1",
21 | "beautifulsoup4<5.0.0,>=4.13.4",
22 | "cachingutils @ git+https://github.com/onerandomusername/cachingutils.git@vcokltfre/feat/v2",
23 | "colorama~=0.4.5; sys_platform == \"win32\"",
24 | "disnake[speed] @ git+https://github.com/DisnakeDev/disnake.git@master",
25 | "ghretos @ git+https://github.com/onerandomusername/ghretos@main",
26 | "githubkit>=0.13.5",
27 | "httpx>=0.28.1",
28 | "httpx-aiohttp>=0.1.9",
29 | "lxml>=5.4.0,<7.0.0",
30 | "markdownify~=1.1.0",
31 | "mistune<3.0.0,>=2.0.4",
32 | "msgpack<2.0.0,>=1.1.0",
33 | "orjson<4.0.0,>=3.10.18",
34 | "Pillow<12.0,>=11.2",
35 | "psutil<6.0.0,>=5.9.8",
36 | "pydantic-settings>=2.11.0",
37 | "pydantic>=2.12.2",
38 | "python-dateutil<3.0.0,>=2.9.0",
39 | "rapidfuzz<4.0.0,>=3.13.0",
40 | "readme-renderer[md] (>=44.0,<45.0)",
41 | "redis[hiredis]<6.0.0,>=5.2.0",
42 | "rich>=14.1.0,<15.0.0",
43 | "sentry-sdk<3.0.0,>=2.28.0",
44 | "SQLAlchemy[asyncio]~=2.0.41",
45 | "statsd<4.0.0,>=3.3.0",
46 | "typing-extensions>=4.15.0",
47 | # markers from https://github.com/MagicStack/uvloop/blob/74f4c96d3fc5281b1820491d2568de771ea7851b/setup.py#L7-L8
48 | "uvloop>=0.22.1 ; sys_platform != 'cli' and sys_platform != 'cygwin' and sys_platform != 'win32'",
49 | "yarl<2.0.0,>=1.17.2",
50 | ]
51 |
52 | [dependency-groups]
53 | dev = [
54 | { include-group = "devlibs" },
55 | { include-group = "docs" },
56 | { include-group = "mdformat" },
57 | { include-group = "tools" },
58 | { include-group = "typing" },
59 | ]
60 | devlibs = [
61 | "fakeredis<3.0.0,>=2.29.0",
62 | "watchfiles<2.0.0,>=1.0.5",
63 | "python-dotenv<2.0.0,>=1.1.0",
64 | "wand>=0.6.13",
65 | "octicons-pack>=19.19.0",
66 | ]
67 | docs = [
68 | "mkdocs<2.0.0,>=1.6.1",
69 | "mkdocs-material[imaging]<10.0.0,>=9.6.20",
70 | "mkdocs-git-revision-date-localized-plugin<2.0.0,>=1.4.7",
71 | "markdown-gfm-admonition<1.0.0,>=0.1.1",
72 | ]
73 | mdformat = [
74 | "mdformat<1.0.0,>=0.7.22",
75 | "mdformat-ruff<1.0.0,>=0.1.3",
76 | "mdformat-gfm<1.0.0,>=0.4.1",
77 | "mdformat-gfm-alerts<3.0.0,>=2.0.0",
78 | "mdformat-mkdocs<5.0.0,>=4.4.1",
79 | "mdformat-pyproject<1.0.0,>=0.0.2",
80 | "mdformat-simple-breaks<1.0.0,>=0.0.1",
81 | "mdformat-tables<2.0.0,>=1.0.0",
82 | "mdformat-frontmatter<3.0.0,>=2.0.8",
83 | ]
84 | nox = ["nox>=2025.5.1"]
85 | ruff = ["ruff==0.14.5"]
86 | tools = [
87 | "poethepoet>=0.37.0",
88 | "prek>=0.2.1,<0.3",
89 | { include-group = "ruff" },
90 | { include-group = "nox" },
91 | ]
92 | typing = ["basedpyright>=1.31.6", "msgpack-types<1.0.0,>=0.5.0"]
93 |
94 | [tool.ruff]
95 | line-length = 120
96 | target-version = "py310"
97 |
98 | [tool.ruff.lint]
99 | select = ["ALL"]
100 | ignore = [
101 | # flake8-commas
102 | "COM",
103 | # Missing Docstrings
104 | "D100",
105 | "D101",
106 | "D104",
107 | "D105",
108 | "D107",
109 | # Docstring Whitespace
110 | "D212",
111 | # Docstring Content
112 | "D401",
113 | "D404",
114 | "D406",
115 | "D407",
116 | "D410",
117 | "D411",
118 | "D416",
119 |
120 | # Type Annotations
121 | "ANN002",
122 | "ANN003",
123 | "ANN204",
124 | "ANN206",
125 | "ANN401",
126 |
127 | # try-except-pass
128 | "S110",
129 |
130 | # temporarily disabled
131 | "C901", # mccabe
132 | "G004", # Logging statement uses f-string
133 | "S101", # Use of `assert` detected
134 | "S311", # pseduo-random generators, random is used everywhere for random choices.
135 |
136 | "ARG",
137 | "BLE",
138 | "ERA",
139 | "FBT",
140 | "FIX",
141 | "FURB",
142 | "PGH",
143 | "PLC",
144 | "PLR",
145 | "PLW",
146 | "RET505",
147 | "RET506",
148 | "RUF012",
149 | "SIM105",
150 | "SIM108",
151 | "SLF",
152 | "TD",
153 | "TRY",
154 | ]
155 |
156 |
157 | [tool.ruff.lint.per-file-ignores]
158 | "monty/alembic/*" = ["D"]
159 | "_global_source_snekcode.py" = ["T201"]
160 |
161 | [tool.ruff.lint.flake8-builtins]
162 | ignorelist = ["id"]
163 |
164 | [tool.ruff.lint.isort]
165 | lines-after-imports = 2
166 | known-first-party = ["monty"]
167 |
168 | [tool.ruff.lint.pydocstyle]
169 | convention = "numpy"
170 |
171 | [tool.ruff.lint.mccabe]
172 | max-complexity = 20
173 |
174 |
175 | [tool.poe.tasks]
176 | lint = { cmd = "prek run --all-files", help = "Lint the source code" }
177 | prek = { cmd = "prek install", help = "Install the git pre-commit hooks" }
178 | pyright = { cmd = "dotenv -f task.env run -- pyright", help = "Run pyright" }
179 | sync = { cmd = "uv sync --all-groups --all-extras", help = "Sync all groups and extras to a local environment" }
180 |
181 | [tool.basedpyright]
182 | typeCheckingMode = "standard"
183 | include = ["monty", "*.py"]
184 |
185 | # temporarily disabled
186 | reportUnnecessaryTypeIgnoreComment = false
187 |
188 | [tool.mdformat]
189 | wrap = 80
190 |
--------------------------------------------------------------------------------
/monty/utils/lock.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import inspect
5 | from collections import defaultdict
6 | from collections.abc import Awaitable, Callable, Coroutine, Hashable
7 | from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
8 | from weakref import WeakValueDictionary
9 |
10 | from monty.errors import LockedResourceError
11 | from monty.log import get_logger
12 | from monty.utils import function
13 | from monty.utils.function import command_wraps
14 |
15 |
16 | if TYPE_CHECKING:
17 | from types import TracebackType
18 |
19 | from typing_extensions import ParamSpec
20 |
21 | P = ParamSpec("P")
22 | T = TypeVar("T")
23 | Coro = Coroutine[Any, Any, T]
24 |
25 |
26 | log = get_logger(__name__)
27 | __lock_dicts = defaultdict(WeakValueDictionary)
28 |
29 | _IdCallableReturn = Hashable | Awaitable[Hashable]
30 | _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn]
31 | ResourceId = Hashable | _IdCallable
32 |
33 |
34 | class SharedEvent:
35 | """
36 | Context manager managing an internal event exposed through the wait coro.
37 |
38 | While any code is executing in this context manager, the underlying event will not be set;
39 | when all of the holders finish the event will be set.
40 | """
41 |
42 | def __init__(self) -> None:
43 | self._active_count = 0
44 | self._event = asyncio.Event()
45 | self._event.set()
46 |
47 | def __enter__(self) -> None:
48 | """Increment the count of the active holders and clear the internal event."""
49 | self._active_count += 1
50 | self._event.clear()
51 |
52 | def __exit__(
53 | self,
54 | _exc_type: type[BaseException] | None,
55 | _exc_val: BaseException | None,
56 | _exc_tb: TracebackType | None,
57 | ) -> None:
58 | """Decrement the count of the active holders; if 0 is reached set the internal event."""
59 | self._active_count -= 1
60 | if not self._active_count:
61 | self._event.set()
62 |
63 | async def wait(self) -> None:
64 | """Wait for all active holders to exit."""
65 | await self._event.wait()
66 |
67 |
68 | @overload
69 | def lock(
70 | namespace: Hashable,
71 | resource_id: ResourceId,
72 | *,
73 | raise_error: Literal[False] = False,
74 | wait: bool = ...,
75 | ) -> Callable[[Callable[P, Coro[T]]], Callable[P, Coro[T | None]]]: ...
76 |
77 |
78 | @overload
79 | def lock(
80 | namespace: Hashable,
81 | resource_id: ResourceId,
82 | *,
83 | raise_error: Literal[True],
84 | wait: bool = False,
85 | ) -> Callable[[Callable[P, Coro[T]]], Callable[P, Coro[T]]]: ...
86 |
87 |
88 | def lock(
89 | namespace: Hashable,
90 | resource_id: ResourceId,
91 | *,
92 | raise_error: bool = False,
93 | wait: bool = False,
94 | ) -> Callable[[Callable[P, Coro[T]]], Callable[P, Coro[T | None]]]:
95 | """
96 | Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`.
97 |
98 | If `wait` is True, wait until the lock becomes available. Otherwise, if any other mutually
99 | exclusive function currently holds the lock for a resource, do not run the decorated function
100 | and return None.
101 |
102 | If `raise_error` is True, raise `LockedResourceError` if the lock cannot be acquired.
103 |
104 | `namespace` is an identifier used to prevent collisions among resource IDs.
105 |
106 | `resource_id` identifies a resource on which to perform a mutually exclusive operation.
107 | It may also be a callable or awaitable which will return the resource ID given an ordered
108 | mapping of the parameters' names to arguments' values.
109 |
110 | If decorating a command, this decorator must go before (below) the `command` decorator.
111 | """
112 |
113 | def decorator(func: Callable[P, Coro[T]]) -> Callable[P, Coro[T | None]]:
114 | name = func.__name__
115 |
116 | @command_wraps(func)
117 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None:
118 | log.trace(f"{name}: mutually exclusive decorator called")
119 |
120 | if callable(resource_id):
121 | log.trace(f"{name}: binding args to signature")
122 | bound_args = function.get_bound_args(func, args, kwargs)
123 |
124 | log.trace(f"{name}: calling the given callable to get the resource ID")
125 | id_ = resource_id(bound_args)
126 |
127 | if inspect.isawaitable(id_):
128 | log.trace(f"{name}: awaiting to get resource ID")
129 | id_ = await id_
130 | else:
131 | id_ = resource_id
132 |
133 | log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}")
134 |
135 | # Get the lock for the ID. Create a lock if one doesn't exist yet.
136 | locks = __lock_dicts[namespace]
137 | lock_ = locks.setdefault(id_, asyncio.Lock())
138 |
139 | # It's safe to check an asyncio.Lock is free before acquiring it because:
140 | # 1. Synchronous code like `if not lock_.locked()` does not yield execution
141 | # 2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free
142 | # 3. awaits only yield execution to the event loop at actual I/O boundaries
143 | if wait or not lock_.locked():
144 | log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...")
145 | async with lock_:
146 | return await func(*args, **kwargs)
147 | else:
148 | log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked")
149 | if raise_error:
150 | raise LockedResourceError(str(namespace), id_)
151 | return None
152 |
153 | return wrapper
154 |
155 | return decorator
156 |
--------------------------------------------------------------------------------
/monty/utils/inventory_parser.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 | import zlib
5 | from collections import defaultdict
6 | from datetime import timedelta
7 | from typing import TYPE_CHECKING
8 |
9 | import aiohttp
10 |
11 | from monty.log import get_logger
12 | from monty.utils.caching import redis_cache
13 |
14 |
15 | if TYPE_CHECKING:
16 | from collections.abc import AsyncIterator
17 |
18 | from monty.bot import Monty
19 |
20 |
21 | log = get_logger(__name__)
22 |
23 | FAILED_REQUEST_ATTEMPTS = 3
24 | _V2_LINE_RE = re.compile(r"(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)")
25 |
26 | InventoryDict = defaultdict[str, list[tuple[str, str, str]]]
27 |
28 |
29 | class InvalidHeaderError(Exception):
30 | """Raised when an inventory file has an invalid header."""
31 |
32 |
33 | class ZlibStreamReader:
34 | """Class used for decoding zlib data of a stream line by line."""
35 |
36 | READ_CHUNK_SIZE = 16 * 1024
37 |
38 | def __init__(self, stream: aiohttp.StreamReader) -> None:
39 | self.stream = stream
40 |
41 | async def _read_compressed_chunks(self) -> AsyncIterator[bytes]:
42 | """Read zlib data in `READ_CHUNK_SIZE` sized chunks and decompress."""
43 | decompressor = zlib.decompressobj()
44 | async for chunk in self.stream.iter_chunked(self.READ_CHUNK_SIZE):
45 | yield decompressor.decompress(chunk)
46 |
47 | yield decompressor.flush()
48 |
49 | async def __aiter__(self) -> AsyncIterator[str]:
50 | """Yield lines of decompressed text."""
51 | buf = b""
52 | async for chunk in self._read_compressed_chunks():
53 | buf += chunk
54 | pos = buf.find(b"\n")
55 | while pos != -1:
56 | yield buf[:pos].decode()
57 | buf = buf[pos + 1 :]
58 | pos = buf.find(b"\n")
59 |
60 |
61 | async def _load_v1(stream: aiohttp.StreamReader) -> InventoryDict:
62 | invdata = defaultdict(list)
63 |
64 | async for line in stream:
65 | name, type_, location = line.decode().rstrip().split(maxsplit=2)
66 | # version 1 did not add anchors to the location
67 | if type_ == "mod":
68 | type_ = "py:module"
69 | location += "#module-" + name
70 | else:
71 | type_ = "py:" + type_
72 | location += "#" + name
73 | invdata[type_].append((name, location, name))
74 | return invdata
75 |
76 |
77 | async def _load_v2(stream: aiohttp.StreamReader) -> InventoryDict:
78 | invdata = defaultdict(list)
79 |
80 | async for line in ZlibStreamReader(stream):
81 | m = _V2_LINE_RE.match(line.rstrip())
82 | if m is None:
83 | continue
84 | name, type_, _priority, location, dispname = m.groups() # ignore the parsed items we don't need
85 | if location.endswith("$"):
86 | location = location[:-1] + name
87 |
88 | invdata[type_].append((name, location, dispname))
89 | return invdata
90 |
91 |
92 | async def _fetch_inventory(bot: Monty, url: str) -> InventoryDict:
93 | """Fetch, parse and return an intersphinx inventory file from an url."""
94 | timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
95 | async with (
96 | bot.http_session.disabled(),
97 | bot.http_session.get(url, timeout=timeout, raise_for_status=True) as response,
98 | ):
99 | stream = response.content
100 |
101 | inventory_header = (await stream.readline()).decode().rstrip()
102 | try:
103 | inventory_version = int(inventory_header[-1:])
104 | except ValueError as e:
105 | msg = "Unable to convert inventory version header."
106 | raise InvalidHeaderError(msg) from e
107 |
108 | has_project_header = (await stream.readline()).startswith(b"# Project")
109 | has_version_header = (await stream.readline()).startswith(b"# Version")
110 | if not (has_project_header and has_version_header):
111 | msg = "Inventory missing project or version header."
112 | raise InvalidHeaderError(msg)
113 |
114 | if inventory_version == 1:
115 | return await _load_v1(stream)
116 |
117 | elif inventory_version == 2:
118 | if b"zlib" not in await stream.readline():
119 | msg = "'zlib' not found in header of compressed inventory."
120 | raise InvalidHeaderError(msg)
121 | return await _load_v2(stream)
122 |
123 | msg = "Incompatible inventory version."
124 | raise InvalidHeaderError(msg)
125 |
126 |
127 | @redis_cache(
128 | "sphinx-inventory",
129 | lambda url, **kw: url,
130 | include_posargs=[1],
131 | skip_cache_func=lambda *args, **kwargs: not kwargs.get("use_cache", True),
132 | timeout=timedelta(hours=12),
133 | )
134 | async def fetch_inventory(bot: Monty, url: str, *, use_cache: bool = True) -> InventoryDict | None:
135 | """
136 | Get an inventory dict from `url`, retrying `FAILED_REQUEST_ATTEMPTS` times on errors.
137 |
138 | `url` should point at a valid sphinx objects.inv inventory file, which will be parsed into the
139 | inventory dict in the format of {"domain:role": [("symbol_name", "relative_url_to_symbol"), ...], ...}
140 | """
141 | for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1):
142 | try:
143 | inventory = await _fetch_inventory(bot, url)
144 | except aiohttp.ClientConnectorError: # noqa: PERF203
145 | log.warning(
146 | f"Failed to connect to inventory url at {url}; trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
147 | )
148 | except aiohttp.ClientError:
149 | log.error(f"Failed to get inventory from {url}; trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS}).")
150 | except InvalidHeaderError:
151 | raise
152 | except Exception:
153 | log.exception(
154 | f"An unexpected error has occurred during fetching of {url}; "
155 | f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
156 | )
157 | raise
158 | else:
159 | return inventory
160 |
161 | return None
162 |
--------------------------------------------------------------------------------
/monty/exts/info/docs/_html.py:
--------------------------------------------------------------------------------
1 | import re
2 | from collections.abc import Callable, Container, Iterable
3 | from functools import partial
4 |
5 | from bs4 import BeautifulSoup
6 | from bs4.element import NavigableString, PageElement, Tag
7 | from bs4.filter import SoupStrainer
8 |
9 | from monty.log import get_logger
10 |
11 | from . import MAX_SIGNATURE_AMOUNT
12 |
13 |
14 | log = get_logger(__name__)
15 |
16 | _UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
17 | _SEARCH_END_TAG_ATTRS = (
18 | "data",
19 | "function",
20 | "class",
21 | "exception",
22 | "seealso",
23 | "section",
24 | "rubric",
25 | "sphinxsidebar",
26 | )
27 |
28 |
29 | # TODO: I'm not actually sure this class does anything anymore
30 | class Strainer(SoupStrainer):
31 | """Subclass of SoupStrainer to allow matching of both `Tag`s and `NavigableString`s."""
32 |
33 | def __init__(self, *, include_strings: bool, **kwargs) -> None:
34 | self.include_strings = include_strings
35 | passed_text = kwargs.pop("text", None)
36 | if passed_text is not None:
37 | log.warning("`text` is not a supported kwarg in the custom strainer.")
38 | super().__init__(**kwargs)
39 |
40 | Markup = PageElement | list["Markup"]
41 |
42 | def search(self, markup: Markup) -> PageElement | str | None:
43 | """Extend default SoupStrainer behaviour to allow matching both `Tag`s` and `NavigableString`s."""
44 | if isinstance(markup, str):
45 | # Let everything through the text filter if we're including strings and tags.
46 | if not self.name_rules and not self.attribute_rules and self.include_strings:
47 | return markup
48 | else:
49 | return super().search(markup) # pyright: ignore[reportArgumentType]
50 | return None
51 |
52 |
53 | def _find_elements_until_tag(
54 | start_element: PageElement | Tag | None,
55 | end_tag_filter: Container[str] | Callable[[Tag], bool],
56 | *,
57 | func: Callable,
58 | include_strings: bool = False,
59 | limit: int | None = None,
60 | ) -> list[Tag | NavigableString]:
61 | """
62 | Get all elements up to `limit` or until a tag matching `end_tag_filter` is found.
63 |
64 | `end_tag_filter` can be either a container of string names to check against,
65 | or a filtering callable that's applied to tags.
66 |
67 | When `include_strings` is True, `NavigableString`s from the document will be included in the result along `Tag`s.
68 |
69 | `func` takes in a BeautifulSoup unbound method for finding multiple elements, such as `BeautifulSoup.find_all`.
70 | The method is then iterated over and all elements until the matching tag or the limit are added to the return list.
71 | """
72 | use_container_filter = not callable(end_tag_filter)
73 | elements = []
74 |
75 | for element in func(start_element, name=Strainer(include_strings=include_strings), limit=limit):
76 | if isinstance(element, Tag):
77 | if use_container_filter:
78 | if element.name in end_tag_filter:
79 | break
80 | elif end_tag_filter(element):
81 | break
82 | elements.append(element)
83 |
84 | return elements
85 |
86 |
87 | _find_next_children_until_tag = partial(_find_elements_until_tag, func=partial(BeautifulSoup.find_all, recursive=False))
88 | _find_recursive_children_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_all)
89 | _find_next_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_next_siblings)
90 | _find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
91 |
92 |
93 | def _class_filter_factory(class_names: Iterable[str]) -> Callable[[Tag], bool]:
94 | """Create callable that returns True when the passed in tag's class is in `class_names` or when it's a table."""
95 |
96 | def match_tag(tag: Tag) -> bool:
97 | for attr in class_names:
98 | if attr in (tag.get("class") or ()):
99 | return True
100 | return tag.name == "table"
101 |
102 | return match_tag
103 |
104 |
105 | def get_general_description(start_element: PageElement) -> list[Tag | NavigableString]:
106 | """
107 | Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.
108 |
109 | A headerlink tag is attempted to be found to skip repeating the symbol information in the description.
110 | If it's found it's used as the tag to start the search from instead of the `start_element`.
111 | """
112 | child_tags = _find_recursive_children_until_tag(start_element, _class_filter_factory(["section"]), limit=100)
113 | tag_children = [el for el in child_tags if isinstance(el, Tag)]
114 | header = next(filter(_class_filter_factory(["headerlink"]), tag_children), None)
115 | start_tag = header.parent if header is not None else start_element
116 | return _find_next_siblings_until_tag(start_tag, _class_filter_factory(_SEARCH_END_TAG_ATTRS), include_strings=True)
117 |
118 |
119 | def get_dd_description(symbol: PageElement) -> list[Tag | NavigableString]:
120 | """Get the contents of the next dd tag, up to a dt or a dl tag."""
121 | description_tag = symbol.find_next("dd")
122 | return _find_next_children_until_tag(description_tag, ("dt", "dl"), include_strings=True)
123 |
124 |
125 | def get_signatures(start_signature: PageElement) -> list[str]:
126 | """
127 | Collect up to `_MAX_SIGNATURE_AMOUNT` signatures from dt tags around the `start_signature` dt tag.
128 |
129 | First the signatures under the `start_signature` are included;
130 | if less than 2 are found, tags above the start signature are added to the result if any are present.
131 | """
132 | signatures: list[str] = []
133 | for element in (
134 | *reversed(_find_previous_siblings_until_tag(start_signature, ("dd",), limit=2)),
135 | start_signature,
136 | *_find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
137 | )[-MAX_SIGNATURE_AMOUNT:]:
138 | signature = _UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
139 |
140 | if signature:
141 | signatures.append(signature)
142 |
143 | return signatures
144 |
--------------------------------------------------------------------------------
/monty/utils/responses.py:
--------------------------------------------------------------------------------
1 | """
2 | Helper methods for responses from the bot to the user.
3 |
4 | These help ensure consistency between errors, as they will all be consistent between different uses.
5 |
6 | Note: these are to used for general success or general errors. Typically, the error handler will make a
7 | response if a command raises a disnake.ext.commands.CommandError exception.
8 | """
9 |
10 | import random
11 | from typing import Any, Literal
12 |
13 | import disnake
14 | from disnake.ext import commands
15 |
16 | from monty import constants
17 | from monty.log import get_logger
18 |
19 |
20 | __all__ = (
21 | "DEFAULT_FAILURE_COLOUR",
22 | "DEFAULT_SUCCESS_COLOUR",
23 | "FAILURE_HEADERS",
24 | "SUCCESS_HEADERS",
25 | "USER_INPUT_ERROR_REPLIES",
26 | "send_general_response",
27 | "send_negatory_response",
28 | "send_positive_response",
29 | )
30 |
31 | _UNSET: Any = object()
32 |
33 | logger = get_logger(__name__)
34 |
35 |
36 | DEFAULT_SUCCESS_COLOUR = disnake.Colour(constants.Colours.soft_green)
37 | SUCCESS_HEADERS: tuple[str, ...] = (
38 | "Affirmative",
39 | "As you wish",
40 | "Done",
41 | "Fine by me",
42 | "There we go",
43 | "Sure!",
44 | "Okay",
45 | "You got it",
46 | "Your wish is my command",
47 | "Yep.",
48 | "Absolutely!",
49 | "Can do!",
50 | "Affirmative!",
51 | "Yeah okay.",
52 | "Sure.",
53 | "Sure thing!",
54 | "You're the boss!",
55 | "Okay.",
56 | "No problem.",
57 | "I got you.",
58 | "Alright.",
59 | "You got it!",
60 | "ROGER THAT",
61 | "Of course!",
62 | "Aye aye, cap'n!",
63 | "I'll allow it.",
64 | )
65 |
66 | DEFAULT_FAILURE_COLOUR = disnake.Colour(constants.Colours.soft_red)
67 | FAILURE_HEADERS: tuple[str, ...] = (
68 | "Abort!",
69 | "I cannot do that",
70 | "Hold up!",
71 | "I was unable to interpret that",
72 | "Not understood",
73 | "Oops",
74 | "Something went wrong",
75 | "\U0001f914",
76 | "Unable to complete your command",
77 | "I'm afraid that's not doable",
78 | "That is not possible.",
79 | "No can do.",
80 | "Sorry, I can't",
81 | "Ow",
82 | "Try again?",
83 | "That's not something I was programmed to do.",
84 | "Error: ",
85 | "Error? Error.",
86 | "Oof.",
87 | "-_-",
88 | "I may have made a mistake.",
89 | )
90 |
91 | # Bot replies
92 | USER_INPUT_ERROR_REPLIES: tuple[str, ...] = (
93 | "That input was invalid.",
94 | "Proper input not received.",
95 | "Please check your arguments.",
96 | "Your input was invalid.",
97 | "User input invalid. Requesting backup.",
98 | "Arguments not found, 404",
99 | "Bad Argument",
100 | )
101 |
102 |
103 | async def send_general_response(
104 | channel: disnake.abc.Messageable,
105 | response: str,
106 | *,
107 | message: disnake.Message | None = None,
108 | embed: disnake.Embed = _UNSET,
109 | colour: disnake.Colour | None = None,
110 | title: str | None = None,
111 | tag_as: Literal["general", "affirmative", "negatory"] = "general",
112 | **kwargs,
113 | ) -> disnake.Message:
114 | """
115 | Helper method to send a response.
116 |
117 | Shortcuts are provided as `send_positive_response` and `send_negatory_response` which
118 | fill in the title and colour automatically.
119 | """
120 | kwargs["allowed_mentions"] = kwargs.get("allowed_mentions", disnake.AllowedMentions.none())
121 |
122 | if isinstance(channel, commands.Context): # pragma: nocover
123 | channel = channel.channel
124 |
125 | logger.debug(f"Requested to send {tag_as} response message to {channel!s}. Response: {response!s}")
126 |
127 | if embed is None:
128 | if message is None:
129 | return await channel.send(response, **kwargs)
130 | else:
131 | return await message.edit(response, **kwargs)
132 |
133 | if embed is _UNSET: # pragma: no branch
134 | embed = disnake.Embed(colour=colour)
135 |
136 | if title is not None:
137 | embed.title = title
138 |
139 | embed.description = response
140 |
141 | if message is None:
142 | return await channel.send(embed=embed, **kwargs)
143 | else:
144 | return await message.edit(embed=embed, **kwargs)
145 |
146 |
147 | async def send_positive_response(
148 | channel: disnake.abc.Messageable,
149 | response: str,
150 | *,
151 | colour: disnake.Colour = _UNSET,
152 | **kwargs,
153 | ) -> disnake.Message:
154 | """
155 | Send an affirmative response.
156 |
157 | Requires a messageable, and a response.
158 | If embed is set to None, this will send response as a plaintext message, with no allowed_mentions.
159 | If embed is provided, this method will send a response using the provided embed, edited in place.
160 | Extra kwargs are passed to Messageable.send()
161 |
162 | If message is provided, it will attempt to edit that message rather than sending a new one.
163 | """
164 | if colour is _UNSET: # pragma: no branch
165 | colour = DEFAULT_SUCCESS_COLOUR
166 |
167 | kwargs["title"] = kwargs.get("title", random.choice(SUCCESS_HEADERS))
168 |
169 | return await send_general_response(
170 | channel=channel,
171 | response=response,
172 | colour=colour,
173 | tag_as="affirmative",
174 | **kwargs,
175 | )
176 |
177 |
178 | async def send_negatory_response(
179 | channel: disnake.abc.Messageable,
180 | response: str,
181 | *,
182 | colour: disnake.Colour = _UNSET,
183 | **kwargs,
184 | ) -> disnake.Message:
185 | """
186 | Send a negatory response.
187 |
188 | Requires a messageable, and a response.
189 | If embed is set to None, this will send response as a plaintext message, with no allowed_mentions.
190 | If embed is provided, this method will send a response using the provided embed, edited in place.
191 | Extra kwargs are passed to Messageable.send()
192 | """
193 | if colour is _UNSET: # pragma: no branch
194 | colour = DEFAULT_FAILURE_COLOUR
195 |
196 | kwargs["title"] = kwargs.get("title", random.choice(FAILURE_HEADERS))
197 |
198 | return await send_general_response(
199 | channel=channel,
200 | response=response,
201 | colour=colour,
202 | tag_as="negatory",
203 | **kwargs,
204 | )
205 |
--------------------------------------------------------------------------------