├── 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 | --------------------------------------------------------------------------------