├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.rst ├── bot.py ├── common ├── const.py ├── models.py └── utils.py ├── exts ├── docs.py ├── etc.py ├── git.py ├── help.py ├── info.py ├── roles.py ├── tags.py └── user.py ├── metadata.yml └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | clear.py 4 | venv/ 5 | env/ 6 | .env 7 | .vscode 8 | actions.json 9 | discord.log 10 | *.config.js 11 | metadata_test.yml 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: requirements-txt-fixer 6 | name: Requirements 7 | types: [file] 8 | exclude_types: ['image'] 9 | - id: debug-statements 10 | name: Debugging 11 | language: python 12 | language_version: python3.10 13 | types: [file, python] 14 | exclude_types: ['image'] 15 | - id: trailing-whitespace 16 | name: Trailing Whitespace 17 | language: python 18 | types: [file] 19 | exclude_types: ['image'] 20 | - id: end-of-file-fixer 21 | name: EOF Newlines 22 | language: python 23 | types: [file] 24 | exclude_types: ['image'] 25 | - id: check-yaml 26 | name: YAML Structure 27 | language: python 28 | - id: check-toml 29 | name: TOML Structure 30 | - id: check-merge-conflict 31 | name: Merge Conflicts 32 | - repo: https://github.com/psf/black 33 | rev: 22.12.0 34 | hooks: 35 | - id: black 36 | name: Black Formatting 37 | language: python 38 | args: [--target-version, py310, --line-length=100, --preview] 39 | language_version: python3.10 40 | - repo: https://github.com/pycqa/isort 41 | rev: 5.12.0 42 | hooks: 43 | - id: isort 44 | name: isort Formatting 45 | language: python 46 | types: [file, python] 47 | ci: 48 | autoupdate_branch: "master" 49 | autofix_prs: true 50 | autoupdate_commit_msg: "ci: weekly check." 51 | autoupdate_schedule: weekly 52 | autofix_commit_msg: "ci: correct from checks." 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 eunwoo1104 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 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Astro 2 | ===== 3 | 4 | Astro is an open-sourced, in-house bot designed for the needs of the interactions.py Discord support server. We've built this bot off of our own library. 5 | 6 | Features 7 | ******** 8 | 9 | Some of the features Astro has include: 10 | 11 | - "Tag" commands for storing text prompts, followed by fuzzy matched autocompletion. 12 | - GitHub embed rendering of Pull Requests, Issues and commit hash logs. 13 | - Commands to help manage the help channel, including post generation and archiving. 14 | 15 | Setup 16 | ***** 17 | 18 | Setting up this bot is simple. 19 | 20 | First, clone this repository. Then, in order to install any dependencies that the bot uses, please run this command in the root namespace of the cloned repository: 21 | 22 | 23 | .. code-block:: bash 24 | 25 | pip install -U -r requirements.txt 26 | 27 | Astro requires a ``.env`` file that contains sensitive information, like your bot token. In particular, it is looking for a file in this format: 28 | 29 | .. code-block:: bash 30 | 31 | TOKEN="Your bot token." 32 | MONGO_DB_URL="A url pointing to a MongoDB database. You can create one for free on the MongoDB website, or run one yourself." 33 | 34 | ``metadata.yml`` will also likely need to be changed for your needs. The configuration on this repository is for the interactions.py server - you will need to change the IDs of each entry to the appropriate channels, roles, and tags in your server. 35 | 36 | Finally, running the bot is as simple as: 37 | 38 | .. code-block:: bash 39 | 40 | python bot.py 41 | 42 | .. _interactions.py: https://discord.gg/interactions 43 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | load_dotenv() 4 | 5 | import asyncio 6 | import logging 7 | import os 8 | 9 | import aiohttp 10 | import interactions as ipy 11 | from beanie import init_beanie 12 | from interactions.ext import prefixed_commands 13 | from motor.motor_asyncio import AsyncIOMotorClient 14 | from pymongo.server_api import ServerApi 15 | 16 | import common.utils as utils 17 | from common.const import * 18 | from common.models import Tag 19 | 20 | logger = logging.getLogger("astro_bot") 21 | logger.setLevel(logging.DEBUG) 22 | 23 | 24 | stderr_handler = logging.StreamHandler() 25 | stderr_handler.setLevel(logging.WARNING) 26 | logger.addHandler(stderr_handler) 27 | 28 | file_handler = logging.FileHandler(filename="discord.log", encoding="utf-8", mode="a") 29 | file_handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")) 30 | file_handler.setLevel(logging.INFO) 31 | logger.addHandler(file_handler) 32 | 33 | activity = ipy.Activity.create(name="you. 👀", type=ipy.ActivityType.WATCHING) 34 | 35 | intents = ipy.Intents.new( 36 | guilds=True, 37 | guild_members=True, 38 | guild_moderation=True, 39 | guild_messages=True, 40 | direct_messages=True, 41 | message_content=True, 42 | ) 43 | 44 | bot = ipy.Client( 45 | intents=intents, 46 | sync_interactions=False, 47 | debug_scope=METADATA["guild"], 48 | disable_dm_commands=True, 49 | status=ipy.Status.DO_NOT_DISTURB, 50 | activity=activity, 51 | fetch_members=True, 52 | send_command_tracebacks=False, 53 | logger=logger, 54 | ) 55 | prefixed_commands.setup(bot) 56 | 57 | 58 | async def start(): 59 | client = AsyncIOMotorClient(os.environ["MONGO_DB_URL"], server_api=ServerApi("1")) 60 | await init_beanie(client.Astro, document_models=[Tag]) # type: ignore 61 | 62 | bot.session = aiohttp.ClientSession() 63 | 64 | ext_list = utils.get_all_extensions(SRC_PATH) 65 | 66 | for ext in ext_list: 67 | bot.load_extension(ext) 68 | 69 | try: 70 | await bot.astart(os.environ["TOKEN"]) 71 | finally: 72 | await bot.session.close() 73 | 74 | 75 | @ipy.listen("command_error", disable_default_listeners=True) 76 | async def on_command_error(event: ipy.events.CommandError): 77 | # basically, this, compared to the built-in version: 78 | # - makes sure if the error can be sent ephemerally, it will 79 | # - only log the error if it isn't an "expected" error (check failure, cooldown, etc) 80 | # - send a message to the user if there's an unexpected error 81 | 82 | try: 83 | if isinstance(event.error, ipy.errors.CommandOnCooldown): 84 | await utils.error_send( 85 | event.ctx, 86 | msg=( 87 | ":x: This command is on cooldown.\n" 88 | f"Please try again in {int(event.error.cooldown.get_cooldown_time())} seconds." 89 | ), 90 | color=ASTRO_COLOR, 91 | ) 92 | elif isinstance(event.error, ipy.errors.MaxConcurrencyReached): 93 | await utils.error_send( 94 | event.ctx, 95 | msg=( 96 | ":x: This command has reached its maximum concurrent usage.\nPlease try again" 97 | " shortly." 98 | ), 99 | color=ASTRO_COLOR, 100 | ) 101 | elif isinstance(event.error, ipy.errors.CommandCheckFailure): 102 | await utils.error_send( 103 | event.ctx, 104 | msg=":x: You do not have permission to run this command.", 105 | color=ipy.BrandColors.YELLOW, 106 | ) 107 | elif isinstance(event.error, ipy.errors.BadArgument): 108 | await utils.error_send( 109 | event.ctx, 110 | msg=f":x: {event.error}", 111 | color=ipy.MaterialColors.RED, 112 | ) 113 | else: 114 | await utils.error_send( 115 | event.ctx, 116 | msg=( 117 | ":x: An unexpected error has occured. The error will be logged and should be" 118 | " fixed shortly." 119 | ), 120 | color=ipy.RoleColors.DARK_RED, 121 | ) 122 | bot.dispatch( 123 | ipy.events.Error( 124 | source=f"cmd `/{event.ctx.invoke_target}`", # type: ignore 125 | error=event.error, 126 | args=event.args, 127 | kwargs=event.kwargs, 128 | ctx=event.ctx, 129 | ) 130 | ) 131 | except ipy.errors.LibraryException: 132 | bot.dispatch( 133 | ipy.events.Error( 134 | source=f"cmd `/{event.ctx.invoke_target}`", # type: ignore 135 | error=event.error, 136 | args=event.args, 137 | kwargs=event.kwargs, 138 | ctx=event.ctx, 139 | ) 140 | ) 141 | 142 | 143 | @ipy.listen("startup") 144 | async def on_startup(): 145 | print(f"Logged in as {bot.user.tag}.") 146 | 147 | 148 | if __name__ == "__main__": 149 | try: 150 | asyncio.run(start()) 151 | except KeyboardInterrupt: 152 | logger.info("Shutting down.") 153 | -------------------------------------------------------------------------------- /common/const.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing 3 | from pathlib import Path 4 | 5 | import interactions as ipy 6 | import yaml 7 | 8 | __all__ = ("ASTRO_COLOR", "SRC_PATH", "LanguageRole", "MetadataTyping", "METADATA") 9 | 10 | ASTRO_COLOR = ipy.Color(0x5865F2) 11 | 12 | # we want to be absolutely sure this path is correct, so we 13 | # do a bit of complicated path logic to get the src folder 14 | SRC_PATH = Path(__file__).parent.parent.absolute().as_posix() 15 | 16 | 17 | class LanguageRole(typing.TypedDict): 18 | id: int 19 | emoji: str 20 | 21 | 22 | class MetadataTyping(typing.TypedDict): 23 | guild: int 24 | roles: dict[str, int] 25 | language_roles: dict[str, LanguageRole] 26 | channels: dict[str, int] 27 | autogenerated_tag: int 28 | 29 | 30 | METADATA_PATH = os.environ.get("METADATA_PATH", f"{SRC_PATH}/metadata.yml") 31 | with open(METADATA_PATH, "r") as file: 32 | METADATA: MetadataTyping = yaml.safe_load(file) 33 | -------------------------------------------------------------------------------- /common/models.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import typing 3 | from datetime import datetime 4 | 5 | from beanie import Document, Indexed 6 | 7 | __all__ = ("Tag",) 8 | 9 | 10 | class Tag(Document): 11 | name: typing.Annotated[str, Indexed(str)] 12 | author_id: str 13 | description: str 14 | created_at: datetime 15 | last_edited_at: typing.Optional[datetime] = None 16 | -------------------------------------------------------------------------------- /common/utils.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import typing 3 | from pathlib import Path 4 | 5 | import interactions as ipy 6 | from interactions.ext import prefixed_commands as prefixed 7 | 8 | from common.const import METADATA 9 | 10 | __all__ = ( 11 | "proficient_check", 12 | "proficient_only", 13 | "mod_check", 14 | "mods_only", 15 | "file_to_ext", 16 | "get_all_extensions", 17 | "error_send", 18 | ) 19 | 20 | 21 | def _member_from_ctx(ctx: ipy.BaseContext): 22 | user = ctx.author 23 | 24 | if isinstance(user, ipy.User): 25 | guild = ctx.bot.get_guild(METADATA["guild"]) 26 | if not guild: 27 | return None 28 | 29 | user = guild.get_member(user.id) 30 | if not user: 31 | return None 32 | 33 | return user 34 | 35 | 36 | def proficient_check(ctx: ipy.BaseContext): 37 | user = _member_from_ctx(ctx) 38 | return ( 39 | user.has_role(METADATA["roles"]["Proficient"]) 40 | or user.has_role(METADATA["roles"]["Moderator"]) 41 | if user 42 | else False 43 | ) 44 | 45 | 46 | def proficient_only() -> typing.Any: 47 | async def predicate(ctx: ipy.BaseContext): 48 | return proficient_check(ctx) 49 | 50 | return ipy.check(predicate) 51 | 52 | 53 | def mod_check(ctx: ipy.BaseContext): 54 | user = _member_from_ctx(ctx) 55 | return user.has_role(METADATA["roles"]["Moderator"]) if user else False 56 | 57 | 58 | def mods_only() -> typing.Any: 59 | async def predicate(ctx: ipy.BaseContext): 60 | return mod_check(ctx) 61 | 62 | return ipy.check(predicate) 63 | 64 | 65 | def file_to_ext(str_path, base_path): 66 | # changes a file to an import-like string 67 | str_path = str_path.replace(base_path, "") 68 | str_path = str_path.replace("/", ".") 69 | return str_path.replace(".py", "") 70 | 71 | 72 | def get_all_extensions(str_path, folder="exts"): 73 | # gets all extensions in a folder 74 | ext_files = collections.deque() 75 | loc_split = str_path.split(folder) 76 | base_path = loc_split[0] 77 | 78 | if base_path == str_path: 79 | base_path = base_path.replace("main.py", "") 80 | base_path = base_path.replace("\\", "/") 81 | 82 | if base_path[-1] != "/": 83 | base_path += "/" 84 | 85 | pathlist = Path(f"{base_path}/{folder}").glob("**/*.py") 86 | for path in pathlist: 87 | str_path = str(path.as_posix()) 88 | str_path = file_to_ext(str_path, base_path) 89 | 90 | if not str_path.startswith("_"): 91 | ext_files.append(str_path) 92 | 93 | return ext_files 94 | 95 | 96 | async def error_send( 97 | ctx: ipy.InteractionContext | prefixed.PrefixedContext, 98 | msg: str, 99 | color: ipy.Color, 100 | ): 101 | embed = ipy.Embed(description=msg, color=color) 102 | 103 | # prefixed commands being replied to looks nicer 104 | func_name = "send" if isinstance(ctx, ipy.InteractionContext) else "reply" 105 | func = getattr(ctx, func_name) 106 | 107 | kwargs: dict[str, typing.Any] = {"embeds": [embed]} 108 | 109 | if isinstance(ctx, ipy.InteractionContext): 110 | kwargs["ephemeral"] = not ctx.responded or ctx.ephemeral 111 | 112 | await func(**kwargs) 113 | -------------------------------------------------------------------------------- /exts/docs.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | import interactions as ipy 5 | import lxml.etree as etree 6 | import tansy 7 | 8 | 9 | def url_encode(url: str): 10 | """Partial URL encoder, because we don't want to encode slashes""" 11 | return url.replace(" ", "%20").lower() 12 | 13 | 14 | def url_to_page_name(url: str): 15 | """Turns the URL of a guide into a human readable name""" 16 | url = url.strip("/").split("/")[-1] # Get the last part of the url 17 | return url.replace("%20", " ") 18 | 19 | 20 | def trim_base(url: str): 21 | """Removes the base URL, and replaces %20 with spaces""" 22 | url = url.replace( 23 | "https://interactions-py.github.io/interactions.py/API%20Reference/API%20Reference/", "" 24 | ) 25 | return url.replace("%20", " ") 26 | 27 | 28 | class DocsCommands(ipy.Extension): 29 | def __init__(self, bot: ipy.Client): 30 | self.session: aiohttp.ClientSession = bot.session 31 | self.guides: list[str] = [] 32 | self.search_index: list[dict] = [] 33 | self.search_fields: dict[str, str] = {} 34 | asyncio.create_task(self.fetch_docs_data()) 35 | 36 | async def fetch_docs_data(self): 37 | # Fetch the sitemap 38 | xml = await self.session.get( 39 | "https://interactions-py.github.io/interactions.py/sitemap.xml" 40 | ) 41 | # parse the XML 42 | tree = etree.fromstring(await xml.read()) 43 | namespaces = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"} 44 | sitemap = [page.text for page in tree.findall(".//sm:loc", namespaces)] 45 | # Filter the sitemap into subsections 46 | self.guides = [p for p in sitemap if "/Guides/" in p] 47 | self.api_ref = [p for p in sitemap if "/API%20Reference/" in p] 48 | 49 | docs = tansy.SlashCommand(name="docs") 50 | 51 | @docs.subcommand("guide", sub_cmd_description="Pull up a guide in the interactions.py docs.") 52 | async def guide( 53 | self, 54 | ctx: ipy.SlashContext, 55 | query: str = tansy.Option("The page to search for.", autocomplete=True), 56 | ): 57 | for page in self.guides: 58 | if url_encode(query) in url_encode(page): 59 | await ctx.send(page) 60 | return 61 | raise ipy.errors.BadArgument("Guide not found.") 62 | 63 | @guide.autocomplete("query") 64 | async def guide_autocomplete(self, ctx: ipy.AutocompleteContext): 65 | await ctx.send( 66 | [ 67 | url_to_page_name(page) 68 | for page in self.guides 69 | if url_encode(ctx.input_text) in url_encode(page) 70 | ] 71 | ) 72 | 73 | @docs.subcommand( 74 | "api", sub_cmd_description="Pull up an API Reference in the interactions.py docs." 75 | ) 76 | async def api( 77 | self, 78 | ctx: ipy.SlashContext, 79 | query: str = tansy.Option("The page to search for.", autocomplete=True), 80 | ): 81 | for page in self.api_ref: 82 | if url_encode(query) in url_encode(page): 83 | await ctx.send(page) 84 | return 85 | raise ipy.errors.BadArgument("API Reference not found.") 86 | 87 | @api.autocomplete("query") 88 | async def api_autocomplete(self, ctx: ipy.AutocompleteContext): 89 | await ctx.send( 90 | [ 91 | trim_base(page) 92 | for page in self.api_ref 93 | if url_encode(ctx.input_text) in url_encode(page) 94 | ][:25] 95 | ) 96 | -------------------------------------------------------------------------------- /exts/etc.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import re 3 | from contextlib import suppress 4 | 5 | import interactions as ipy 6 | from interactions.ext import prefixed_commands as prefixed 7 | 8 | import common.utils as utils 9 | from common.const import * 10 | 11 | TOKEN_REG = re.compile(r"[a-zA-Z0-9_-]{23,28}\.[a-zA-Z0-9_-]{6,7}\.[a-zA-Z0-9_-]{27,}") 12 | 13 | 14 | async def mod_check_wrapper(ctx: ipy.BaseContext) -> bool: 15 | return utils.mod_check(ctx) 16 | 17 | 18 | class Etc(ipy.Extension): 19 | def __init__(self, bot: ipy.Client): 20 | self.bot = bot 21 | 22 | @prefixed.prefixed_command() 23 | @ipy.check(mod_check_wrapper) 24 | async def sync(self, ctx: prefixed.PrefixedContext): 25 | await self.bot.synchronise_interactions(scopes=[METADATA["guild"], 0], delete_commands=True) 26 | await ctx.reply(":white_check_mark: Synchronized commands.") 27 | 28 | @ipy.listen() 29 | async def on_message_create(self, event: ipy.events.MessageCreate): 30 | message = event.message 31 | if message.content and TOKEN_REG.search(message.content): 32 | await message.reply( 33 | "Careful with your token! It looks like you leaked it. :eyes:", 34 | delete_after=30, 35 | ) 36 | with suppress(ipy.errors.Forbidden, ipy.errors.NotFound): 37 | await message.delete() 38 | 39 | 40 | def setup(bot: ipy.Client): 41 | importlib.reload(utils) 42 | Etc(bot) 43 | -------------------------------------------------------------------------------- /exts/git.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import re 4 | import textwrap 5 | 6 | import aiohttp 7 | import githubkit 8 | import interactions as ipy 9 | import unidiff 10 | from githubkit.exception import RequestFailed 11 | from githubkit.rest import Issue 12 | from interactions.ext import paginators 13 | from interactions.ext import prefixed_commands as prefixed 14 | 15 | from common.const import ASTRO_COLOR 16 | 17 | GH_SNIPPET_REGEX = re.compile( 18 | r"https?://github\.com/(\S+)/(\S+)/blob/([\S][^\/]+)/([\S][^#]+)#L([\d]+)(?:-L([\d]+))?" 19 | ) 20 | GH_COMMIT_REGEX = re.compile(r"https?://github\.com/(\S+)/(\S+)/commit/([0-9a-fA-F]{,40})") 21 | TAG_REGEX = re.compile(r"(?:\s|^)#(\d{1,5})") 22 | CODEBLOCK_REGEX = re.compile(r"```([^```]*)```") 23 | IMAGE_REGEX = re.compile(r"!\[.+\]\(.+\)") 24 | COMMENT_REGEX = re.compile(r"") 25 | EXCESS_NEW_LINE_REGEX = re.compile(r"(\n[\t\r ]*){3,}") 26 | 27 | 28 | class GitPaginator(paginators.Paginator): 29 | def create_components(self, disable: bool = False): 30 | actionrows = super().create_components(disable=disable) 31 | 32 | # basically: 33 | # - find the callback button (in our case, the delete button) 34 | # - if found, make it red and use the gh_delete custom id for it 35 | for actionrow in actionrows: 36 | for component in actionrow.components: 37 | if ( 38 | isinstance(component, ipy.Button) 39 | and component.custom_id 40 | and "callback" in component.custom_id 41 | ): 42 | component.custom_id = "gh_delete" 43 | component.style = ipy.ButtonStyle.DANGER 44 | 45 | return actionrows 46 | 47 | 48 | class CustomStrIterator: 49 | def __init__(self, strs: list[str]) -> None: 50 | self.strs = strs 51 | self.index = 0 52 | self.length = len(self.strs) 53 | 54 | def __iter__(self): 55 | return self 56 | 57 | def __next__(self) -> str: 58 | if self.index >= self.length: 59 | raise StopIteration 60 | 61 | result = self.strs[self.index] 62 | self.index += 1 63 | return result 64 | 65 | def next(self) -> str: 66 | return self.__next__() 67 | 68 | def back(self) -> None: 69 | self.index -= 1 70 | 71 | 72 | class Git(ipy.Extension): 73 | """An extension dedicated to linking PRs/issues.""" 74 | 75 | def __init__(self, bot): 76 | self.bot: ipy.Client = bot 77 | self.owner = "interactions-py" 78 | self.repo = "interactions.py" 79 | self.gh_client = githubkit.GitHub() 80 | self.session: aiohttp.ClientSession = bot.session 81 | 82 | def clean_content(self, content: str) -> str: 83 | content = content.replace("### Pull-Request specification", "") 84 | content = content.replace("[ ]", "❌") 85 | content = content.replace("[x]", "✅") 86 | content = content.replace("[X]", "✅") 87 | content = CODEBLOCK_REGEX.sub(string=content, repl="`[CODEBLOCK]`") 88 | content = IMAGE_REGEX.sub(string=content, repl="`[IMAGE]`") 89 | content = COMMENT_REGEX.sub(string=content, repl="") 90 | content = EXCESS_NEW_LINE_REGEX.sub(string=content, repl="\n\n") 91 | return content.strip() 92 | 93 | def get_color(self, issue: Issue): 94 | if issue.state == "open": 95 | return ipy.Color(0x00B700) 96 | elif issue.pull_request and issue.pull_request.merged_at: 97 | return ipy.Color(0x9E3EFF) 98 | return ipy.Color(0xC40000) 99 | 100 | def create_timestamps(self, issue: Issue): 101 | timestamps = [f"• Created "] 102 | 103 | if issue.state == "closed": 104 | if issue.pull_request and issue.pull_request.merged_at: 105 | timestamps.append( 106 | f"• Merged " 107 | f" by [{issue.closed_by.login}](https://github.com/{issue.closed_by.login})" 108 | ) 109 | else: 110 | # 18: we should check if issues are closed as under wontfix or completed status. 111 | # (see github api) 112 | timestamps.append( 113 | f"• Closed by" 114 | f" [{issue.closed_by.login}](https://github.com/{issue.closed_by.login})" 115 | ) 116 | 117 | return "\n".join(timestamps) 118 | 119 | def prepare_issue(self, issue: Issue): 120 | embed = ipy.Embed( 121 | title=issue.title, 122 | description=self.create_timestamps(issue), 123 | color=self.get_color(issue), 124 | url=issue.html_url, 125 | ) 126 | if issue.user: 127 | embed.set_footer(text=issue.user.login, icon_url=issue.user.avatar_url) 128 | 129 | body = self.clean_content(issue.body or "No description") 130 | new_body = [] 131 | 132 | # make all headers bold instead 133 | for line in body.split("\n"): # purposely using \n for consistency 134 | if line.startswith("#"): 135 | # ideal format: ## title 136 | space_split = line.split(" ", 1) 137 | if len(space_split) > 1 and all(c == "#" for c in space_split[0].strip()): 138 | line = f"**{space_split[1].strip()}**" 139 | new_body.append(line) 140 | 141 | if len(new_body) > 7: 142 | new_body = new_body[:7] + ["..."] 143 | 144 | embed.add_field("Description", "\n".join(new_body)) 145 | return embed 146 | 147 | def prepare_pr(self, issue: Issue): 148 | embed = ipy.Embed( 149 | title=issue.title, 150 | description=self.create_timestamps(issue), 151 | color=self.get_color(issue), 152 | url=issue.html_url, 153 | ) 154 | if issue.user: 155 | embed.set_footer(text=issue.user.login, icon_url=issue.user.avatar_url, ) 156 | 157 | body = self.clean_content(issue.body or "No description") 158 | line_split = body.split("\n") # purposely using \n for consistency 159 | line_iter = CustomStrIterator(line_split) # we need to go back and forward at will 160 | 161 | # essentially, what we're trying to do is get each "part" of the pr 162 | # that's seperated by a header, or ## 163 | # we can't just use split since split removes the ##, and we also 164 | # want to handle prs that don't have any headers while knowing they 165 | # don't have a header 166 | header_split: list[str] = [] 167 | current_part = [] 168 | 169 | check_types = False 170 | 171 | for line in line_iter: 172 | # we need to extract the pr type so it doesnt take 200 lines 173 | # we also need to ignore whitespace until we get to the checkboxes, so take this 174 | # ugly hack 175 | if line.startswith("## Pull Request Type"): 176 | check_types = True 177 | 178 | # 18: we look for keywords, like "closes" or "fixes" to look for a tagged issue. 179 | # this code assumes ONLY issues are tagged, otherwise a pr WILL be mistaken. 180 | # commenting this out for further revision, sorry! 181 | # if line.lower() in {"closes", "fixes"}: 182 | # tmp = line 183 | # tmp = tmp.strip("#").split(" ") 184 | # for chars in tmp: 185 | # if chars.isdigit(): 186 | # chars = chars.replace(chars, f"https://github.com/issues/{chars}") 187 | 188 | # once we're ready to check the checkboxes and know we're searching for types in the first 189 | # place, we go in here 190 | elif check_types and (line.startswith("- ✅") or line.startswith("- ❌")): 191 | line_iter.back() # rewind to previous line 192 | types: list[str] = [] 193 | 194 | while (line := line_iter.next()) and ( 195 | line.startswith("- ✅") or line.startswith("- ❌") 196 | ): # while the next line is still a checkbox line 197 | if line.startswith("- ✅"): 198 | types.append(line.removeprefix("- ✅ ").strip()) 199 | 200 | if types: 201 | embed.description = ( 202 | f"• Pull Request Type: {', '.join(types)}\n{embed.description}" 203 | ) 204 | 205 | check_types = False 206 | 207 | # continue iter until next title 208 | while (line := line_iter.next()) and not line.startswith("## "): 209 | continue 210 | line_iter.back() 211 | 212 | continue 213 | 214 | elif line.startswith("## "): 215 | if current_part: 216 | header_split.append("\n".join(current_part).strip()) 217 | current_part = [] 218 | 219 | # well, this part is weird 220 | # basically, the old astro version had a "tasks" and a "checklist", 221 | # which were "Checklist" split into two parts based on the line below 222 | # we're trying to "trick" our future parser into thinking these are 223 | # legitmately two seperate parts by manupulating our current_part 224 | # to match up for what's expected 225 | elif "I've made this pull request" in line: 226 | if current_part: 227 | current_part[0] = "## Tasks" 228 | header_split.append("\n".join(current_part).strip()) 229 | current_part = ["## Checklist"] 230 | 231 | if not check_types: 232 | current_part.append(line) 233 | 234 | # likely will be spares 235 | if current_part: 236 | header_split.append("\n".join(current_part).strip()) 237 | 238 | for part in header_split: 239 | desc = part 240 | if part.startswith("## "): 241 | line_split = part.split("\n") 242 | title = line_split[0].removeprefix("## ").strip() 243 | desc = "\n".join(line_split[1:]) 244 | if not desc: 245 | desc = "N/A" 246 | else: 247 | title = "Description" 248 | 249 | if len(desc) > 1021: # field limit 250 | desc = f"{desc[:1021].strip()}..." 251 | 252 | embed.add_field( 253 | title, 254 | desc, 255 | inline=title in ("Python Compatibility", "Tasks", "Checklist"), 256 | ) 257 | 258 | return embed 259 | 260 | async def resolve_issue_num(self, message: ipy.Message, issue_num: int): 261 | try: 262 | resp = await self.gh_client.rest.issues.async_get(self.owner, self.repo, issue_num) 263 | except RequestFailed: 264 | return 265 | 266 | issue = resp.parsed_data 267 | 268 | if issue.pull_request: 269 | embed = self.prepare_pr(issue) 270 | else: 271 | embed = self.prepare_issue(issue) 272 | 273 | await message.reply(embeds=embed) 274 | 275 | async def resolve_gh_snippet(self, message: ipy.Message): 276 | # heavily inspired and slightly stolen from 277 | # https://github.com/NAFTeam/NAFB/blob/0460e8d2cada81e39909198ba3d84fa25f174e1a/scales/githubMessages.py#L203-L241 278 | # NAFB under MIT License, owner LordOfPolls 279 | 280 | results = GH_SNIPPET_REGEX.search(message.content) 281 | 282 | if not results: 283 | return 284 | 285 | owner = results[1] 286 | repo = results[2] 287 | ref = results[3] 288 | file_path = results[4] 289 | extension = ".".join(file_path.split(".")[1:]) 290 | 291 | start_line_num = int(results[5]) if results.end() > 3 and results[5] else 0 292 | end_line_num = int(results[6]) if results.end() > 4 and results[6] else -1 293 | 294 | if end_line_num != -1: 295 | # i dont even know 296 | end_line_num += 1 297 | 298 | if end_line_num != -1 and start_line_num > end_line_num: 299 | return 300 | 301 | if end_line_num == -1 and start_line_num > 0: 302 | end_line_num = start_line_num + 1 303 | 304 | async with self.session.get( 305 | f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{file_path}" 306 | ) as resp: 307 | if resp.status != 200: 308 | return 309 | 310 | # weird code, but basically, we're trying to detect if the file is under 311 | # 1 MiB, because if it's larger, we really don't want to download all 312 | # of it and take memory 313 | 314 | # anyways, readexactly... reads exactly how many bytes are specified 315 | # however, if there are less bytes in the content (file) than 316 | # specified, it will throw an error as it couldn't read everything 317 | # we're abusing this by hoping it throws an error for files under 318 | # 1 MiB, and making it stop downloading a file if it's over 1 MiB 319 | # if it errors, we can get the data of the file from the partial variable 320 | # and continue on 321 | try: 322 | await resp.content.readexactly(1048577) # one MiB + 1 323 | return 324 | except asyncio.IncompleteReadError as e: 325 | content = e.partial 326 | except Exception: # we can get some random errors 327 | return 328 | 329 | try: 330 | file_data = content.decode(resp.get_encoding()) 331 | if not file_data: 332 | return 333 | except Exception: # we can get some random errors 334 | return 335 | 336 | line_split = file_data.splitlines() 337 | file_data = line_split[start_line_num - 1 :] 338 | 339 | if end_line_num > 0: 340 | file_data = file_data[: end_line_num - start_line_num] 341 | 342 | final_text = textwrap.dedent("\n".join(file_data)) 343 | 344 | # there's an invisible character here so that the resulting codeblock 345 | # doesn't fail if the code we're looking at has ` in it 346 | final_text = final_text.replace("`", "`​") 347 | 348 | if len(final_text) > 3900: 349 | character_count = 0 350 | new_final_text = [] 351 | line_split = final_text.splitlines() 352 | 353 | for line in line_split: 354 | character_count += len(line) 355 | if character_count > 3900: 356 | break 357 | 358 | new_final_text.append(line) 359 | 360 | final_text = "\n".join(new_final_text) 361 | 362 | if not final_text: 363 | return 364 | 365 | embed = ipy.Embed( 366 | title=f"{owner}/{repo}", 367 | description=f"```{extension}\n{final_text.strip()}\n```", 368 | color=ASTRO_COLOR, 369 | ) 370 | component = ipy.Button(style=ipy.ButtonStyle.DANGER, emoji="🗑️", custom_id="gh_delete") 371 | await message.suppress_embeds() 372 | await message.reply(embeds=embed, components=component) 373 | 374 | async def resolve_gh_commit_diff(self, message: ipy.Message): 375 | results = GH_COMMIT_REGEX.search(message.content) 376 | 377 | if not results: 378 | return 379 | 380 | owner = results[1] 381 | repo = results[2] 382 | commit_hash = results[3] 383 | 384 | # get special funky url that gets us diff 385 | async with self.session.get( 386 | f"https://github.com/{owner}/{repo}/commit/{commit_hash}.diff" 387 | ) as resp: 388 | if resp.status != 200: 389 | return 390 | 391 | try: 392 | await resp.content.readexactly(1048577) # one MiB + 1 393 | return 394 | except asyncio.IncompleteReadError as e: 395 | content = e.partial 396 | except Exception: # we can get some random errors 397 | return 398 | 399 | try: 400 | file_data = content.decode(resp.get_encoding()) 401 | if not file_data: 402 | return 403 | except Exception: # we can get some random errors 404 | return 405 | 406 | # now, the raw diff we do get is... eh. yeah, it's eh, and i don't want to display it 407 | # so we'll do some processing to make it not so eh 408 | processed_diff = unidiff.PatchSet.from_string(file_data) 409 | final_diff_builder: list[str] = [] 410 | 411 | for diff in processed_diff: 412 | diff: unidiff.PatchedFile 413 | diff_text = str(diff) 414 | 415 | entry = f"--- {diff.path} ---" 416 | if diff.is_rename: 417 | entry = f"--- {diff.source_file[2:]} > {diff.target_file[2:]} ---" 418 | new_diff_builder: list[str] = [entry] 419 | 420 | try: 421 | first_double_at = diff_text.index("@@") 422 | rest_of_diff = diff_text[first_double_at:].strip() 423 | 424 | line_split = rest_of_diff.splitlines() 425 | if "No newline at end of file" in line_split[-1]: 426 | line_split = line_split[:-1] 427 | 428 | if diff.is_removed_file: 429 | new_diff_builder.append("File deleted.") 430 | elif diff.added + diff.removed > 1000: 431 | # we have to draw the line somewhere 432 | new_diff_builder.append("File changed. Large changes have not been rendered.") 433 | else: 434 | new_diff_builder.append("\n".join(line_split).strip()) 435 | 436 | except ValueError: 437 | # special cases - usually deletions or renames 438 | if diff.is_rename: 439 | new_diff_builder.append("File renamed.") 440 | elif diff.is_removed_file: 441 | new_diff_builder.append("File deleted.") 442 | elif diff.is_added_file: 443 | new_diff_builder.append("File created.") 444 | else: 445 | new_diff_builder.append("Binary file changed.") 446 | 447 | final_diff_builder.append("\n".join(new_diff_builder)) 448 | 449 | final_diff = "\n\n".join(final_diff_builder) 450 | final_diff = final_diff.replace("`", "`​") 451 | 452 | embeds: list[ipy.Embed] = [] 453 | current_entries: list[str] = [] 454 | 455 | current_length = 0 456 | line_split = final_diff.splitlines() 457 | 458 | url = f"https://github.com/{owner}/{repo}/commit/{commit_hash}" 459 | title = f"{owner}/{repo}@{commit_hash}" 460 | 461 | # the gh embed that gh generates has the title of the commit 462 | # if we can find it, exploit it by using the title from the embed as the 463 | # title of our own embed 464 | if possible_gh_embed := next((e for e in message.embeds if e.url and e.url == url), None): 465 | title = possible_gh_embed.title 466 | else: 467 | with contextlib.suppress(RequestFailed): 468 | resp = await self.gh_client.rest.git.async_get_commit(owner, repo, commit_hash) 469 | data = resp.parsed_data 470 | 471 | # this is around what gh does for their embeds 472 | first_line = data.message.splitlines()[0].strip() 473 | with_extras = f"{first_line} · {owner}/{repo}@{data.sha[:7]}" 474 | title = with_extras if len(with_extras) <= 70 else f"{with_extras[:67]}..." 475 | 476 | for line in line_split: 477 | current_length += len(line) 478 | if current_length > 3700: 479 | current_text = "\n".join(current_entries).strip() 480 | embeds.append( 481 | ipy.Embed( 482 | title=title, 483 | url=url, 484 | description=f"```diff\n{current_text}\n```", 485 | color=ASTRO_COLOR, 486 | ) 487 | ) 488 | current_entries = [] 489 | current_length = 0 490 | 491 | current_entries.append(line) 492 | 493 | if current_entries: 494 | current_text = "\n".join(current_entries).strip() 495 | embeds.append( 496 | ipy.Embed( 497 | title=title, 498 | url=url, 499 | description=f"```diff\n{current_text}\n```", 500 | color=ASTRO_COLOR, 501 | ) 502 | ) 503 | 504 | if not embeds: 505 | return 506 | 507 | if len(embeds) > 1: 508 | the_pag = GitPaginator.create_from_embeds(self.bot, *embeds, timeout=300) 509 | the_pag.show_callback_button = True 510 | the_pag.callback_button_emoji = "🗑️" 511 | the_pag.callback = self.delete_gh.callback 512 | 513 | fake_ctx = prefixed.PrefixedContext.from_message(self.bot, message) 514 | 515 | await message.suppress_embeds() 516 | await the_pag.reply(fake_ctx) 517 | 518 | else: 519 | component = ipy.Button(style=ipy.ButtonStyle.DANGER, emoji="🗑️", custom_id="gh_delete") 520 | await message.suppress_embeds() 521 | await message.reply(embeds=embeds, components=component) 522 | 523 | @ipy.component_callback("gh_delete") # type: ignore 524 | async def delete_gh(self, ctx: ipy.ComponentContext): 525 | await ctx.defer(ephemeral=True) 526 | reply = await self.bot.cache.fetch_message( 527 | ctx.message.message_reference.channel_id, 528 | ctx.message.message_reference.message_id, 529 | ) 530 | if reply: 531 | if ctx.author.id == reply.author.id or ( 532 | isinstance(ctx.author, ipy.Member) 533 | and ctx.author.has_permission(ipy.Permissions.MANAGE_MESSAGES) 534 | ): 535 | await ctx.message.delete() 536 | await ctx.send("Deleted.", ephemeral=True) 537 | else: 538 | raise ipy.errors.BadArgument("You do not have permission to delete this.") 539 | else: 540 | raise ipy.errors.BadArgument("Could not find original message.") 541 | 542 | @ipy.listen("message_create") 543 | async def on_message_create(self, event: ipy.events.MessageCreate): 544 | message = event.message 545 | 546 | if message.author.bot: 547 | return 548 | 549 | if "github.com/" in message.content: 550 | if "#L" in message.content: 551 | await self.resolve_gh_snippet(message) 552 | elif "commit" in message.content: 553 | await self.resolve_gh_commit_diff(message) 554 | 555 | elif tag := TAG_REGEX.search(message.content): 556 | issue_num = int(tag.group(1)) 557 | await self.resolve_issue_num(message, issue_num) 558 | 559 | 560 | def setup(bot): 561 | Git(bot) 562 | -------------------------------------------------------------------------------- /exts/help.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import importlib 3 | import io 4 | 5 | import aiohttp 6 | import interactions as ipy 7 | 8 | import common.utils as utils 9 | from common.const import * 10 | 11 | 12 | async def check_archive(ctx: ipy.BaseContext): 13 | return ctx.channel.parent_id in {METADATA["channels"]["help"], METADATA["channels"]["help-v4"]} 14 | 15 | 16 | class HelpChannel(ipy.Extension): 17 | def __init__(self, bot: ipy.Client): 18 | self.client = bot 19 | self.session: aiohttp.ClientSession = bot.session 20 | self.help_channel: ipy.GuildForum = None # type: ignore 21 | asyncio.create_task(self.fill_help_channel()) 22 | 23 | async def fill_help_channel(self): 24 | await self.bot.wait_until_ready() 25 | self.help_channel = self.bot.get_channel(METADATA["channels"]["help"]) # type: ignore 26 | 27 | @ipy.context_menu("Create Help Thread", context_type=ipy.CommandType.MESSAGE) 28 | async def create_thread_context_menu(self, ctx: ipy.ContextMenuContext): 29 | message: ipy.Message = ctx.target # type: ignore 30 | 31 | thread_name = f"[AUTO] {message.content[:40]}" 32 | if len(message.content) > 40: 33 | thread_name += "..." 34 | 35 | modal = ipy.Modal( 36 | ipy.ShortText( 37 | label="What should the thread be named?", 38 | value=thread_name, 39 | min_length=1, 40 | max_length=100, 41 | custom_id="help_thread_name", 42 | ), 43 | ipy.ParagraphText( 44 | label="What should the question be?", 45 | value=message.content[:4000], 46 | min_length=1, 47 | max_length=4000, 48 | custom_id="edit_content", 49 | ), 50 | ipy.ParagraphText( 51 | label="Any addition information?", 52 | required=False, 53 | min_length=1, 54 | max_length=1024, 55 | custom_id="extra_content", 56 | ), 57 | title="Create Help Thread", 58 | custom_id=f"help_thread_creation_{message.channel.id}|{message.id}", 59 | ) 60 | await ctx.send_modal(modal) 61 | await ctx.send(":white_check_mark: Modal sent.", ephemeral=True) 62 | 63 | def generate_tag_select(self, channel: ipy.GuildForum): 64 | tags = channel.available_tags 65 | options: list[ipy.StringSelectOption] = [] 66 | 67 | for tag in tags: 68 | if tag.id == METADATA["autogenerated_tag"]: 69 | continue 70 | 71 | emoji = None 72 | if tag.emoji_id: 73 | emoji = ipy.PartialEmoji(id=tag.emoji_id, name=tag.emoji_name or "emoji") 74 | elif tag.emoji_name: 75 | emoji = ipy.PartialEmoji.from_str(tag.emoji_name) 76 | 77 | options.append(ipy.StringSelectOption(label=tag.name, value=str(tag.id), emoji=emoji)) 78 | 79 | options.append( 80 | ipy.StringSelectOption( 81 | label="Remove all tags", 82 | value="remove_all_tags", 83 | emoji=ipy.PartialEmoji.from_str("🗑"), 84 | ), 85 | ) 86 | return ipy.StringSelectMenu( 87 | *options, 88 | placeholder="Select the tags you want", 89 | min_values=1, 90 | max_values=len(options), 91 | custom_id="tag_selection", 92 | ) 93 | 94 | @ipy.listen("modal_completion") 95 | async def context_menu_handling(self, event: ipy.events.ModalCompletion): 96 | ctx = event.ctx 97 | 98 | if ctx.custom_id.startswith("help_thread_creation_"): 99 | await ctx.defer(ephemeral=True) 100 | 101 | channel_id, message_id = ctx.custom_id.removeprefix("help_thread_creation_").split("|") 102 | 103 | channel = self.bot.get_channel(int(channel_id)) 104 | if not channel: 105 | return await utils.error_send( 106 | ctx, 107 | ":x: Could not find channel of message.", 108 | ipy.MaterialColors.RED, 109 | ) 110 | 111 | message = await channel.fetch_message(int(message_id)) # type: ignore 112 | if not message: 113 | return await utils.error_send( 114 | ctx, ":x: Could not fetch message.", ipy.MaterialColors.RED 115 | ) 116 | 117 | files: list[ipy.File] = [] 118 | 119 | if message.attachments: 120 | for attahcment in message.attachments: 121 | if attahcment.size > 8388608: # if it's over 8 MiB, that's a bit much 122 | continue 123 | 124 | async with self.session.get(attahcment.proxy_url) as resp: 125 | try: 126 | resp.raise_for_status() 127 | except aiohttp.ClientResponseError: 128 | continue 129 | 130 | raw_file = await resp.read() 131 | files.append(ipy.File(io.BytesIO(raw_file), file_name=attahcment.filename)) 132 | 133 | post_thread = await self.help_channel.create_post( 134 | ctx.responses["help_thread_name"], 135 | content=ctx.responses["edit_content"], 136 | applied_tags=[str(METADATA["autogenerated_tag"])], 137 | auto_archive_duration=1440, # type: ignore 138 | files=files, # type: ignore 139 | reason="Auto help thread creation", 140 | allowed_mentions=ipy.AllowedMentions.none(), 141 | ) 142 | 143 | await post_thread.add_member(ctx.author) 144 | await post_thread.add_member(message.author) 145 | 146 | embed = None 147 | 148 | if content := ctx.responses.get("extra_content"): 149 | embed = ipy.Embed( 150 | title="Additional Information", 151 | description=content, 152 | color=ASTRO_COLOR, 153 | ) 154 | embed.set_footer(text="Please create a thread in #help to ask questions!") 155 | 156 | select = self.generate_tag_select(self.help_channel) 157 | 158 | original_message_button = ipy.Button( 159 | style=ipy.ButtonStyle.LINK, 160 | label="Original message", 161 | url=message.jump_url, 162 | ) 163 | close_button = ipy.Button( 164 | style=ipy.ButtonStyle.DANGER, 165 | label="Close this thread", 166 | custom_id="close_thread", 167 | ) 168 | 169 | starter_message = await post_thread.send( 170 | ( 171 | "This help thread was automatically generated. Read the message" 172 | " above for more information." 173 | ), 174 | embeds=embed, 175 | components=[[original_message_button], [select], [close_button]], 176 | ) 177 | await starter_message.pin() 178 | 179 | await message.reply( 180 | f"Hey, {message.author.mention}! At this time, we only help with" 181 | " support-related questions in our help channel. Please redirect to" 182 | f" {post_thread.mention} in order to receive help." 183 | ) 184 | await ctx.send(":white_check_mark: Thread created.", ephemeral=True) 185 | 186 | @ipy.listen("new_thread_create") 187 | async def first_message_for_help(self, event: ipy.events.NewThreadCreate): 188 | thread = event.thread 189 | if not thread.parent_id or int(thread.parent_id) not in { 190 | METADATA["channels"]["help"], 191 | }: 192 | return 193 | 194 | if thread.owner_id == self.bot.user.id: 195 | # an autogenerated thread, don't interfere 196 | return 197 | 198 | help_channel = thread.parent_channel 199 | select = self.generate_tag_select(help_channel) 200 | close_button = ipy.Button( 201 | style=ipy.ButtonStyle.DANGER, 202 | label="Close this thread", 203 | custom_id="close_thread", 204 | ) 205 | 206 | try: 207 | message = await thread.send( 208 | "Hey! Once your issue is solved, press the button below to close this thread!", 209 | components=[[select], [close_button]], 210 | ) 211 | except ipy.errors.HTTPException: 212 | # this tends to happen often if the "thread author has not sent their initial message" 213 | # techically, they already did because you can't make a thread otherwise... 214 | # so we're just waiting for discord to get the memo 215 | await asyncio.sleep(5) 216 | message = await thread.send( 217 | "Hey! Once your issue is solved, press the button below to close this thread!", 218 | components=[[select], [close_button]], 219 | ) 220 | 221 | await message.pin() 222 | 223 | @ipy.component_callback("tag_selection") # type: ignore 224 | async def modify_tags(self, ctx: ipy.ComponentContext): 225 | if not utils.proficient_check(ctx) and ctx.author.id != ctx.channel.owner_id: 226 | return await utils.error_send( 227 | ctx, ":x: You are not a proficient user.", ipy.MaterialColors.YELLOW 228 | ) 229 | 230 | if ctx.channel.archived: 231 | return await ctx.defer(edit_origin=True) 232 | 233 | await ctx.defer(ephemeral=True) 234 | 235 | channel: ipy.GuildForumPost = ctx.channel # type: ignore 236 | tags = [int(v) for v in ctx.values] if "remove_all_tags" not in ctx.values else [] 237 | await channel.edit(applied_tags=tags) 238 | await ctx.send(":white_check_mark: Done.", ephemeral=True) 239 | 240 | @ipy.component_callback("TAG_SELECTION") # type: ignore 241 | async def legacy_modify_tags(self, ctx: ipy.ComponentContext): 242 | await self.modify_tags.call_with_binding(self.modify_tags.callback, ctx) 243 | 244 | @ipy.check(check_archive) # type: ignore 245 | @ipy.slash_command( 246 | "archive", 247 | description="Archives a help thread.", 248 | default_member_permissions=ipy.Permissions.MANAGE_CHANNELS, 249 | ) 250 | async def archive(self, ctx: ipy.InteractionContext): 251 | await ctx.send(":white_check_mark: Archiving...") 252 | await ctx.channel.edit(archived=True, locked=True) 253 | 254 | @ipy.component_callback("close_thread") # type: ignore 255 | async def close_help_thread(self, ctx: ipy.ComponentContext): 256 | if not utils.proficient_check(ctx) and ctx.author.id != ctx.channel.owner_id: 257 | return await utils.error_send( 258 | ctx, ":x: You are not a proficient user.", ipy.MaterialColors.YELLOW 259 | ) 260 | 261 | if ctx.channel.archived: 262 | return await ctx.defer(edit_origin=True) 263 | 264 | await ctx.send(":white_check_mark: Closing. Thank you for using our help system.") 265 | await ctx.channel.edit(archived=True, locked=True) 266 | 267 | @ipy.component_callback("close thread") # type: ignore 268 | async def legacy_close_thread(self, ctx: ipy.ComponentContext): 269 | await self.close_help_thread.call_with_binding(self.close_help_thread.callback, ctx) 270 | 271 | 272 | def setup(bot): 273 | importlib.reload(utils) 274 | HelpChannel(bot) 275 | -------------------------------------------------------------------------------- /exts/info.py: -------------------------------------------------------------------------------- 1 | import interactions as ipy 2 | 3 | from common.const import ASTRO_COLOR 4 | 5 | 6 | class Info(ipy.Extension): 7 | """An extension dedicated to /info.""" 8 | 9 | def __init__(self, bot): 10 | self.bot = bot 11 | 12 | @ipy.slash_command( 13 | "info", 14 | description="Get information about the bot.", 15 | ) 16 | async def info(self, ctx: ipy.InteractionContext): 17 | embed = ipy.Embed( 18 | title="Info", 19 | color=ASTRO_COLOR, 20 | ) 21 | embed.add_field( 22 | "What is this for?", 23 | ( 24 | "Astro is the main bot powering moderation and other utilities in the" 25 | " interactions.py support server. The goal of Astro is to make searching simple," 26 | " and automate our moderation process. Whether that's creating tags with" 27 | " autocompleted suggestions, code examples, or interactive tutorials: Astro has you" 28 | " covered. Interactions should be simple to understand, and coding them is no" 29 | " different." 30 | ), 31 | ) 32 | embed.add_field( 33 | "What does this bot run on?", 34 | ( 35 | "This project is built with interactions.py, the go-to interactions-based Python" 36 | " library that empowers bots with the ability to implement slash commands and" 37 | " components with ease. The codebase of this bot reflects how simple, modular and" 38 | " scalable the library is---staying true to the motto of what it does." 39 | ), 40 | ) 41 | embed.add_field( 42 | "How can I contribute to this bot?", 43 | ( 44 | "Please check out the official GitHub repository which holds the source code of" 45 | " this bot here: https://github.com/interactions-py/Astro" 46 | ), 47 | ) 48 | embed.set_footer("This bot is maintained by the interactions.py ext team.") 49 | await ctx.send(embeds=embed) 50 | 51 | 52 | def setup(bot): 53 | Info(bot) 54 | -------------------------------------------------------------------------------- /exts/roles.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import importlib 3 | import typing 4 | 5 | import interactions as ipy 6 | import tansy 7 | 8 | import common.utils as utils 9 | from common.const import METADATA 10 | 11 | 12 | async def check_admin(ctx: ipy.BaseContext): 13 | return isinstance(ctx.author, ipy.Member) and ctx.author.has_permission( 14 | ipy.Permissions.ADMINISTRATOR 15 | ) 16 | 17 | 18 | class Roles(ipy.Extension): 19 | def __init__(self, bot: ipy.Client): 20 | self.client = bot 21 | self.guild: ipy.Guild = None # type: ignore 22 | 23 | asyncio.create_task(self.fill_guild()) 24 | 25 | async def fill_guild(self): 26 | await self.bot.wait_until_ready() 27 | self.guild = self.bot.get_guild(METADATA["guild"]) # type: ignore 28 | 29 | @tansy.slash_command( 30 | "subscribe", 31 | description="Subscribes (or unsubscribes) you to updates via self-assignable roles.", 32 | ) 33 | async def subscribe( 34 | self, 35 | ctx: ipy.InteractionContext, 36 | changelog: str = tansy.Option( 37 | "To what roles do you want to (un)subscribe to? (default only main library changelogs)", 38 | choices=[ 39 | ipy.SlashCommandChoice( 40 | name="Library Changelogs", 41 | value=str(METADATA["roles"]["Changelog pings"]), 42 | ), 43 | ipy.SlashCommandChoice( 44 | name="Polls Pings", 45 | value=str(METADATA["roles"]["Polls pings"]), 46 | ), 47 | ipy.SlashCommandChoice( 48 | name="Server News", 49 | value=str(METADATA["roles"]["Server news"]), 50 | ), 51 | ], 52 | default=str(METADATA["roles"]["Changelog pings"]), 53 | ), 54 | ): 55 | await ctx.defer(ephemeral=True) 56 | 57 | if typing.TYPE_CHECKING: 58 | assert isinstance(ctx.author, ipy.Member) 59 | 60 | author_roles = set(ctx.author._role_ids) # don't want to update roles till end 61 | str_builder = [":white_check_mark:"] 62 | 63 | for role_id in changelog.split(" "): # kinda smart way of fitting 2+ roles in a choice 64 | action_word = "" 65 | 66 | if ctx.author.has_role(role_id): 67 | author_roles.remove(int(role_id)) 68 | action_word = "removed" 69 | else: 70 | author_roles.add(int(role_id)) 71 | action_word = "added" 72 | 73 | role = self.guild.get_role(role_id) 74 | # id prefer the actual name of the role here since pinging the role 75 | # would probably look weird, but if the role somehow doesn't get 76 | # found, might as well have a backup 77 | role_name = f"`{role.name}`" if role is not None else f"<@&{role_id}>" 78 | # this looks weird out of context, but it'll look like: 79 | # `Changelog pings` role added. 80 | # which seems pretty natural to me 81 | str_builder.append(f"{role_name} role {action_word}.") 82 | 83 | await ctx.author.edit(roles=author_roles) 84 | 85 | await ctx.send( 86 | " ".join(str_builder), 87 | allowed_mentions=ipy.AllowedMentions.none(), 88 | ephemeral=True, 89 | ) 90 | 91 | @ipy.slash_command( 92 | "add-role-menu", 93 | description="N/A.", 94 | default_member_permissions=ipy.Permissions.ADMINISTRATOR, 95 | ) 96 | @ipy.check(check_admin) 97 | async def add_role_menu(self, ctx: ipy.InteractionContext): 98 | await ctx.defer(ephemeral=True) 99 | 100 | info_channel = await self.bot.fetch_channel(METADATA["channels"]["information"]) 101 | 102 | role_menu = ipy.StringSelectMenu( 103 | *( 104 | ipy.StringSelectOption( 105 | label=lang, 106 | # if it were up to me, the value would be the role id 107 | # sadly, we must keep backwards compat 108 | value=lang, 109 | emoji=ipy.PartialEmoji( 110 | id=None, 111 | name=role["emoji"], 112 | animated=False, 113 | ), 114 | ) 115 | for lang, role in METADATA["language_roles"].items() 116 | ), 117 | placeholder="Choose a language.", 118 | custom_id="language_role", 119 | min_values=1, 120 | max_values=len(METADATA["language_roles"]), 121 | ) 122 | 123 | await info_channel.send(components=role_menu) # type: ignore 124 | await ctx.send(":white_check_mark:", ephemeral=True) 125 | 126 | @ipy.component_callback("language_role") # type: ignore 127 | async def on_astro_language_role_select(self, ctx: ipy.ComponentContext): 128 | await ctx.defer(ephemeral=True) 129 | 130 | if typing.TYPE_CHECKING: 131 | assert isinstance(ctx.author, ipy.Member) 132 | 133 | # same idea as subscribe, but... 134 | author_roles = set(ctx.author._role_ids) 135 | 136 | # since there are a lot more languages than roles, i wanted to make the result 137 | # message a bit nicer. that requires having both of these lists 138 | added = [] 139 | removed = [] 140 | 141 | for language in ctx.values: 142 | language: str 143 | 144 | role = METADATA["language_roles"].get(language) 145 | if not role: 146 | # this shouldn't happen 147 | return await utils.error_send( 148 | ctx, 149 | ":x: The role you selected was invalid.", 150 | ipy.MaterialColors.RED, 151 | ) 152 | 153 | if ctx.author.has_role(role["id"]): 154 | author_roles.remove(int(role["id"])) 155 | removed.append(f"`{language}`") # thankfully, the language here is its role name 156 | else: 157 | author_roles.add(int(role["id"])) 158 | added.append(f"`{language}`") 159 | 160 | await ctx.author.edit(roles=author_roles) 161 | 162 | resp = ":white_check_mark: " 163 | # yep, all we're doing is listing out the roles added and removed 164 | if added: 165 | resp += f"Added: {', '.join(added)}. " 166 | if removed: 167 | resp += f"Removed: {', '.join(removed)}." 168 | resp = resp.strip() # not like it's needed, but still 169 | await ctx.send(resp, ephemeral=True) 170 | 171 | 172 | def setup(bot): 173 | importlib.reload(utils) 174 | Roles(bot) 175 | -------------------------------------------------------------------------------- /exts/tags.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import importlib 3 | 4 | import interactions as ipy 5 | import tansy 6 | from beanie import PydanticObjectId 7 | from interactions.ext import paginators 8 | from rapidfuzz import fuzz, process 9 | 10 | import common.utils as utils 11 | from common.const import * 12 | from common.models import Tag 13 | 14 | 15 | class Tags(ipy.Extension): 16 | def __init__(self, bot: ipy.Client): 17 | self.client = bot 18 | 19 | tag = tansy.TansySlashCommand( 20 | name="tag", 21 | description="The base command for managing and viewing tags.", # type: ignore 22 | ) 23 | 24 | @tag.subcommand( 25 | sub_cmd_name="view", 26 | sub_cmd_description="Views a tag that currently exists within the database.", 27 | ) 28 | async def view( 29 | self, 30 | ctx: ipy.InteractionContext, 31 | name: str = tansy.Option("The name of the tag to view.", autocomplete=True), 32 | ): 33 | if not (tag := await Tag.find_one(Tag.name == name)): 34 | raise ipy.errors.BadArgument(f"Tag {name} does not exist.") 35 | 36 | if len(tag.description) > 2048: 37 | await ctx.send( 38 | embed=ipy.Embed(title=tag.name, description=tag.description, color=ASTRO_COLOR) 39 | ) 40 | else: 41 | await ctx.send(tag.description, allowed_mentions=ipy.AllowedMentions.none()) 42 | 43 | @tag.subcommand( 44 | sub_cmd_name="info", 45 | sub_cmd_description=( 46 | "Gathers information about a tag that currently exists within the database." 47 | ), 48 | ) 49 | async def info( 50 | self, 51 | ctx: ipy.InteractionContext, 52 | name: str = tansy.Option("The name of the tag to get.", autocomplete=True), 53 | ): 54 | tag = await Tag.find_one(Tag.name == name) 55 | if not tag: 56 | raise ipy.errors.BadArgument(f"Tag {name} does not exist.") 57 | 58 | embed = ipy.Embed( 59 | title=tag.name, 60 | color=ASTRO_COLOR, 61 | ) 62 | 63 | embed.add_field("Author", f"<@{tag.author_id}>", inline=True) 64 | embed.add_field( 65 | "Timestamps", 66 | f"Created at: \n" 67 | + "Last edited:" 68 | f" {f'' if tag.last_edited_at else 'N/A'}", 69 | inline=True, 70 | ) 71 | embed.add_field( 72 | "Counts", 73 | f"Words: {len(tag.description.split())}\nCharacters: {len(tag.description)}", 74 | inline=True, 75 | ) 76 | embed.set_footer( 77 | "Tags are made and maintained by the Proficient users here in the support" 78 | " server. Please contact one if you believe one is incorrect." 79 | ) 80 | 81 | await ctx.send(embeds=embed) 82 | 83 | @tag.subcommand( 84 | sub_cmd_name="list", 85 | sub_cmd_description="Lists all the tags existing in the database.", 86 | ) 87 | async def list(self, ctx: ipy.InteractionContext): 88 | await ctx.defer() 89 | 90 | all_tags = await Tag.find_all().to_list() 91 | # generate the string summary of each tag 92 | tag_list = [f"` {i+1} ` {t.name}" for i, t in enumerate(all_tags)] 93 | # get chunks of tags, each of which have 9 tags 94 | # why 9? each tag, with its name and number, can be at max 95 | # around ~106-107 characters (100 for name of tag, 6-7 for other parts), 96 | # and fields have a 1024 character limit 97 | # 1024 // 107 gives 9, so here we are 98 | chunks = [tag_list[x : x + 9] for x in range(0, len(tag_list), 9)] 99 | # finally, make embeds for each chunk of tags 100 | embeds = [ 101 | ipy.Embed( 102 | title="Tag List", 103 | description="This is the list of currently existing tags.", 104 | color=ASTRO_COLOR, 105 | fields=[ipy.EmbedField(name="Names", value="\n".join(c))], 106 | ) 107 | for c in chunks 108 | ] 109 | 110 | if len(embeds) == 1: 111 | await ctx.send(embeds=embeds) 112 | return 113 | 114 | pag = paginators.Paginator.create_from_embeds(self.bot, *embeds, timeout=300) 115 | pag.show_select_menu = True 116 | await pag.send(ctx) 117 | 118 | @tag.subcommand( 119 | sub_cmd_name="create", 120 | sub_cmd_description="Creates a tag and adds it into the database.", 121 | ) 122 | @utils.proficient_only() 123 | async def create(self, ctx: ipy.SlashContext): 124 | create_modal = ipy.Modal( 125 | ipy.ShortText( 126 | label="What do you want the tag to be named?", 127 | placeholder="d.py cogs vs. i.py extensions", 128 | custom_id="tag_name", 129 | min_length=1, 130 | max_length=100, 131 | ), 132 | ipy.ParagraphText( 133 | label="What do you want the tag to include?", 134 | placeholder="(Note: you can also put codeblocks in here!)", 135 | custom_id="tag_description", 136 | min_length=1, 137 | max_length=4000, 138 | ), 139 | title="Create new tag", 140 | custom_id="astro_new_tag", 141 | ) 142 | 143 | await ctx.send_modal(create_modal) 144 | await ctx.send(":white_check_mark: Modal sent.", ephemeral=True) 145 | 146 | @tag.subcommand( 147 | sub_cmd_name="edit", 148 | sub_cmd_description="Edits a tag that currently exists within the database.", 149 | ) 150 | @utils.proficient_only() 151 | async def edit( 152 | self, 153 | ctx: ipy.SlashContext, 154 | name: str = tansy.Option("The name of the tag to edit.", autocomplete=True), 155 | ): 156 | tag = await Tag.find_one(Tag.name == name) 157 | if not tag: 158 | raise ipy.errors.BadArgument(f"Tag {name} does not exist.") 159 | 160 | edit_modal = ipy.Modal( 161 | ipy.ShortText( 162 | label="What do you want the tag to be named?", 163 | value=tag.name, 164 | placeholder="d.py cogs vs. i.py extensions", 165 | custom_id="tag_name", 166 | min_length=1, 167 | max_length=100, 168 | ), 169 | ipy.ParagraphText( 170 | label="What do you want the tag to include?", 171 | value=tag.description, 172 | placeholder="(Note: you can also put codeblocks in here!)", 173 | custom_id="tag_description", 174 | min_length=1, 175 | max_length=4000, 176 | ), 177 | title="Edit tag", 178 | custom_id=f"astro_edit_tag_{str(tag.id)}", 179 | ) 180 | await ctx.send_modal(edit_modal) 181 | await ctx.send(":white_check_mark: Modal sent.", ephemeral=True) 182 | 183 | async def add_tag(self, ctx: ipy.ModalContext): 184 | tag_name = ctx.responses["tag_name"] 185 | if await Tag.find_one(Tag.name == tag_name).exists(): 186 | return await utils.error_send( 187 | ctx, 188 | ( 189 | f":x: Tag `{tag_name}` already exists.\n(Did you mean to use" 190 | f" {self.edit.mention()}?)" 191 | ), 192 | ipy.BrandColors.YELLOW, 193 | ) 194 | 195 | await Tag( 196 | name=tag_name, 197 | author_id=str(ctx.author.id), 198 | description=ctx.responses["tag_description"], 199 | created_at=datetime.datetime.now(), 200 | ).create() 201 | 202 | await ctx.send( 203 | ( 204 | f":white_check_mark: `{tag_name}` now exists. In order to view it," 205 | f" please use {self.view.mention()}." 206 | ), 207 | ephemeral=True, 208 | ) 209 | 210 | async def edit_tag(self, ctx: ipy.ModalContext): 211 | tag_id = ctx.custom_id.removeprefix("astro_edit_tag_") 212 | 213 | if tag := await Tag.get(PydanticObjectId(tag_id)): 214 | tag_name = ctx.responses["tag_name"] 215 | 216 | original_name = tag.name 217 | tag.name = tag_name 218 | tag.description = ctx.responses["tag_description"] 219 | tag.last_edited_at = datetime.datetime.now() 220 | await tag.save() # type: ignore 221 | 222 | await ctx.send( 223 | ( 224 | f":white_check_mark: Tag `{tag_name}` has been edited." 225 | if tag_name == original_name 226 | else ( 227 | f":white_check_mark: Tag `{original_name}` has been edited and" 228 | f" re-named to `{tag_name}`." 229 | ) 230 | ), 231 | ephemeral=True, 232 | ) 233 | else: 234 | await ctx.send(":x: The original tag could not be found.", ephemeral=True) 235 | 236 | @ipy.listen("modal_completion") 237 | async def modal_tag_handling(self, event: ipy.events.ModalCompletion): 238 | ctx = event.ctx 239 | 240 | if ctx.custom_id == "astro_new_tag": 241 | await self.add_tag(ctx) 242 | 243 | elif ctx.custom_id.startswith("astro_edit_tag"): 244 | await self.edit_tag(ctx) 245 | 246 | @tag.subcommand( 247 | sub_cmd_name="delete", 248 | sub_cmd_description="Deletes a tag that currently exists within the database.", 249 | ) 250 | @utils.proficient_only() 251 | async def delete( 252 | self, 253 | ctx: ipy.InteractionContext, 254 | name: str = tansy.Option("The name of the tag to delete.", autocomplete=True), 255 | ): 256 | await ctx.defer(ephemeral=True) 257 | 258 | if tag := await Tag.find_one(Tag.name == name): 259 | await tag.delete() # type: ignore 260 | 261 | await ctx.send( 262 | f":white_check_mark: Tag `{name}` has been successfully deleted.", 263 | ephemeral=True, 264 | ) 265 | else: 266 | raise ipy.errors.BadArgument(f"Tag {name} does not exist.") 267 | 268 | def _process_tag(self, tag: Tag): 269 | return tag.lower().strip() if isinstance(tag, str) else tag.name.lower().strip() 270 | 271 | @view.autocomplete("name") 272 | @info.autocomplete("name") 273 | @edit.autocomplete("name") 274 | @delete.autocomplete("name") 275 | async def tag_name_autocomplete(self, ctx: ipy.AutocompleteContext): 276 | if name := ctx.kwargs.get("name"): 277 | tags = await Tag.find_all().to_list() 278 | options = process.extract( 279 | name.lower(), 280 | tags, 281 | scorer=fuzz.partial_ratio, 282 | processor=self._process_tag, 283 | limit=25, 284 | score_cutoff=75, 285 | ) 286 | choices = [{"name": o[0].name, "value": o[0].name} for o in options] 287 | await ctx.send(choices) # type: ignore 288 | else: 289 | await ctx.send( 290 | [{"name": tag.name, "value": tag.name} async for tag in Tag.find_all(limit=25)] 291 | ) 292 | 293 | 294 | def setup(bot): 295 | importlib.reload(utils) 296 | Tags(bot) 297 | -------------------------------------------------------------------------------- /exts/user.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import interactions as ipy 4 | 5 | from common.const import * 6 | 7 | 8 | class UserExt(ipy.Extension): 9 | def __init__(self, bot: ipy.Client): 10 | self.client = bot 11 | self.action_logs: ipy.GuildText = None # type: ignore 12 | asyncio.create_task(self.fill_action_logs()) 13 | 14 | async def fill_action_logs(self): 15 | await self.bot.wait_until_ready() 16 | self.action_logs = self.bot.get_channel(METADATA["channels"]["logs"]) # type: ignore 17 | 18 | @ipy.context_menu("Get User Information", context_type=ipy.CommandType.USER) 19 | async def get_user_information(self, ctx: ipy.InteractionContext): 20 | member: ipy.Member | ipy.User = ctx.target # type: ignore 21 | 22 | roles = list(reversed(sorted(member.roles if isinstance(member, ipy.Member) else []))) 23 | color_to_use = next( 24 | (r.color for r in roles if r.color.value), 25 | member.accent_color or ASTRO_COLOR, 26 | ) 27 | 28 | embed = ipy.Embed( 29 | title="User Information", 30 | description=f"This is the retrieved information on {member.mention}.", 31 | color=color_to_use, 32 | ) 33 | embed.set_author( 34 | member.display_name, 35 | f"https://discord.com/users/{member.id}", 36 | member.display_avatar.as_url(size=128), 37 | ) 38 | embed.set_thumbnail(member.display_avatar.as_url()) 39 | 40 | embed.add_field("Username", member.tag, inline=True) 41 | embed.add_field("ID", member.id, inline=True) 42 | embed.add_field( 43 | "Timestamps", 44 | ( 45 | "Joined:" 46 | f" {member.joined_at.format('R') if isinstance(member, ipy.Member) else 'N/A'}\nCreated:" 47 | f" {member.created_at.format('R')}" 48 | ), 49 | inline=True, 50 | ) 51 | embed.add_field("Roles", ", ".join(r.mention for r in roles) if roles else "N/A") 52 | 53 | await ctx.send(embed=embed, ephemeral=True) 54 | 55 | @ipy.context_menu("Report User", context_type=ipy.CommandType.USER) 56 | async def report_user(self, ctx: ipy.ContextMenuContext): 57 | member: ipy.Member | ipy.User = ctx.target # type: ignore 58 | if not isinstance(member, ipy.Member): 59 | raise ipy.errors.BadArgument("This user has left the server.") 60 | 61 | if member.id == ctx.author.id: 62 | raise ipy.errors.BadArgument("You cannot report yourself.") 63 | 64 | modal = ipy.Modal( 65 | ipy.ParagraphText( 66 | label="Why are you reporting this user?", 67 | custom_id="report_user_reason", 68 | min_length=30, 69 | max_length=1024, 70 | ), 71 | title="Report user", 72 | custom_id=f"astro_report_user_{member.id}", 73 | ) 74 | await ctx.send_modal(modal) 75 | await ctx.send(":white_check_mark: Modal sent.", ephemeral=True) 76 | 77 | @ipy.listen("modal_completion") 78 | async def report_handling(self, event: ipy.events.ModalCompletion): 79 | ctx = event.ctx 80 | 81 | if ctx.custom_id.startswith("astro_report_user_"): 82 | member_id = ctx.custom_id.removeprefix("astro_report_user_") 83 | member = ctx.guild.get_member(int(member_id)) # type: ignore 84 | 85 | if not member: 86 | await ctx.send( 87 | ":x: Could not report user - they likely left the server.", 88 | ephemeral=True, 89 | ) 90 | return 91 | 92 | embed = ipy.Embed( 93 | title="User Reported", 94 | color=ipy.MaterialColors.DEEP_ORANGE, 95 | ) 96 | embed.set_author(member.tag, icon_url=member.display_avatar.as_url(size=128)) 97 | embed.add_field("Reported User", f"<@{member_id}>", inline=True) 98 | embed.add_field("Reported By", ctx.author.mention, inline=True) 99 | embed.add_field("Reason", ctx.responses.get("report_user_reason", "N/A"), inline=False) 100 | 101 | await self.action_logs.send(embed=embed) 102 | await ctx.send(":white_check_mark: Report sent.", ephemeral=True) 103 | 104 | 105 | def setup(bot): 106 | UserExt(bot) 107 | -------------------------------------------------------------------------------- /metadata.yml: -------------------------------------------------------------------------------- 1 | guild: 789032594456576001 2 | 3 | roles: 4 | "Changelog pings": 789773555792740353 5 | "External Changelog pings": 989950290927190117 6 | "Polls pings": 1010535515512119297 7 | "Server news": 1081836785405009990 8 | "Proficient": 818861272484806656 9 | "Moderator": 789041109208793139 10 | 11 | language_roles: 12 | 한국어: 13 | id: 791532197281529937 14 | emoji: 🇰🇷 15 | Русский: 16 | id: 823502288726261781 17 | emoji: 🇷🇺 18 | Deutsch: 19 | id: 853004334945796149 20 | emoji: 🇩🇪 21 | Français: 22 | id: 876494510723588117 23 | emoji: 🇫🇷 24 | हिंदी: 25 | id: 876854835721429023 26 | emoji: 🇮🇳 27 | Italiano: 28 | id: 880657156213461042 29 | emoji: 🇮🇹 30 | Polskie: 31 | id: 880657302812766209 32 | emoji: 🇵🇱 33 | Español: 34 | id: 905859809662889994 35 | emoji: 🇪🇸 36 | Україна: 37 | id: 959472357414666250 38 | emoji: 🇺🇦 39 | 40 | channels: 41 | information: 789033206769778728 42 | help: 1076867263191339078 43 | staff: 820672900583522335 44 | logs: 1230337317898551316 45 | 46 | autogenerated_tag: 1054125934787186890 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beanie 2 | discord-py-interactions 3 | githubkit 4 | lxml 5 | motor[srv] 6 | python-dotenv==0.20.0 7 | pyyaml 8 | rapidfuzz 9 | tansy 10 | unidiff 11 | --------------------------------------------------------------------------------