diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..25f19c2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +name: "CodeQL" + +on: + push: + branches: + - main + + pull_request: + branches: + - main + schedule: + - cron: '36 7 * * 0' + +jobs: + Analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Set up Python 3.11 + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Set up Poetry + uses: Gr1N/setup-poetry@v8 + - name: Cache Poetry + id: cache-poetry + uses: actions/cache@v3 + with: + path: ~/.cache/pypoetry/virtualenvs + key: ${{ runner.os }}-codeql-python-${{ hashFiles('**/poetry.lock') }} + - name: Install Poetry Dependencies + if: steps.cache-poetry.outputs.cache-hit != 'true' + run: | + poetry install + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: python + setup-python-dependencies: false + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + upload: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..aa7e90b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,51 @@ +name: Lint +on: + push: + branches: + - main + + pull_request: + branches: + - main + +jobs: + Analyze: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + version: [3.9, '3.10', '3.11'] + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.version }} + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.version }} + + - name: Set up Poetry + uses: Gr1N/setup-poetry@v8 + + - name: Cache Poetry + id: cache-poetry + uses: actions/cache@v3 + with: + path: ~/.cache/pypoetry/virtualenvs + key: ${{ runner.os }}-poetry-lint-${{ matrix.version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install Poetry Dependencies + if: steps.cache-poetry.outputs.cache-hit != 'true' + run: | + poetry install --with dev + + - name: Run Pyright + run: | + poetry run pyright bot + + - name: Run Ruff + run: | + poetry run ruff bot diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..77cff45 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release +on: + push: + branches: + - main +jobs: + Release: + runs-on: ubuntu-latest + if: contains(github.event.head_commit.message, '#major') || contains(github.event.head_commit.message, '#minor') || contains(github.event.head_commit.message, '#patch') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '0' + + - name: Bump version and push tag + uses: anothrNick/github-tag-action@1.67.0 + id: tag_version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WITH_V: true + RELEASE_BRANCHES: main + + - name: Release New Version + uses: ncipollo/release-action@v1 + with: + bodyFile: "changelog.md" + token: ${{ secrets.PAT_TOKEN }} + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} diff --git a/README.md b/README.md index ceae229..5f8d382 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ # rodhaj proto-repo for discord app + +**later down the road, pls do not push to main branch directly** + +## Stuff that needs to be done + +- [x] Paginators +- [ ] R. Danny migrations or asyncpg-trek +- [ ] The features + +## Getting Started + +### Preface on Slash Commands + +Unlike other frameworks, discord.py does not automatically sync slash commands (if you want to learn more why, see [this and why Noelle is heavily against it](https://github.com/No767/Zoee#preface-on-slash-commands-and-syncing)). So the way to sync is by using an prefixed commands, which is [Umbra's Sync Command](https://about.abstractumbra.dev/discord.py/2023/01/29/sync-command-example.html). More than likely you'll need to read up on how this slash command works in order to get started. In short, you'll probably want to sync your test bot to the guild instead (as demostrated here): + +``` +# Replace 1235 with your guild id +r>sync 1235 +``` + + +### Setup Instructions + +You must have these installed: + +- Poetry +- Python +- Git +- PostgreSQL + +In order to run pg in a docker container, spin up the docker compose file +located in the root of the repo (`sudo docker compose up -d`). + +1. Clone the repo or use it as a template. +2. Copy over the ENV file template to the `bot` directory + + ```bash + cp envs/dev.env bot/.env + ``` +3. Install the dependencies + + ```bash + poetry install + ``` + +4. Configure the settings in the ENV (note that configuring the postgres uri is required) + +5. Run the bot + + ```bash + poetry run python bot/launcher.py + ``` + +6. Once your bot is running, sync the commands to your guild. You might have to wait a while because the syncing process usually takes some time. Once completed, you should now have the `CommandTree` synced to that guild. + + ``` + # Replace 12345 with your guild id + r>sync 12345 + ``` +7. Now go ahead and play around with the default commands. Add your own, delete some, do whatever you want now. diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py new file mode 100644 index 0000000..b87e97f --- /dev/null +++ b/bot/cogs/__init__.py @@ -0,0 +1,16 @@ +from pkgutil import iter_modules +from typing import Literal, NamedTuple + + +class VersionInfo(NamedTuple): + major: int + minor: int + micro: int + releaselevel: Literal["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") diff --git a/bot/cogs/dev_tools.py b/bot/cogs/dev_tools.py new file mode 100644 index 0000000..faaa365 --- /dev/null +++ b/bot/cogs/dev_tools.py @@ -0,0 +1,81 @@ +from typing import Literal, Optional + +import discord +from cogs import EXTENSIONS +from discord.ext import commands +from discord.ext.commands import Context, Greedy + +from rodhaj import Rodhaj + + +class DevTools(commands.Cog, command_attrs=dict(hidden=True)): + """Tools for developing RodHaj""" + + def __init__(self, bot: Rodhaj): + self.bot = bot + + # 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.is_owner() + @commands.command(name="sync", hidden=True) + 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.is_owner() + @commands.command(name="reload-all", hidden=True) + async def reload_all(self, ctx: commands.Context) -> 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") + + +async def setup(bot: Rodhaj): + await bot.add_cog(DevTools(bot)) diff --git a/bot/cogs/meta.py b/bot/cogs/meta.py new file mode 100644 index 0000000..3ca1a0c --- /dev/null +++ b/bot/cogs/meta.py @@ -0,0 +1,101 @@ +import datetime +import itertools +import platform + +import discord +import psutil +import pygit2 +from discord import app_commands +from discord.ext import commands +from discord.utils import format_dt +from libs.utils import Embed, human_timedelta + +from rodhaj import Rodhaj + + +# A cog houses a category of commands +# Unlike djs, think of commands being stored as a category, +# which the cog is that category +class Meta(commands.Cog): + def __init__(self, bot: Rodhaj) -> None: + self.bot = bot + self.process = psutil.Process() + + def get_bot_uptime(self, *, brief: bool = False) -> str: + return human_timedelta( + self.bot.uptime, accuracy=None, brief=brief, suffix=False + ) + + def format_commit(self, commit: pygit2.Commit) -> str: + short, _, _ = commit.message.partition("\n") + short_sha2 = commit.hex[0:6] + commit_tz = datetime.timezone( + datetime.timedelta(minutes=commit.commit_time_offset) + ) + commit_time = datetime.datetime.fromtimestamp(commit.commit_time).astimezone( + commit_tz + ) + + # [`hash`](url) message (offset) + offset = format_dt(commit_time.astimezone(datetime.timezone.utc), "R") + return f"[`{short_sha2}`](https://github.com/transprogrammer/rodhaj/commit/{commit.hex}) {short} ({offset})" + + def get_last_commits(self, count: int = 5): + repo = pygit2.Repository(".git") + commits = list( + itertools.islice( + repo.walk(repo.head.target, pygit2.GIT_SORT_TOPOLOGICAL), count + ) + ) + return "\n".join(self.format_commit(c) for c in commits) + + @app_commands.command(name="about") + async def about(self, interaction: discord.Interaction) -> None: + """Shows some stats for Rodhaj""" + total_members = 0 + total_unique = len(self.bot.users) + + for guild in self.bot.guilds: + total_members += guild.member_count or 0 + + # For Kumiko, it's done differently + # R. Danny's way of doing it is probably close enough anyways + memory_usage = self.process.memory_full_info().uss / 1024**2 + cpu_usage = self.process.cpu_percent() / psutil.cpu_count() + + revisions = self.get_last_commits() + 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.set_footer( + text=f"Made with discord.py v{discord.__version__}", + icon_url="https://cdn.discordapp.com/emojis/596577034537402378.png?size=128", + ) + embed.add_field(name="Servers Count", value=len(self.bot.guilds)) + embed.add_field( + name="User Count", 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="Python Version", value=platform.python_version()) + embed.add_field(name="Version", value=str(self.bot.version)) + embed.add_field(name="Uptime", value=self.get_bot_uptime(brief=True)) + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="uptime") + async def uptime(self, interaction: discord.Interaction) -> None: + """Displays the bot's uptime""" + uptime_message = f"Uptime: {self.get_bot_uptime()}" + await interaction.response.send_message(uptime_message) + + @app_commands.command(name="version") + async def version(self, interaction: discord.Interaction) -> None: + """Displays the current build version""" + version_message = f"Version: {self.bot.version}" + await interaction.response.send_message(version_message) + + +async def setup(bot: Rodhaj) -> None: + await bot.add_cog(Meta(bot)) diff --git a/bot/launcher.py b/bot/launcher.py new file mode 100644 index 0000000..7a94e1f --- /dev/null +++ b/bot/launcher.py @@ -0,0 +1,55 @@ +import asyncio +import os +from pathlib import Path + +import asyncpg +import discord +from aiohttp import ClientSession +from dotenv import load_dotenv +from libs.utils import RodhajLogger + +from rodhaj import Rodhaj + +# Only used for Windows development +if os.name == "nt": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) +else: + try: + import uvloop + + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + except ImportError: + pass + +load_dotenv() + +ENV_PATH = Path(__file__).parent / ".env" + +TOKEN = os.environ["TOKEN"] +DEV_MODE = os.getenv("DEV_MODE") in ("True", "TRUE") +POSTGRES_URI = os.environ["POSTGRES_URI"] + +intents = discord.Intents.default() +intents.message_content = True + + +async def main() -> None: + async with ClientSession() as session, asyncpg.create_pool( + dsn=POSTGRES_URI, min_size=25, max_size=25, command_timeout=30 + ) as pool: + async with Rodhaj( + intents=intents, session=session, pool=pool, dev_mode=DEV_MODE + ) as bot: + await bot.start(TOKEN) + + +def launch() -> None: + with RodhajLogger(): + asyncio.run(main()) + + +if __name__ == "__main__": + try: + launch() + except KeyboardInterrupt: + pass diff --git a/bot/libs/utils/__init__.py b/bot/libs/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/libs/utils/embeds.py b/bot/libs/utils/embeds.py new file mode 100644 index 0000000..315f43f --- /dev/null +++ b/bot/libs/utils/embeds.py @@ -0,0 +1,18 @@ +import discord + + +class Embed(discord.Embed): + def __init__(self, **kwargs): + kwargs.setdefault("color", discord.Color.from_rgb(255, 163, 253)) + super().__init__(**kwargs) + + +class ErrorEmbed(discord.Embed): + def __init__(self, **kwargs): + kwargs.setdefault("color", discord.Color.from_rgb(214, 6, 6)) + kwargs.setdefault("title", "Oh no, an error has occurred!") + kwargs.setdefault( + "description", + "Uh oh! It seems like the command ran into an issue! For support, ask the dev team", + ) + super().__init__(**kwargs) diff --git a/bot/libs/utils/logger.py b/bot/libs/utils/logger.py new file mode 100644 index 0000000..aa20cd1 --- /dev/null +++ b/bot/libs/utils/logger.py @@ -0,0 +1,45 @@ +import logging +from logging.handlers import RotatingFileHandler +from types import TracebackType +from typing import Optional, Type, TypeVar + +import discord + +BE = TypeVar("BE", bound=BaseException) + + +class RodhajLogger: + def __init__(self) -> None: + self.self = self + self.log = logging.getLogger("zoee") + + def __enter__(self) -> None: + max_bytes = 32 * 1024 * 1024 # 32 MiB + self.log.setLevel(logging.INFO) + logging.getLogger("discord").setLevel(logging.INFO) + handler = RotatingFileHandler( + filename="rodhaj.log", + encoding="utf-8", + mode="w", + maxBytes=max_bytes, + backupCount=5, + ) + fmt = logging.Formatter( + fmt="%(asctime)s %(levelname)s %(message)s", + datefmt="[%Y-%m-%d %H:%M:%S]", + ) + handler.setFormatter(fmt) + self.log.addHandler(handler) + discord.utils.setup_logging(formatter=fmt) + + def __exit__( + self, + exc_type: Optional[Type[BE]], + exc: Optional[BE], + traceback: Optional[TracebackType], + ) -> None: + self.log.info("Shutting down...") + handlers = self.log.handlers[:] + for hdlr in handlers: + hdlr.close() + self.log.removeHandler(hdlr) diff --git a/bot/libs/utils/pages/__init__.py b/bot/libs/utils/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/libs/utils/pages/modals.py b/bot/libs/utils/pages/modals.py new file mode 100644 index 0000000..d75041c --- /dev/null +++ b/bot/libs/utils/pages/modals.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Optional + +import discord + + +class NumberedPageModal(discord.ui.Modal, title="Go to page"): + page = discord.ui.TextInput( + label="Page", placeholder="Enter a number", min_length=1 + ) + + def __init__(self, max_pages: Optional[int]) -> None: + super().__init__() + if max_pages is not None: + as_string = str(max_pages) + self.page.placeholder = f"Enter a number between 1 and {as_string}" + self.page.max_length = len(as_string) + + async def on_submit(self, interaction: discord.Interaction) -> None: + self.interaction = interaction + self.stop() diff --git a/bot/libs/utils/pages/paginator.py b/bot/libs/utils/pages/paginator.py new file mode 100644 index 0000000..de0faab --- /dev/null +++ b/bot/libs/utils/pages/paginator.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +import discord +from discord.ext import menus + +from .modals import NumberedPageModal + + +class HajPages(discord.ui.View): + def __init__( + self, + source: menus.PageSource, + *, + interaction: discord.Interaction, + check_embeds: bool = True, + compact: bool = False, + ): + super().__init__() + self.source: menus.PageSource = source + self.check_embeds: bool = check_embeds + self.interaction: discord.Interaction = interaction + self.followup: Optional[discord.InteractionMessage] = None + self.current_page: int = 0 + self.compact: bool = compact + self.clear_items() + self.fill_items() + + def fill_items(self) -> None: + if not self.compact: + self.numbered_page.row = 1 + self.stop_pages.row = 1 + + if self.source.is_paginating(): + max_pages = self.source.get_max_pages() + use_last_and_first = max_pages is not None and max_pages >= 2 + if use_last_and_first: + self.add_item(self.go_to_first_page) + self.add_item(self.go_to_previous_page) + if not self.compact: + self.add_item(self.go_to_current_page) + self.add_item(self.go_to_next_page) + if use_last_and_first: + self.add_item(self.go_to_last_page) + if not self.compact: + self.add_item(self.numbered_page) + self.add_item(self.stop_pages) + + async def _get_kwargs_from_page(self, page: int) -> Dict[str, Any]: + value = await discord.utils.maybe_coroutine(self.source.format_page, self, page) + if isinstance(value, dict): + return value + elif isinstance(value, str): + return {"content": value, "embed": None} + elif isinstance(value, discord.Embed): + return {"embed": value, "content": None} + else: + return {} + + async def show_page( + self, interaction: discord.Interaction, page_number: int + ) -> None: + page = await self.source.get_page(page_number) + self.current_page = page_number + kwargs = await self._get_kwargs_from_page(page) + self._update_labels(page_number) + if kwargs: + if interaction.response.is_done(): + if self.followup: + await self.followup.edit(**kwargs, view=self) + else: + await interaction.response.edit_message(**kwargs, view=self) + + def _update_labels(self, page_number: int) -> None: + self.go_to_first_page.disabled = page_number == 0 + if self.compact: + max_pages = self.source.get_max_pages() + self.go_to_last_page.disabled = ( + max_pages is None or (page_number + 1) >= max_pages + ) + self.go_to_next_page.disabled = ( + max_pages is not None and (page_number + 1) >= max_pages + ) + self.go_to_previous_page.disabled = page_number == 0 + return + + self.go_to_current_page.label = str(page_number + 1) + self.go_to_previous_page.label = str(page_number) + self.go_to_next_page.label = str(page_number + 2) + self.go_to_next_page.disabled = False + self.go_to_previous_page.disabled = False + self.go_to_first_page.disabled = False + + max_pages = self.source.get_max_pages() + if max_pages is not None: + self.go_to_last_page.disabled = (page_number + 1) >= max_pages + if (page_number + 1) >= max_pages: + self.go_to_next_page.disabled = True + self.go_to_next_page.label = "…" + if page_number == 0: + self.go_to_previous_page.disabled = True + self.go_to_previous_page.label = "…" + + async def show_checked_page( + self, interaction: discord.Interaction, page_number: int + ) -> None: + max_pages = self.source.get_max_pages() + try: + if max_pages is None: + # If it doesn't give maximum pages, it cannot be checked + await self.show_page(interaction, page_number) + elif max_pages > page_number >= 0: + await self.show_page(interaction, page_number) + except IndexError: + # An error happened that can be handled, so ignore it. + pass + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user and interaction.user.id in ( + self.interaction.client.application.owner.id, # type: ignore + self.interaction.user.id, + ): + return True + await interaction.response.send_message( + "This pagination menu cannot be controlled by you, sorry!", ephemeral=True + ) + return False + + async def on_timeout(self) -> None: + if self.followup: + await self.followup.edit(view=None) + + async def on_error( + self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item + ) -> None: + await interaction.followup.send( + "An unknown error occurred, sorry", ephemeral=True + ) + + async def start( + self, *, content: Optional[str] = None, ephemeral: bool = False + ) -> None: + if self.check_embeds and not self.interaction.permissions.embed_links: + await self.interaction.response.send_message( + "Bot doesn't have embed link perms in this channel", ephemeral=True + ) + return + + await self.source._prepare_once() + page = await self.source.get_page(0) + kwargs = await self._get_kwargs_from_page(page) + if content: + kwargs.setdefault("content", content) + + self._update_labels(0) + + # Fixes the issue of somehow the interaction failing for /pronouns profile + if self.interaction.response.is_done(): + await self.interaction.followup.send( + **kwargs, view=self, ephemeral=ephemeral + ) + self.followup = await self.interaction.original_response() + return + + await self.interaction.response.send_message( + **kwargs, view=self, ephemeral=ephemeral + ) + self.followup = await self.interaction.original_response() + + @discord.ui.button(label="≪", style=discord.ButtonStyle.grey) + async def go_to_first_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """go to the first page""" + await self.show_page(interaction, 0) + + @discord.ui.button(label="Back", style=discord.ButtonStyle.blurple) + async def go_to_previous_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """go to the previous page""" + await self.show_checked_page(interaction, self.current_page - 1) + + # Shows the current button + @discord.ui.button(label="Current", style=discord.ButtonStyle.grey, disabled=True) + async def go_to_current_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """show current page""" + + @discord.ui.button(label="Next", style=discord.ButtonStyle.blurple) + async def go_to_next_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """go to the next page""" + await self.show_checked_page(interaction, self.current_page + 1) + + @discord.ui.button(label="≫", style=discord.ButtonStyle.grey) + async def go_to_last_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """go to the last page""" + # The call here is safe because it's guarded by skip_if + await self.show_page(interaction, self.source.get_max_pages() - 1) # type: ignore + + @discord.ui.button(label="Skip to page...", style=discord.ButtonStyle.grey) + async def numbered_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """lets you type a page number to go to""" + if self.followup is None: + return + + modal = NumberedPageModal(self.source.get_max_pages()) + await interaction.response.send_modal(modal) + timed_out = await modal.wait() + + if timed_out: + await interaction.followup.send("Took too long", ephemeral=True) + return + elif self.is_finished(): + await modal.interaction.response.send_message( + "Took too long", ephemeral=True + ) + return + + value = str(modal.page.value) + if not value.isdigit(): + await modal.interaction.response.send_message( + f"Expected a number not {value!r}", ephemeral=True + ) + return + + value = int(value) + await self.show_checked_page(modal.interaction, value - 1) + if not modal.interaction.response.is_done(): + error = modal.page.placeholder.replace("Enter", "Expected") # type: ignore # Can't be None + await modal.interaction.response.send_message(error, ephemeral=True) + + @discord.ui.button(label="Quit", style=discord.ButtonStyle.red) + async def stop_pages( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + """stops the pagination session.""" + await interaction.response.defer() + await interaction.delete_original_response() + self.stop() diff --git a/bot/libs/utils/pages/simple_pages.py b/bot/libs/utils/pages/simple_pages.py new file mode 100644 index 0000000..e25f151 --- /dev/null +++ b/bot/libs/utils/pages/simple_pages.py @@ -0,0 +1,19 @@ +import discord + +from .paginator import HajPages +from .sources import SimplePageSource + + +class SimplePages(HajPages): + """A simple pagination session reminiscent of the old Pages interface. + + Basically an embed with some normal formatting. + """ + + def __init__( + self, entries, *, interaction: discord.Interaction, per_page: int = 12 + ): + super().__init__( + SimplePageSource(entries, per_page=per_page), interaction=interaction + ) + self.embed = discord.Embed(colour=discord.Colour.from_rgb(200, 168, 255)) diff --git a/bot/libs/utils/pages/sources.py b/bot/libs/utils/pages/sources.py new file mode 100644 index 0000000..6cbd195 --- /dev/null +++ b/bot/libs/utils/pages/sources.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Any + +import discord +from discord.ext import menus + +from ..embeds import Embed +from .paginator import HajPages + + +# I (Noelle) will more than likely work on an cleaner way for this +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] + ) -> discord.Embed: + """Formatter for the embed list source + + Ideally the structure of the entries should be: + { + "title": "Title of the embed", + "description": "Description of the embed", + "image": "Image of the embed", + "thumbnail": "Thumbnail of the embed", + "fields": [ + { + "name": "Name of the embed", + "value": "Value of the embed", + "inline": True + }, + { + "name": "Name of the embed", + "value": "Value of the embed", + "inline": True + } + ] + } + + Args: + menu (menus.Menu): What menu should be formatted + entries (Dict[str, Any]) List of all of the entries to format + + Returns: + discord.Embed: An embed with the formatted entries + """ + maximum = self.get_max_pages() + embed = Embed() + embed.title = entries["title"] if "title" in entries else "" + embed.description = entries["description"] if "description" in entries else "" + embed.set_image(url=entries["image"]) if "image" in entries else ... + embed.set_thumbnail(url=entries["thumbnail"]) if "thumbnail" in entries else ... + embed.set_footer(text=f"Page {menu.current_page + 1}/{maximum}") + if "fields" in entries: + for item in entries["fields"]: + embed.add_field(name=item["name"] or ..., value=item["value"] or ...) + return embed + + +class SimplePageSource(menus.ListPageSource): + async def format_page(self, menu, entries): + pages = [] + for index, entry in enumerate(entries, start=menu.current_page * self.per_page): + pages.append(f"{index + 1}. {entry}") + + maximum = self.get_max_pages() + if maximum > 1: + footer = ( + f"Page {menu.current_page + 1}/{maximum} ({len(self.entries)} entries)" + ) + menu.embed.set_footer(text=footer) + + menu.embed.description = "\n".join(pages) + return menu.embed diff --git a/bot/libs/utils/time.py b/bot/libs/utils/time.py new file mode 100644 index 0000000..b3d99c5 --- /dev/null +++ b/bot/libs/utils/time.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import datetime +from typing import Optional, Sequence + +from dateutil.relativedelta import relativedelta + + +class Plural: + def __init__(self, value: int): + self.value: int = value + + def __format__(self, format_spec: str) -> str: + v = self.value + singular, _, plural = format_spec.partition("|") + plural = plural or f"{singular}s" + if abs(v) != 1: + return f"{v} {plural}" + return f"{v} {singular}" + + +def human_join(seq: Sequence[str], delim: str = ", ", final: str = "or") -> str: + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +# The old system does work, but as noted, can be inaccurate +# This system is from RDanny and should provide more accurate results +def human_timedelta( + dt: datetime.datetime, + *, + source: Optional[datetime.datetime] = None, + accuracy: Optional[int] = 3, + brief: bool = False, + suffix: bool = True, +) -> str: + now = source or datetime.datetime.now(datetime.timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + + if now.tzinfo is None: + now = now.replace(tzinfo=datetime.timezone.utc) + + # Microsecond free zone + now = now.replace(microsecond=0) + dt = dt.replace(microsecond=0) + + # This implementation uses relativedelta instead of the much more obvious + # divmod approach with seconds because the seconds approach is not entirely + # accurate once you go over 1 week in terms of accuracy since you have to + # hardcode a month as 30 or 31 days. + # A query like "11 months" can be interpreted as "!1 months and 6 days" + if dt > now: + delta = relativedelta(dt, now) + output_suffix = "" + else: + delta = relativedelta(now, dt) + output_suffix = " ago" if suffix else "" + + attrs = [ + ("year", "y"), + ("month", "mo"), + ("day", "d"), + ("hour", "h"), + ("minute", "m"), + ("second", "s"), + ] + + output = [] + for attr, brief_attr in attrs: + elem = getattr(delta, attr + "s") + if not elem: + continue + + if attr == "day": + weeks = delta.weeks + if weeks: + elem -= weeks * 7 + if not brief: + output.append(format(Plural(weeks), "week")) + else: + output.append(f"{weeks}w") + + if elem <= 0: + continue + + if brief: + output.append(f"{elem}{brief_attr}") + else: + output.append(format(Plural(elem), attr)) + + if accuracy is not None: + output = output[:accuracy] + + if len(output) == 0: + return "now" + else: + if not brief: + return human_join(output, final="and") + output_suffix + else: + return " ".join(output) + output_suffix diff --git a/bot/libs/utils/tree.py b/bot/libs/utils/tree.py new file mode 100644 index 0000000..304abd4 --- /dev/null +++ b/bot/libs/utils/tree.py @@ -0,0 +1,28 @@ +import traceback + +import discord +from discord import app_commands +from discord.utils import utcnow +from libs.utils import ErrorEmbed + + +class RodhajCommandTree(app_commands.CommandTree): + def build_error_embed(self, error: app_commands.AppCommandError) -> ErrorEmbed: + error_traceback = "\n".join(traceback.format_exception_only(type(error), error)) + embed = ErrorEmbed() + embed.description = f""" + Uh oh! It seems like the command ran into an issue! + + **Error**: + ``` + {error_traceback} + ``` + """ + embed.set_footer(text="Happened At") + embed.timestamp = utcnow() + return embed + + async def on_error( + self, interaction: discord.Interaction, error: app_commands.AppCommandError + ) -> None: + await interaction.response.send_message(embed=self.build_error_embed(error)) diff --git a/bot/libs/utils/views.py b/bot/libs/utils/views.py new file mode 100644 index 0000000..aba35d0 --- /dev/null +++ b/bot/libs/utils/views.py @@ -0,0 +1,19 @@ +import discord + +NO_CONTROL_MSG = "This menu cannot be controlled by you, sorry!" + + +# TODO: Include the view that Soheab created +class RoboView(discord.ui.View): + def __init__(self, interaction: discord.Interaction): + super().__init__() + self.interaction = interaction + + async def interaction_check(self, interaction: discord.Interaction, /) -> bool: + if interaction.user and interaction.user.id in ( + self.interaction.client.application.owner.id, # type: ignore + self.interaction.user.id, + ): + return True + await interaction.response.send_message(NO_CONTROL_MSG, ephemeral=True) + return False diff --git a/bot/rodhaj.py b/bot/rodhaj.py new file mode 100644 index 0000000..2ecb32a --- /dev/null +++ b/bot/rodhaj.py @@ -0,0 +1,77 @@ +import logging +import signal +from pathlib import Path + +import asyncpg +import discord +from aiohttp import ClientSession +from cogs import EXTENSIONS, VERSION +from discord.ext import commands +from libs.utils import RodhajCommandTree + +_fsw = True +try: + from watchfiles import awatch +except ImportError: + _fsw = False + + +class Rodhaj(commands.Bot): + """Main bot for Rodhaj""" + + def __init__( + self, + intents: discord.Intents, + session: ClientSession, + pool: asyncpg.Pool, + dev_mode: bool = False, + *args, + **kwargs, + ): + super().__init__( + activity=discord.Activity( + type=discord.ActivityType.watching, name="a game" + ), + command_prefix="r>", + help_command=None, + intents=intents, + tree_cls=RodhajCommandTree, + *args, + **kwargs, + ) + self.logger = logging.getLogger("rodhaj") + self.session = session + self.pool = pool + self.version = str(VERSION) + self._dev_mode = dev_mode + + async def fs_watcher(self) -> None: + cogs_path = Path(__file__).parent.joinpath("cogs") + async for changes in awatch(cogs_path): + changes_list = list(changes)[0] + if changes_list[0].modified == 2: + reload_file = Path(changes_list[1]) + self.logger.info(f"Reloading extension: {reload_file.name[:-3]}") + await self.reload_extension(f"cogs.{reload_file.name[:-3]}") + + 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) + + if self._dev_mode is True and _fsw is True: + self.logger.info("Dev mode is enabled. Loading Jishaku and FSWatcher") + await self.load_extension("jishaku") + self.loop.create_task(self.fs_watcher()) + + async def on_ready(self): + if not hasattr(self, "uptime"): + self.uptime = discord.utils.utcnow() + + curr_user = None if self.user is None else self.user.name + self.logger.info(f"{curr_user} is fully ready!") diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 45055ac..d1da027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MPL-2.0" readme = "README.md" [tool.poetry.dependencies] -python = "^3.11" +python = ">=3.9,<3.12" discord-py = {extras = ["speed"], version = "^2.3.2"} python-dotenv = "^1.0.0" uvloop = "^0.17.0"