From e5269c7c92174a67b5bd12f8337d5dbf16677432 Mon Sep 17 00:00:00 2001 From: Noelle Wang <73260931+No767@users.noreply.github.com> Date: Tue, 2 Jan 2024 20:31:29 -0800 Subject: [PATCH] Modernize codebase (#34) * Switch to a trunk-based release * Include Nanika's shutdown handlers * Update `?about` with fresh info * Rename `HajPages` to `RoboPages` This is for consistency with `RoboView` and others. Plus, Haj is an internal reference to a dear mentor of mine * Include basic help command * Move `DevTools` cog into `Admin` * Include Catherine-Chan's reload-all command * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Align to best practices * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check if docker env and send diff revision text * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Include requirements.txt * Upgrade Dockerfile to 3.12 and remove poetry installation * Fix `?about` command not working on Docker * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add ping command * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix logging not working for files * Update .gitignore --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .gitignore | 1 + bot/cogs/__init__.py | 4 +- bot/cogs/admin.py | 184 +++++++++++++++++ bot/cogs/config.py | 4 + bot/cogs/dev_tools.py | 98 --------- bot/cogs/utilities.py | 75 ++++++- bot/launcher.py | 11 +- bot/libs/utils/__init__.py | 3 + bot/libs/utils/checks.py | 14 ++ bot/libs/utils/handler.py | 18 ++ bot/libs/utils/help.py | 299 +++++++++++++++++++++++++++ bot/libs/utils/logger.py | 4 +- bot/libs/utils/pages/__init__.py | 2 +- bot/libs/utils/pages/paginator.py | 2 +- bot/libs/utils/pages/simple_pages.py | 4 +- bot/libs/utils/pages/sources.py | 4 +- bot/rodhaj.py | 16 +- docker/Dockerfile | 37 +--- requirements.txt | 14 ++ 19 files changed, 634 insertions(+), 160 deletions(-) create mode 100644 bot/cogs/admin.py delete mode 100644 bot/cogs/dev_tools.py create mode 100644 bot/libs/utils/handler.py create mode 100644 bot/libs/utils/help.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index b632800..ed84f18 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,7 @@ celerybeat.pid .venv env/ venv/ +rodhaj-venv/ ENV/ env.bak/ venv.bak/ diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py index b87e97f..8da0892 100644 --- a/bot/cogs/__init__.py +++ b/bot/cogs/__init__.py @@ -6,11 +6,11 @@ class VersionInfo(NamedTuple): major: int minor: int micro: int - releaselevel: Literal["alpha", "beta", "final"] + releaselevel: Literal["main", "alpha", "beta", "final"] def __str__(self) -> str: return f"{self.major}.{self.minor}.{self.micro}-{self.releaselevel}" EXTENSIONS = [module.name for module in iter_modules(__path__, f"{__package__}.")] -VERSION: VersionInfo = VersionInfo(major=0, minor=1, micro=0, releaselevel="alpha") +VERSION: VersionInfo = VersionInfo(major=0, minor=1, micro=0, releaselevel="main") diff --git a/bot/cogs/admin.py b/bot/cogs/admin.py new file mode 100644 index 0000000..15bbec7 --- /dev/null +++ b/bot/cogs/admin.py @@ -0,0 +1,184 @@ +import asyncio +import importlib +import os +import re +import subprocess # nosec # We already know this is dangerous, but it's needed +import sys +from typing import Literal, Optional + +import discord +from discord.ext import commands +from discord.ext.commands import Greedy +from libs.utils import RoboContext +from rodhaj import Rodhaj + +GIT_PULL_REGEX = re.compile(r"\s+(?P.*)\b\s+\|\s+[\d]") + + +class Admin(commands.Cog, command_attrs=dict(hidden=True)): + """Administrative commands for Rodhaj""" + + def __init__(self, bot: Rodhaj) -> None: + self.bot = bot + + @property + def display_emoji(self) -> discord.PartialEmoji: + return discord.PartialEmoji(name="\U00002699") + + async def cog_check(self, ctx: RoboContext) -> bool: + return await self.bot.is_owner(ctx.author) + + async def reload_or_load_extension(self, module: str) -> None: + try: + await self.bot.reload_extension(module) + except commands.ExtensionNotLoaded: + await self.bot.load_extension(module) + + def find_modules_from_git(self, output: str) -> list[tuple[int, str]]: + files = GIT_PULL_REGEX.findall(output) + ret: list[tuple[int, str]] = [] + for file in files: + root, ext = os.path.splitext(file) + if ext != ".py" or root.endswith("__init__"): + continue + + true_root = ".".join(root.split("/")[1:]) + + if true_root.startswith("cogs") or true_root.startswith("libs"): + # A subdirectory within these are a part of the codebase + + ret.append((true_root.count(".") + 1, true_root)) + + # For reload order, the submodules should be reloaded first + ret.sort(reverse=True) + return ret + + async def run_process(self, command: str) -> list[str]: + process = await asyncio.create_subprocess_shell( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + result = await process.communicate() + + return [output.decode() for output in result] + + def tick(self, opt: Optional[bool], label: Optional[str] = None) -> str: + lookup = { + True: "\U00002705", + False: "\U0000274c", + None: "\U000023e9", + } + emoji = lookup.get(opt, "\U0000274c") + if label is not None: + return f"{emoji}: {label}" + return emoji + + def format_results(self, statuses: list) -> str: + desc = "\U00002705 - Successful reload | \U0000274c - Failed reload | \U000023e9 - Skipped\n\n" + status = "\n".join(f"- {status}: `{module}`" for status, module in statuses) + desc += status + return desc + + async def reload_exts(self, module: str) -> list[tuple[str, str]]: + statuses = [] + try: + await self.reload_or_load_extension(module) + statuses.append((self.tick(True), module)) + except commands.ExtensionError: + statuses.append((self.tick(False), module)) + + return statuses + + def reload_lib_modules(self, module: str) -> list[tuple[str, str]]: + statuses = [] + try: + actual_module = sys.modules[module] + importlib.reload(actual_module) + statuses.append((self.tick(True), module)) + except KeyError: + statuses.append((self.tick(None), module)) + except Exception: + statuses.append((self.tick(False), module)) + return statuses + + # Umbra's sync command + # To learn more about it, see the link below (and ?tag ass on the dpy server): + # https://about.abstractumbra.dev/discord.py/2023/01/29/sync-command-example.html + @commands.guild_only() + @commands.command(name="sync") + async def sync( + self, + ctx: RoboContext, + guilds: Greedy[discord.Object], + spec: Optional[Literal["~", "*", "^"]] = None, + ) -> None: + """Performs a sync of the tree. This will sync, copy globally, or clear the tree.""" + await ctx.defer() + if not guilds: + if spec == "~": + synced = await self.bot.tree.sync(guild=ctx.guild) + elif spec == "*": + self.bot.tree.copy_global_to(guild=ctx.guild) # type: ignore + synced = await self.bot.tree.sync(guild=ctx.guild) + elif spec == "^": + self.bot.tree.clear_commands(guild=ctx.guild) + await self.bot.tree.sync(guild=ctx.guild) + synced = [] + else: + synced = await self.bot.tree.sync() + + await ctx.send( + f"Synced {len(synced)} commands {'globally' if spec is None else 'to the current guild.'}" + ) + return + + ret = 0 + for guild in guilds: + try: + await self.bot.tree.sync(guild=guild) + except discord.HTTPException: + pass + else: + ret += 1 + + await ctx.send(f"Synced the tree to {ret}/{len(guilds)}.") + + @commands.command(name="reload-all", hidden=True) + async def reload(self, ctx: RoboContext) -> None: + """Reloads all cogs and utils""" + async with ctx.typing(): + stdout, _ = await self.run_process("git pull") + + # progress and stuff is redirected to stderr in git pull + # however, things like "fast forward" and files + # along with the text "already up-to-date" are in stdout + + if stdout.startswith("Already up-to-date."): + await ctx.send(stdout) + return + + modules = self.find_modules_from_git(stdout) + + mods_text = "\n".join( + f"{index}. `{module}`" for index, (_, module) in enumerate(modules, start=1) + ) + prompt_text = ( + f"This will update the following modules, are you sure?\n{mods_text}" + ) + + confirm = await ctx.prompt(prompt_text) + if not confirm: + await ctx.send("Aborting....") + return + + statuses = [] + for is_submodule, module in modules: + if is_submodule: + statuses = self.reload_lib_modules(module) + else: + statuses = await self.reload_exts(module) + + await ctx.send(self.format_results(statuses)) + + +async def setup(bot: Rodhaj) -> None: + await bot.add_cog(Admin(bot)) diff --git a/bot/cogs/config.py b/bot/cogs/config.py index 2c87fa0..3816dff 100644 --- a/bot/cogs/config.py +++ b/bot/cogs/config.py @@ -89,6 +89,10 @@ def __init__(self, bot: Rodhaj) -> None: self.bot = bot self.pool = self.bot.pool + @property + def display_emoji(self) -> discord.PartialEmoji: + return discord.PartialEmoji(name="\U0001f6e0") + @alru_cache() async def get_guild_config(self, guild_id: int) -> Optional[GuildConfig]: # Normally using the star is bad practice but... diff --git a/bot/cogs/dev_tools.py b/bot/cogs/dev_tools.py deleted file mode 100644 index 4ea99eb..0000000 --- a/bot/cogs/dev_tools.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Literal, Optional - -import discord -from cogs import EXTENSIONS -from discord.ext import commands -from discord.ext.commands import Context, Greedy -from libs.utils import RoboContext, RoboView -from rodhaj import Rodhaj - - -class MaybeView(RoboView): - def __init__(self, ctx: RoboContext) -> None: - super().__init__(ctx) - - @discord.ui.button(label="eg") - async def eg( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - await interaction.response.send_message("yo nice oen", ephemeral=True) - - -class DevTools(commands.Cog, command_attrs=dict(hidden=True)): - """Tools for developing RodHaj""" - - def __init__(self, bot: Rodhaj): - self.bot = bot - - async def cog_check(self, ctx: RoboContext) -> bool: - return await self.bot.is_owner(ctx.author) - - # Umbra's sync command - # To learn more about it, see the link below (and ?tag ass on the dpy server): - # https://about.abstractumbra.dev/discord.py/2023/01/29/sync-command-example.html - @commands.guild_only() - @commands.command(name="sync") - async def sync( - self, - ctx: Context, - guilds: Greedy[discord.Object], - spec: Optional[Literal["~", "*", "^"]] = None, - ) -> None: - """Performs a sync of the tree. This will sync, copy globally, or clear the tree. - - Args: - ctx (Context): Context of the command - guilds (Greedy[discord.Object]): Which guilds to sync to. Greedily accepts a number of guilds - spec (Optional[Literal["~", "*", "^"], optional): Specs to sync. - """ - await ctx.defer() - if not guilds: - if spec == "~": - synced = await self.bot.tree.sync(guild=ctx.guild) - elif spec == "*": - self.bot.tree.copy_global_to(guild=ctx.guild) # type: ignore - synced = await self.bot.tree.sync(guild=ctx.guild) - elif spec == "^": - self.bot.tree.clear_commands(guild=ctx.guild) - await self.bot.tree.sync(guild=ctx.guild) - synced = [] - else: - synced = await self.bot.tree.sync() - - await ctx.send( - f"Synced {len(synced)} commands {'globally' if spec is None else 'to the current guild.'}" - ) - return - - ret = 0 - for guild in guilds: - try: - await self.bot.tree.sync(guild=guild) - except discord.HTTPException: - pass - else: - ret += 1 - - await ctx.send(f"Synced the tree to {ret}/{len(guilds)}.") - - @commands.guild_only() - @commands.command(name="reload-all") - async def reload_all(self, ctx: RoboContext) -> None: - """Reloads all cogs. Used in production to not produce any downtime""" - if not hasattr(self.bot, "uptime"): - await ctx.send("Bot + exts must be up and loaded before doing this") - return - - for extension in EXTENSIONS: - await self.bot.reload_extension(extension) - await ctx.send("Successfully reloaded all extensions live") - - @commands.command(name="view-test", hidden=True) - async def view_test(self, ctx: RoboContext) -> None: - view = MaybeView(ctx) - view.message = await ctx.send("yeo", view=view) - - -async def setup(bot: Rodhaj): - await bot.add_cog(DevTools(bot)) diff --git a/bot/cogs/utilities.py b/bot/cogs/utilities.py index a41c807..82760b3 100644 --- a/bot/cogs/utilities.py +++ b/bot/cogs/utilities.py @@ -1,13 +1,14 @@ import datetime import itertools import platform +from time import perf_counter import discord import psutil import pygit2 from discord.ext import commands from discord.utils import format_dt -from libs.utils import Embed, RoboContext, human_timedelta +from libs.utils import Embed, RoboContext, human_timedelta, is_docker from rodhaj import Rodhaj @@ -19,6 +20,10 @@ def __init__(self, bot: Rodhaj) -> None: self.bot = bot self.process = psutil.Process() + @property + def display_emoji(self) -> discord.PartialEmoji: + return discord.PartialEmoji(name="\U0001f9f0") + def get_bot_uptime(self, *, brief: bool = False) -> str: return human_timedelta( self.bot.uptime, accuracy=None, brief=brief, suffix=False @@ -47,13 +52,31 @@ def get_last_commits(self, count: int = 5): ) return "\n".join(self.format_commit(c) for c in commits) + def get_current_branch( + self, + ) -> str: + repo = pygit2.Repository(".git") + return repo.head.shorthand + + async def fetch_num_active_tickets(self) -> int: + query = "SELECT COUNT(*) FROM tickets;" + value = await self.bot.pool.fetchval(query) + if value is None: + return 0 + return value + @commands.hybrid_command(name="about") async def about(self, ctx: RoboContext) -> None: """Shows some stats for Rodhaj""" total_members = 0 total_unique = len(self.bot.users) + guilds = 0 for guild in self.bot.guilds: + guilds += 1 + if guild.unavailable: + continue + total_members += guild.member_count or 0 # For Kumiko, it's done differently @@ -61,26 +84,64 @@ async def about(self, ctx: RoboContext) -> None: memory_usage = self.process.memory_full_info().uss / 1024**2 cpu_usage = self.process.cpu_percent() / psutil.cpu_count() - revisions = self.get_last_commits() + revisions = "See [GitHub](https://github.com/transprogrammer/rodhaj)" + working_branch = "Docker" + + if not is_docker(): + revisions = self.get_last_commits() + working_branch = self.get_current_branch() + + footer_text = ( + "Developed by Noelle and the Transprogrammer dev team\n" + f"Made with discord.py v{discord.__version__} | Running Python {platform.python_version()}" + ) embed = Embed() embed.set_author(name=self.bot.user.name, icon_url=self.bot.user.display_avatar.url) # type: ignore - embed.title = "About Me" - embed.description = f"Latest Changes:\n {revisions}" + embed.title = "Rodhaj" + embed.description = ( + "Rodhaj is a modern, improved ModMail bot designed exclusively for " + "the transprogrammer community. By creating a shared inbox, " + "it allows for users and staff to seamlessly communicate safely, securely, and privately. " + "In order to start using Rodhaj, please DM Rodhaj to make a ticket. " + f"\n\nLatest Changes ({working_branch}):\n {revisions}" + ) embed.set_footer( - text=f"Made with discord.py v{discord.__version__} | Running Python {platform.python_version()}", + text=footer_text, icon_url="https://cdn.discordapp.com/emojis/596577034537402378.png", ) - embed.add_field(name="Servers Count", value=len(self.bot.guilds)) + embed.add_field(name="Servers", value=guilds) embed.add_field( - name="User Count", value=f"{total_members} total\n{total_unique} unique" + name="User", value=f"{total_members} total\n{total_unique} unique" ) embed.add_field( name="Process", value=f"{memory_usage:.2f} MiB\n{cpu_usage:.2f}% CPU" ) + embed.add_field( + name="Active Tickets", value=await self.fetch_num_active_tickets() + ) embed.add_field(name="Version", value=str(self.bot.version)) embed.add_field(name="Uptime", value=self.get_bot_uptime(brief=True)) await ctx.send(embed=embed) + @commands.hybrid_command(name="ping") + async def ping(self, ctx: RoboContext) -> None: + """Obtains ping information""" + start = perf_counter() + await self.bot.pool.fetchrow("SELECT 1") + end = perf_counter() + db_ping = end - start + + embed = Embed() + embed.add_field( + name="DB Latency", value=f"```{db_ping * 1000:.2f}ms```", inline=False + ) + embed.add_field( + name="Websocket Latency", + value=f"```{self.bot.latency * 1000:.2f}ms```", + inline=False, + ) + await ctx.send(embed=embed) + @commands.hybrid_command(name="uptime") async def uptime(self, ctx: RoboContext) -> None: """Displays the bot's uptime""" diff --git a/bot/launcher.py b/bot/launcher.py index b841689..b3d15c3 100644 --- a/bot/launcher.py +++ b/bot/launcher.py @@ -1,11 +1,11 @@ import os +import signal import asyncpg import discord from aiohttp import ClientSession from environs import Env -from libs.utils import RodhajLogger - +from libs.utils import KeyboardInterruptHandler, RodhajLogger from rodhaj import Rodhaj if os.name == "nt": @@ -32,6 +32,8 @@ async def main() -> None: async with Rodhaj( intents=intents, session=session, pool=pool, dev_mode=DEV_MODE ) as bot: + bot.loop.add_signal_handler(signal.SIGTERM, KeyboardInterruptHandler(bot)) + bot.loop.add_signal_handler(signal.SIGINT, KeyboardInterruptHandler(bot)) await bot.start(TOKEN) @@ -41,7 +43,4 @@ def launch() -> None: if __name__ == "__main__": - try: - launch() - except KeyboardInterrupt: - pass + launch() diff --git a/bot/libs/utils/__init__.py b/bot/libs/utils/__init__.py index a2b8ab0..09083f5 100644 --- a/bot/libs/utils/__init__.py +++ b/bot/libs/utils/__init__.py @@ -1,11 +1,14 @@ from .checks import ( is_admin as is_admin, + is_docker as is_docker, is_manager as is_manager, is_mod as is_mod, ) from .context import RoboContext as RoboContext from .embeds import Embed as Embed, ErrorEmbed as ErrorEmbed from .errors import send_error_embed as send_error_embed +from .handler import KeyboardInterruptHandler as KeyboardInterruptHandler +from .help import RodhajHelp as RodhajHelp from .logger import RodhajLogger as RodhajLogger from .modals import RoboModal as RoboModal from .time import human_timedelta as human_timedelta diff --git a/bot/libs/utils/checks.py b/bot/libs/utils/checks.py index 19a8284..a006469 100644 --- a/bot/libs/utils/checks.py +++ b/bot/libs/utils/checks.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Callable, TypeVar from discord import app_commands @@ -50,3 +51,16 @@ def is_mod(): def is_admin(): return hybrid_permissions_check(administrator=True) + + +def is_docker() -> bool: + """Checks if the current environment is running in Docker + + Returns: + bool: Returns `True` if in fact it is an Docker environment, + `False` if not + """ + path = "/proc/self/cgroup" + return os.path.exists("/.dockerenv") or ( + os.path.isfile(path) and any("docker" in line for line in open(path)) + ) diff --git a/bot/libs/utils/handler.py b/bot/libs/utils/handler.py new file mode 100644 index 0000000..7d4afea --- /dev/null +++ b/bot/libs/utils/handler.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from bot.rodhaj import Rodhaj + + +class KeyboardInterruptHandler: + def __init__(self, bot: Rodhaj): + self.bot = bot + self._task: Optional[asyncio.Task] = None + + def __call__(self): + if self._task: + raise KeyboardInterrupt + self._task = self.bot.loop.create_task(self.bot.close()) diff --git a/bot/libs/utils/help.py b/bot/libs/utils/help.py new file mode 100644 index 0000000..eeb9347 --- /dev/null +++ b/bot/libs/utils/help.py @@ -0,0 +1,299 @@ +import inspect +import itertools +from typing import Any, Optional, Union + +import discord +from discord.ext import commands, menus + +from .pages import RoboPages + +# RGB Colors: +# Pink (255, 161, 231) - Used for the main bot page +# Lavender (197, 184, 255) - Used for cog and group pages +# Light Orange (255, 199, 184) - Used for command pages + + +class GroupHelpPageSource(menus.ListPageSource): + def __init__( + self, + group: Union[commands.Group, commands.Cog], + entries: list[commands.Command], + *, + prefix: str, + ): + super().__init__(entries=entries, per_page=6) + self.group: Union[commands.Group, commands.Cog] = group + self.prefix: str = prefix + self.title: str = f"{self.group.qualified_name} Commands" + self.description: str = self.group.description + + async def format_page(self, menu: RoboPages, commands: list[commands.Command]): + embed = discord.Embed( + title=self.title, + description=self.description, + colour=discord.Colour.from_rgb(197, 184, 255), + ) + + for command in commands: + signature = f"{command.qualified_name} {command.signature}" + embed.add_field( + name=signature, + value=command.short_doc or "No help given...", + inline=False, + ) + + maximum = self.get_max_pages() + if maximum > 1: + embed.set_author( + name=f"Page {menu.current_page + 1}/{maximum} ({len(self.entries)} commands)" + ) + + embed.set_footer( + text=f'Use "{self.prefix}help command" for more info on a command.' + ) + return embed + + +class HelpSelectMenu(discord.ui.Select["HelpMenu"]): + def __init__(self, entries: dict[commands.Cog, list[commands.Command]], bot): + super().__init__( + placeholder="Select a category...", + min_values=1, + max_values=1, + row=0, + ) + self.cmds: dict[commands.Cog, list[commands.Command]] = entries + self.bot = bot + self.__fill_options() + + def __fill_options(self) -> None: + self.add_option( + label="Index", + emoji="\N{WAVING HAND SIGN}", + value="__index", + description="The help page showing how to use the bot.", + ) + for cog, cmds in self.cmds.items(): + if not cmds: + continue + description = cog.description.split("\n", 1)[0] or None + emoji = getattr(cog, "display_emoji", None) + self.add_option( + label=cog.qualified_name, + value=cog.qualified_name, + description=description, + emoji=emoji, + ) + + async def callback(self, interaction: discord.Interaction): + if self.view is not None: + value = self.values[0] + if value == "__index": + await self.view.rebind(FrontPageSource(), interaction) + else: + cog = self.bot.get_cog(value) + if cog is None: + await interaction.response.send_message( + "Somehow this category does not exist?", ephemeral=True + ) + return + + commands = self.cmds[cog] + if not commands: + await interaction.response.send_message( + "This category has no commands for you", ephemeral=True + ) + return + + source = GroupHelpPageSource( + cog, commands, prefix=self.view.ctx.clean_prefix + ) + await self.view.rebind(source, interaction) + + +class HelpMenu(RoboPages): + def __init__(self, source: menus.PageSource, ctx: commands.Context): + super().__init__(source, ctx=ctx, compact=True) + + def add_categories( + self, commands: dict[commands.Cog, list[commands.Command]] + ) -> None: + self.clear_items() + self.add_item(HelpSelectMenu(commands, self.ctx.bot)) + self.fill_items() + + async def rebind( + self, source: menus.PageSource, interaction: discord.Interaction + ) -> None: + self.source = source + self.current_page = 0 + + await self.source._prepare_once() + page = await self.source.get_page(0) + kwargs = await self.get_kwargs_from_page(page) + self._update_labels(0) + await interaction.response.edit_message(**kwargs, view=self) + + +class FrontPageSource(menus.PageSource): + def is_paginating(self) -> bool: + # This forces the buttons to appear even in the front page + return True + + def get_max_pages(self) -> Optional[int]: + # There's only one actual page in the front page + # However we need at least 2 to show all the buttons + return 2 + + async def get_page(self, page_number: int) -> Any: + # The front page is a dummy + self.index = page_number + return self + + def format_page(self, menu: HelpMenu, page: Any): + embed = discord.Embed( + title="Bot Help", colour=discord.Colour.from_rgb(255, 161, 231) + ) + embed.description = inspect.cleandoc( + f""" + Hello! Welcome to the help page. + + Use "{menu.ctx.clean_prefix}help command" for more info on a command. + Use "{menu.ctx.clean_prefix}help category" for more info on a category. + Use the dropdown menu below to select a category. + """ + ) + + if self.index == 0: + embed.add_field( + name="About Rodhaj", + value=( + "Rodhaj is a modern, improved ModMail bot designed exclusively for " + "the transprogrammer community. Rodhaj offers an advanced shared ticket inbox, " + "which allows for users and staff to seamlessly communicate safely, securely, and privately. " + "In addition, utilities to ease communication such as tags are available. You can get more" + "information on the commands offered by using the dropdown below.\n\n" + "Rodhaj is also open source. You can see the code on [GitHub](https://github.com/transprogrammer/rodhaj)" + ), + inline=False, + ) + elif self.index == 1: + entries = ( + ("", "This means the argument is __**required**__."), + ("[argument]", "This means the argument is __**optional**__."), + ("[A|B]", "This means that it can be __**either A or B**__."), + ( + "[argument...]", + "This means you can have multiple arguments.\n" + "Now that you know the basics, it should be noted that...\n" + "__**You do not type in the brackets!**__", + ), + ) + + embed.add_field( + name="How do I use this bot?", + value="Reading the bot signature is pretty simple.", + ) + + for name, value in entries: + embed.add_field(name=name, value=value, inline=False) + + return embed + + +class RodhajHelp(commands.HelpCommand): + context: commands.Context + + def __init__(self): + super().__init__( + command_attrs={ + "cooldown": commands.CooldownMapping.from_cooldown( + 1, 3.0, commands.BucketType.member + ), + "help": "Shows help about the bot, a command, or a category", + } + ) + + async def on_help_command_error( + self, ctx: commands.Context, error: commands.CommandError + ): + if isinstance(error, commands.CommandInvokeError): + # Ignore missing permission errors + if ( + isinstance(error.original, discord.HTTPException) + and error.original.code == 50013 + ): + return + + await ctx.send(str(error.original)) + + def get_command_signature(self, command: commands.Command) -> str: + parent = command.full_parent_name + if len(command.aliases) > 0: + aliases = "|".join(command.aliases) + fmt = f"[{command.name}|{aliases}]" + if parent: + fmt = f"{parent} {fmt}" + alias = fmt + else: + alias = command.name if not parent else f"{parent} {command.name}" + return f"{alias} {command.signature}" + + async def send_bot_help(self, mapping): + bot = self.context.bot + + def key(command) -> str: + cog = command.cog + return cog.qualified_name if cog else "\U0010ffff" + + entries: list[commands.Command] = await self.filter_commands( + bot.commands, sort=True, key=key + ) + + all_commands: dict[commands.Cog, list[commands.Command]] = {} + for name, children in itertools.groupby(entries, key=key): + if name == "\U0010ffff": + continue + + cog = bot.get_cog(name) + if cog is not None: + all_commands[cog] = sorted(children, key=lambda c: c.qualified_name) + + menu = HelpMenu(FrontPageSource(), ctx=self.context) + menu.add_categories(all_commands) + await menu.start() + + async def send_cog_help(self, cog): + entries = await self.filter_commands(cog.get_commands(), sort=True) + menu = HelpMenu( + GroupHelpPageSource(cog, entries, prefix=self.context.clean_prefix), + ctx=self.context, + ) + await menu.start() + + def common_command_formatting(self, embed_like, command): + embed_like.title = self.get_command_signature(command) + if command.description: + embed_like.description = f"{command.description}\n\n{command.help}" + else: + embed_like.description = command.help or "No help found..." + + async def send_command_help(self, command): + # No pagination necessary for a single command. + embed = discord.Embed(colour=discord.Colour.from_rgb(255, 199, 184)) + self.common_command_formatting(embed, command) + await self.context.send(embed=embed) + + async def send_group_help(self, group): + subcommands = group.commands + if len(subcommands) == 0: + return await self.send_command_help(group) + + entries = await self.filter_commands(subcommands, sort=True) + if len(entries) == 0: + return await self.send_command_help(group) + + source = GroupHelpPageSource(group, entries, prefix=self.context.clean_prefix) + self.common_command_formatting(source, group) + menu = HelpMenu(source, ctx=self.context) + await menu.start() diff --git a/bot/libs/utils/logger.py b/bot/libs/utils/logger.py index aa20cd1..310e6ca 100644 --- a/bot/libs/utils/logger.py +++ b/bot/libs/utils/logger.py @@ -11,7 +11,7 @@ class RodhajLogger: def __init__(self) -> None: self.self = self - self.log = logging.getLogger("zoee") + self.log = logging.getLogger("rodhaj") def __enter__(self) -> None: max_bytes = 32 * 1024 * 1024 # 32 MiB @@ -25,7 +25,7 @@ def __enter__(self) -> None: backupCount=5, ) fmt = logging.Formatter( - fmt="%(asctime)s %(levelname)s %(message)s", + fmt="%(asctime)s %(levelname)s\t%(message)s", datefmt="[%Y-%m-%d %H:%M:%S]", ) handler.setFormatter(fmt) diff --git a/bot/libs/utils/pages/__init__.py b/bot/libs/utils/pages/__init__.py index b6f3457..84bf3e2 100644 --- a/bot/libs/utils/pages/__init__.py +++ b/bot/libs/utils/pages/__init__.py @@ -1,4 +1,4 @@ -from .paginator import HajPages as HajPages +from .paginator import RoboPages as RoboPages from .simple_pages import SimplePages as SimplePages from .sources import ( EmbedListSource as EmbedListSource, diff --git a/bot/libs/utils/pages/paginator.py b/bot/libs/utils/pages/paginator.py index 6b16cb1..accb4c2 100644 --- a/bot/libs/utils/pages/paginator.py +++ b/bot/libs/utils/pages/paginator.py @@ -10,7 +10,7 @@ # This is originally from RoboDanny's Paginator class (RoboPages) -class HajPages(discord.ui.View): +class RoboPages(discord.ui.View): def __init__( self, source: menus.PageSource, diff --git a/bot/libs/utils/pages/simple_pages.py b/bot/libs/utils/pages/simple_pages.py index 975387a..cbf4876 100644 --- a/bot/libs/utils/pages/simple_pages.py +++ b/bot/libs/utils/pages/simple_pages.py @@ -1,11 +1,11 @@ import discord from discord.ext import commands -from .paginator import HajPages +from .paginator import RoboPages from .sources import SimplePageSource -class SimplePages(HajPages): +class SimplePages(RoboPages): """A simple pagination session reminiscent of the old Pages interface. Basically an embed with some normal formatting. diff --git a/bot/libs/utils/pages/sources.py b/bot/libs/utils/pages/sources.py index 6cbd195..c9b9665 100644 --- a/bot/libs/utils/pages/sources.py +++ b/bot/libs/utils/pages/sources.py @@ -6,7 +6,7 @@ from discord.ext import menus from ..embeds import Embed -from .paginator import HajPages +from .paginator import RoboPages # I (Noelle) will more than likely work on an cleaner way for this @@ -14,7 +14,7 @@ class EmbedListSource(menus.ListPageSource): """Source for taking contents of an Embed, and formatting them into a page""" async def format_page( - self, menu: HajPages, entries: dict[str, Any] + self, menu: RoboPages, entries: dict[str, Any] ) -> discord.Embed: """Formatter for the embed list source diff --git a/bot/rodhaj.py b/bot/rodhaj.py index f4a2fe4..8943fcd 100644 --- a/bot/rodhaj.py +++ b/bot/rodhaj.py @@ -1,5 +1,4 @@ import logging -import signal from pathlib import Path from typing import Union @@ -8,7 +7,12 @@ from aiohttp import ClientSession from cogs import EXTENSIONS, VERSION from discord.ext import commands -from libs.utils import RoboContext, RodhajCommandTree, send_error_embed +from libs.utils import ( + RoboContext, + RodhajCommandTree, + RodhajHelp, + send_error_embed, +) _fsw = True try: @@ -34,7 +38,7 @@ def __init__( type=discord.ActivityType.watching, name="a game" ), command_prefix=["r>", "?", "!"], - help_command=None, # I need to create one + help_command=RodhajHelp(), intents=intents, tree_cls=RodhajCommandTree, *args, @@ -66,12 +70,6 @@ async def on_command_error( await send_error_embed(ctx, error) async def setup_hook(self) -> None: - def stop(): - self.loop.create_task(self.close()) - - self.loop.add_signal_handler(signal.SIGTERM, stop) - self.loop.add_signal_handler(signal.SIGINT, stop) - for extension in EXTENSIONS: await self.load_extension(extension) diff --git a/docker/Dockerfile b/docker/Dockerfile index 84aa30d..962daa5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,49 +1,26 @@ -#################################################################################################### -## Builder image -#################################################################################################### -FROM python:3.11-slim-bookworm AS builder - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - curl \ - git \ - && apt-get clean - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -RUN curl -sSL https://install.python-poetry.org | python - -ENV PATH="${PATH}:/root/.local/bin" - -WORKDIR /rodhaj -COPY /pyproject.toml /rodhaj/ -COPY /poetry.lock /rodhaj/ - -RUN poetry export --output requirements.txt --without-hashes --only main - #################################################################################################### ## Final image #################################################################################################### -FROM python:3.11-slim-bookworm +FROM python:3.12-slim-bookworm + +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends \ tini \ bash \ netcat-traditional \ - libopus-dev \ libffi-dev \ - libsodium-dev \ git \ + gcc \ + libc6-dev \ && apt-get clean WORKDIR /rodhaj COPY /bot /rodhaj/bot/ COPY /docker/start.sh /rodhaj/start.sh COPY /docker/wait-for /rodhaj/wait-for - -COPY --from=builder /rodhaj/requirements.txt /rodhaj/requirements.txt +COPY /requirements.txt /rodhaj/requirements.txt RUN adduser --disabled-password --gecos "" rodhaj \ && chown -R rodhaj:rodhaj /rodhaj \ @@ -65,4 +42,4 @@ STOPSIGNAL SIGTERM LABEL org.opencontainers.image.title="Rodhaj" LABEL org.opencontainers.image.description="A discord modmail bot" LABEL org.opencontainers.image.licenses="Apache-2.0" -LABEL org.opencontainers.image.source="https://github.com/transprogrammer/rodhaj" +LABEL org.opencontainers.image.source="https://github.com/transprogrammer/rodhaj" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4a2c861 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +discord.py[speed]==2.3.2 +uvloop==0.19.0 ; sys_platform != "win32" +winloop==0.1.0 ; sys_platform == "win32" +asyncpg==0.29.0 +discord-ext-menus @ git+https://github.com/Rapptz/discord-ext-menus@8686b5d1bbc1d3c862292eb436ab630d6e9c9b53 +psutil==5.9.7 +pygit2==1.13.3 +python-dateutil==2.8.2 +click==8.1.7 +typing-extensions==4.9.0 +environs==10.0.0 +async-lru==2.0.4 +msgspec==0.18.5 +jishaku==2.5.2 \ No newline at end of file