diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py index 97b1025..edc5401 100644 --- a/bot/cogs/__init__.py +++ b/bot/cogs/__init__.py @@ -14,3 +14,5 @@ def __str__(self) -> str: EXTENSIONS = [module.name for module in iter_modules(__path__, f"{__package__}.")] VERSION: VersionInfo = VersionInfo(major=0, minor=3, micro=1, releaselevel="final") + +del Literal, NamedTuple diff --git a/bot/cogs/admin.py b/bot/cogs/admin.py index 174c2c9..17834dc 100644 --- a/bot/cogs/admin.py +++ b/bot/cogs/admin.py @@ -1,11 +1,5 @@ from __future__ import annotations -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 TYPE_CHECKING, Literal, Optional import discord @@ -13,12 +7,10 @@ from discord.ext.commands import Greedy if TYPE_CHECKING: - from libs.utils import RoboContext + from utils.context import RoboContext from bot.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""" @@ -33,78 +25,6 @@ def display_emoji(self) -> discord.PartialEmoji: 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 @@ -147,43 +67,6 @@ async def sync( 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 89a1bb8..c5383fe 100644 --- a/bot/cogs/config.py +++ b/bot/cogs/config.py @@ -10,7 +10,6 @@ Any, AsyncIterator, Literal, - NamedTuple, Optional, Union, overload, @@ -23,22 +22,23 @@ from async_lru import alru_cache from discord import app_commands from discord.ext import commands, menus -from libs.tickets.utils import get_cached_thread -from libs.utils.checks import ( +from utils.checks import ( bot_check_permissions, check_permissions, is_manager, ) -from libs.utils.config import OptionsHelp -from libs.utils.embeds import CooldownEmbed, Embed -from libs.utils.pages import SimplePages -from libs.utils.pages.paginator import RoboPages -from libs.utils.prefix import get_prefix -from libs.utils.time import FriendlyTimeResult, UserFriendlyTime +from utils.config import OptionsHelp +from utils.embeds import CooldownEmbed, Embed +from utils.pages import SimplePages +from utils.pages.paginator import RoboPages +from utils.prefix import get_prefix +from utils.time import FriendlyTimeResult, UserFriendlyTime + +from cogs.tickets import get_cached_thread if TYPE_CHECKING: - from libs.utils import GuildContext from rodhaj import Rodhaj + from utils import GuildContext from cogs.tickets import Tickets @@ -48,12 +48,30 @@ ) OPTIONS_FILE = Path(__file__).parents[1] / "locale" / "options.json" +### Enums + + +class ConfigType(Enum): + TOGGLE = 0 + SET = 1 + + +### Structs + -class BlocklistTicket(NamedTuple): +class BlocklistTicket(msgspec.Struct, frozen=True): cog: Tickets thread: discord.Thread +class ConfigHelpEntry(msgspec.Struct, frozen=True): + key: str + default: str + description: str + examples: list[str] + notes: list[str] + + class BlocklistEntity(msgspec.Struct, frozen=True): bot: Rodhaj guild_id: int @@ -65,63 +83,6 @@ def format(self) -> str: return f"{name} (ID: {self.entity_id})" -class BlocklistPages(SimplePages): - def __init__(self, entries: list[BlocklistEntity], *, ctx: GuildContext): - converted = [entry.format() for entry in entries] - super().__init__(converted, ctx=ctx) - - -class Blocklist: - def __init__(self, bot: Rodhaj): - self.bot = bot - self._blocklist: dict[int, BlocklistEntity] = {} - - async def _load(self, connection: Union[asyncpg.Connection, asyncpg.Pool]): - query = """ - SELECT guild_id, entity_id - FROM blocklist; - """ - rows = await connection.fetch(query) - return { - row["entity_id"]: BlocklistEntity(bot=self.bot, **dict(row)) for row in rows - } - - async def load(self, connection: Optional[asyncpg.Connection] = None): - try: - self._blocklist = await self._load(connection or self.bot.pool) - except Exception: - self._blocklist = {} - - @overload - def get(self, key: int) -> Optional[BlocklistEntity]: ... - - @overload - def get(self, key: int) -> BlocklistEntity: ... - - def get(self, key: int, default: Any = None) -> Optional[BlocklistEntity]: - return self._blocklist.get(key, default) - - def __contains__(self, item: int) -> bool: - return item in self._blocklist - - def __getitem__(self, item: int) -> BlocklistEntity: - return self._blocklist[item] - - def __len__(self) -> int: - return len(self._blocklist) - - def all(self) -> dict[int, BlocklistEntity]: - return self._blocklist - - def replace(self, blocklist: dict[int, BlocklistEntity]) -> None: - self._blocklist = blocklist - - -class ConfigType(Enum): - TOGGLE = 0 - SET = 1 - - # Msgspec Structs are usually extremely fast compared to slotted classes class GuildConfig(msgspec.Struct): bot: Rodhaj @@ -195,6 +156,55 @@ def ticket_channel(self) -> Optional[discord.ForumChannel]: return guild and guild.get_channel(self.ticket_channel_id) # type: ignore +### Core classes + + +class Blocklist: + def __init__(self, bot: Rodhaj): + self.bot = bot + self._blocklist: dict[int, BlocklistEntity] = {} + + async def _load(self, connection: Union[asyncpg.Connection, asyncpg.Pool]): + query = """ + SELECT guild_id, entity_id + FROM blocklist; + """ + rows = await connection.fetch(query) + return { + row["entity_id"]: BlocklistEntity(bot=self.bot, **dict(row)) for row in rows + } + + async def load(self, connection: Optional[asyncpg.Connection] = None): + try: + self._blocklist = await self._load(connection or self.bot.pool) + except Exception: + self._blocklist = {} + + @overload + def get(self, key: int) -> Optional[BlocklistEntity]: ... + + @overload + def get(self, key: int) -> BlocklistEntity: ... + + def get(self, key: int, default: Any = None) -> Optional[BlocklistEntity]: + return self._blocklist.get(key, default) + + def __contains__(self, item: int) -> bool: + return item in self._blocklist + + def __getitem__(self, item: int) -> BlocklistEntity: + return self._blocklist[item] + + def __len__(self) -> int: + return len(self._blocklist) + + def all(self) -> dict[int, BlocklistEntity]: + return self._blocklist + + def replace(self, blocklist: dict[int, BlocklistEntity]) -> None: + self._blocklist = blocklist + + class GuildWebhookDispatcher: def __init__(self, bot: Rodhaj, guild_id: int): self.bot = bot @@ -233,12 +243,7 @@ async def get_config(self) -> Optional[GuildWebhook]: return GuildWebhook(bot=self.bot, **dict(rows)) -class ConfigHelpEntry(msgspec.Struct, frozen=True): - key: str - default: str - description: str - examples: list[str] - notes: list[str] +### Embeds class ConfigEntryEmbed(Embed): @@ -255,6 +260,15 @@ def __init__(self, entry: ConfigHelpEntry, **kwargs): ) +### UI elements (Sources and Pages) + + +class BlocklistPages(SimplePages): + def __init__(self, entries: list[BlocklistEntity], *, ctx: GuildContext): + converted = [entry.format() for entry in entries] + super().__init__(converted, ctx=ctx) + + class ConfigHelpPageSource(menus.ListPageSource): async def format_page(self, menu: ConfigHelpPages, entry: ConfigHelpEntry): embed = ConfigEntryEmbed(entry=entry) @@ -308,6 +322,9 @@ def __init__( self.embed = discord.Embed(colour=discord.Colour.from_rgb(200, 168, 255)) +### Flags and Converters + + class ConfigOptionFlags(commands.FlagConverter): active: Optional[bool] = commands.flag( name="active", @@ -376,6 +393,9 @@ async def convert(self, ctx: GuildContext, argument: str): return argument +### Command cog + + class Config(commands.Cog): """Config and setup commands for Rodhaj""" @@ -510,6 +530,7 @@ def clean_prefixes(self, prefixes: Union[str, list[str]]) -> str: return ", ".join(f"`{prefix}`" for prefix in prefixes[2:]) ### Misc Utilities + async def _handle_error( self, error: commands.CommandError, *, ctx: GuildContext ) -> None: @@ -519,6 +540,8 @@ async def _handle_error( elif isinstance(error, commands.BadArgument): await ctx.send(str(error)) + ### Commands + @is_manager() @bot_check_permissions(manage_channels=True, manage_webhooks=True) @commands.guild_only() diff --git a/bot/cogs/tickets.py b/bot/cogs/tickets.py index 909eb2f..b65231d 100644 --- a/bot/cogs/tickets.py +++ b/bot/cogs/tickets.py @@ -1,30 +1,35 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Annotated, NamedTuple, Optional, Union +import asyncio +import datetime +import uuid +from typing import TYPE_CHECKING, Annotated, Optional, TypedDict, Union import asyncpg import discord +import msgspec from async_lru import alru_cache from discord.ext import commands from discord.utils import format_dt, utcnow -from libs.tickets.structs import ReservedTags, StatusChecklist, TicketThread -from libs.tickets.utils import ( - get_cached_thread, - get_partial_ticket, - safe_content, -) -from libs.utils.checks import bot_check_permissions -from libs.utils.embeds import CooldownEmbed, Embed, LoggingEmbed +from utils import ErrorEmbed +from utils.checks import bot_check_permissions +from utils.embeds import CooldownEmbed, Embed +from utils.modals import RoboModal +from utils.views import RoboView from .config import GuildWebhookDispatcher if TYPE_CHECKING: - from libs.utils import GuildContext, RoboContext from rodhaj import Rodhaj + from utils import GuildContext, RoboContext + + from .config import Config TICKET_EMOJI = "\U0001f3ab" # U+1F3AB Ticket +### Command checks + def is_ticket_or_dm(): def pred(ctx: RoboContext) -> bool: @@ -51,12 +56,189 @@ def pred(ctx: RoboContext) -> bool: return commands.check(pred) -class TicketOutput(NamedTuple): +### Public ticket utilities + + +async def register_user( + user_id: int, connection: Union[asyncpg.Pool, asyncpg.Connection] +): + """Registers the user into the database + + Args: + user_id (int): ID of the user + connection (Union[asyncpg.Pool, asyncpg.Connection]): A connection (can be a pool) to the PostgreSQL server (through asyncpg) + + Returns: + bool: `True` if the user has been successfully registered into the database, + `False` if the user is already in the database + """ + query = """ + INSERT INTO user_config (id) + VALUES ($1) ON CONFLICT (id) DO NOTHING; + """ + status = await connection.execute(query, user_id) + if status[-1] == "0": + return False + return True + + +@alru_cache(maxsize=256) +async def get_partial_ticket( + bot: Rodhaj, user_id: int, pool: Optional[asyncpg.Pool] = None +) -> PartialTicket: + """Provides an `PartialTicket` object in order to perform various actions + + The `PartialTicket` represents a partial record of an ticket found in the + PostgreSQL database. + + If the `PartialTicket` instance has the attribute `id` set to `None`, then this means + that there is no ticket found. If an ticket is found, then the partial information + of it is filled. + + Args: + bot (Rodhaj): An instance of `Rodhaj` + user_id (int): ID of the user + pool (asyncpg.Pool): Pool of connections from asyncpg. Defaults to `None` + + Returns: + PartialTicket: An representation of a "partial" ticket + """ + query = """ + SELECT id, thread_id, owner_id, location_id, locked + FROM tickets + WHERE owner_id = $1; + """ + pool = pool or bot.pool + rows = await pool.fetchrow(query, user_id) + if rows is None: + # In order to prevent caching invalid tickets, we need to invalidate the cache. + # By invalidating the cache, we basically "ignore" the invalid + # ticket. This essentially still leaves us with the performance boosts + # of the LRU cache, while also properly invalidating invalid tickets + get_partial_ticket.cache_invalidate(bot, user_id, pool) + return PartialTicket() + return PartialTicket(rows) + + +@alru_cache(maxsize=64) +async def get_cached_thread( + bot: Rodhaj, user_id: int, connection: Optional[asyncpg.Pool] = None +) -> Optional[ThreadWithGuild]: + """Obtains an cached thread from the tickets channel + + This has a small LRU cache (size of 64) so the cache is forced to refresh its + internal data. + + Args: + bot (Rodhaj): Instance of `RodHaj` + user_id (int): ID of the user + connection (Optional[asyncpg.Pool]): Pool of connections from asyncpg. Defaults to `None` + + Returns: + Optional[ThreadWithGuild]: The thread with the guild the thread belongs to. + `None` if not found. + """ + query = """ + SELECT guild_config.ticket_channel_id, tickets.thread_id, tickets.location_id + FROM tickets + INNER JOIN guild_config ON guild_config.id = tickets.location_id + WHERE tickets.owner_id = $1; + """ + connection = connection or bot.pool + record = await connection.fetchrow(query, user_id) + if record is None: + return None + forum_channel = bot.get_channel(record["ticket_channel_id"]) or ( + await bot.fetch_channel(record["ticket_channel_id"]) + ) + if isinstance(forum_channel, discord.ForumChannel): + thread = forum_channel.get_thread(record["thread_id"]) + if thread is None: + get_cached_thread.cache_invalidate(bot, user_id, connection) + return None + return ThreadWithGuild(thread, thread.guild) + + +def safe_content(content: str, amount: int = 4000) -> str: + """Safely sends the content by reducing the length + to avoid errors + + Args: + content (str): Content to be sent + + Returns: + str: A safe version of the content + """ + if len(content) > amount: + return content[: amount - 3] + "..." + return content + + +### Typed Structs and Slotted Classes + + +class ReservedTags(TypedDict): + question: bool + serious: bool + private: bool + + +class TicketOutput(msgspec.Struct, frozen=True): status: bool ticket: discord.channel.ThreadWithMessage msg: str +class ThreadWithGuild(msgspec.Struct, frozen=True): + thread: discord.Thread + source_guild: discord.Guild + + +class StatusChecklist(msgspec.Struct, frozen=True): + title: asyncio.Event = asyncio.Event() + tags: asyncio.Event = asyncio.Event() + + +class TicketThread(msgspec.Struct, frozen=True): + title: str + user: Union[discord.User, discord.Member] + location_id: int + mention: str + content: str + tags: list[str] + files: list[discord.File] + created_at: datetime.datetime + + +class PartialTicket: + __slots__ = ("id", "thread_id", "owner_id", "location_id", "locked") + + def __init__(self, record: Optional[asyncpg.Record] = None): + self.id = None + + if record: + self.id = record["id"] + self.thread_id = record["thread_id"] + self.owner_id = record["owner_id"] + self.location_id = record["location_id"] + self.locked = record["locked"] + + +class PartialConfig: + __slots__ = ("id", "ticket_channel_id", "logging_channel_id") + + def __init__(self, record: Optional[asyncpg.Record] = None): + self.id = None + + if record: + self.id = record["id"] + self.ticket_channel_id = record["ticket_channel_id"] + self.logging_channel_id = record["logging_channel_id"] + + +### Embeds + + class ClosedEmbed(discord.Embed): def __init__(self, **kwargs): kwargs.setdefault("color", discord.Color.from_rgb(138, 255, 157)) @@ -75,6 +257,365 @@ def __init__(self, author: Union[discord.User, discord.Member], **kwargs): self.set_author(name=author.global_name, icon_url=author.display_avatar.url) +class LoggingEmbed(discord.Embed): + def __init__(self, **kwargs): + kwargs.setdefault("color", discord.Color.from_rgb(212, 252, 255)) + kwargs.setdefault("timestamp", discord.utils.utcnow()) + super().__init__(**kwargs) + + +### UI Components (Views, Modals and Selects) + + +# Note that these emoji codepoints appear a lot: +# \U00002705 - U+2705 White Heavy Check Mark +# \U0000274c - U+274c Cross Mark +class TicketTitleModal(RoboModal, title="Ticket Title"): + def __init__(self, ctx: RoboContext, ticket_cog: Tickets, *args, **kwargs): + super().__init__(ctx=ctx, *args, **kwargs) + + self.title_input = discord.ui.TextInput( + label="Title", + style=discord.TextStyle.long, + placeholder="Input a title...", + min_length=20, + max_length=100, + ) + self.input: Optional[str] = None + self.ticket_cog = ticket_cog + self.add_item(self.title_input) + + async def on_submit( + self, interaction: discord.Interaction[Rodhaj] + ) -> Optional[str]: + self.input = self.title_input.value + self.ticket_cog.in_progress_tickets[interaction.user.id].title.set() + await interaction.response.send_message( + f"The title of the ticket is set to:\n`{self.title_input.value}`", + ephemeral=True, + ) + return self.input + + +class TicketTagsSelect(discord.ui.Select): + def __init__(self, tickets_cog: Tickets): + options = [ + discord.SelectOption( + label="Question", + value="question", + description="Represents one or more question", + emoji=discord.PartialEmoji(name="\U00002753"), + ), + discord.SelectOption( + label="Serious", + value="serious", + description="Represents a serious concern or question(s)", + emoji=discord.PartialEmoji(name="\U0001f610"), + ), + discord.SelectOption( + label="Private", + value="private", + description="Represents a private concern or matter", + emoji=discord.PartialEmoji(name="\U0001f512"), + ), + ] + super().__init__( + placeholder="Select a tag...", + min_values=1, + max_values=3, + options=options, + row=0, + ) + self.tickets_cog = tickets_cog + self.prev_selected: Optional[set] = None + + def tick(self, status) -> str: + if status is True: + return "\U00002705" + return "\U0000274c" + + async def callback(self, interaction: discord.Interaction[Rodhaj]) -> None: + values = self.values + in_progress_tag = self.tickets_cog.reserved_tags.get(interaction.user.id) + if in_progress_tag is None: + await interaction.response.send_message( + "Are there really any tags cached?", ephemeral=True + ) + return + + output_tag = ReservedTags(question=False, serious=False, private=False) + + if interaction.user.id in self.tickets_cog.reserved_tags: + output_tag = in_progress_tag + + current_selected = set(self.values) + + if self.prev_selected is not None: + missing = self.prev_selected - current_selected + added = current_selected - self.prev_selected + + combined = missing.union(added) + + for tag in combined: + output_tag[tag] = not in_progress_tag[tag] + else: + for tag in values: + output_tag[tag] = not in_progress_tag[tag] + + self.tickets_cog.reserved_tags[interaction.user.id] = output_tag + self.tickets_cog.in_progress_tickets[interaction.user.id].tags.set() + self.prev_selected = set(self.values) + formatted_str = "\n".join( + f"{self.tick(v)} - {k.title()}" for k, v in output_tag.items() + ) + result = f"The following have been modified:\n\n{formatted_str}" + + embed = Embed(title="Modified Tags") + embed.description = result + embed.set_footer(text="\U00002705 = Selected | \U0000274c = Unselected") + await interaction.response.send_message(embed=embed, ephemeral=True) + + +class TicketConfirmView(RoboView): + def __init__( + self, + attachments: list[discord.Attachment], + bot: Rodhaj, + ctx: RoboContext, + cog: Tickets, + config_cog: Config, + content: str, + guild: discord.Guild, + delete_after: bool = True, + ) -> None: + super().__init__(ctx=ctx, timeout=300.0) + self.attachments = attachments + self.bot = bot + self.ctx = ctx + self.cog = cog + self.config_cog = config_cog + self.content = content + self.guild = guild + self.delete_after = delete_after + self.triggered = asyncio.Event() + self.pool = self.bot.pool + self._modal = None + self.add_item(TicketTagsSelect(cog)) + + def tick(self, status) -> str: + if status is True: + return "\U00002705" + return "\U0000274c" + + async def delete_response(self, interaction: discord.Interaction): + await interaction.response.defer() + if self.delete_after: + await interaction.delete_original_response() + + self.stop() + + async def get_or_fetch_member(self, member_id: int) -> Optional[discord.Member]: + member = self.guild.get_member(member_id) + if member is not None: + return member + + members = await self.guild.query_members( + limit=1, user_ids=[member_id], cache=True + ) + if not members: + return None + return members[0] + + @discord.ui.button( + label="Checklist", + style=discord.ButtonStyle.primary, + emoji=discord.PartialEmoji(name="\U00002753"), + row=1, + ) + async def see_checklist( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + status = self.cog.in_progress_tickets.get(interaction.user.id) + + if status is None: + await interaction.response.send_message( + "Unable to view checklist", ephemeral=True + ) + return + + dict_status = {"title": status.title, "tags": status.tags} + formatted_status = "\n".join( + f"{self.tick(v.is_set())} - {k.title()}" for k, v in dict_status.items() + ) + + embed = Embed() + embed.title = "\U00002753 Status Checklist" + embed.description = f"The current status is shown below:\n\n{formatted_status}" + embed.set_footer(text="\U00002705 = Completed | \U0000274c = Incomplete") + await interaction.response.send_message(embed=embed, ephemeral=True) + + @discord.ui.button( + label="Set Title", + style=discord.ButtonStyle.primary, + emoji=discord.PartialEmoji(name="\U0001f58b"), + row=1, + ) + async def set_title( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + self._modal = TicketTitleModal(self.ctx, self.cog) + await interaction.response.send_modal(self._modal) + + @discord.ui.button( + label="Confirm", + style=discord.ButtonStyle.green, + emoji="<:greenTick:596576670815879169>", + row=1, + ) + async def confirm( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await register_user(self.ctx.author.id, self.pool) + author = self.ctx.author + + thread_display_id = uuid.uuid4() + thread_name = f"{author.display_name} | {thread_display_id}" + title = self._modal.input if self._modal and self._modal.input else thread_name + + tags = self.cog.reserved_tags.get(interaction.user.id) + status = self.cog.in_progress_tickets.get(interaction.user.id) + if tags is None or status is None: + await interaction.response.send_message( + "Unable to obtain reserved tags and in progress tags", + ephemeral=True, + ) + return + + applied_tags = [k for k, v in tags.items() if v is True] + + guild_settings = await self.config_cog.get_guild_settings(self.guild.id) + potential_member = await self.get_or_fetch_member(author.id) + + if not guild_settings: + await interaction.response.send_message( + "Unable to find guild settings", ephemeral=True + ) + return + + if (self.guild.created_at - interaction.created_at) < guild_settings.guild_age: + await interaction.response.send_message( + "The guild is too young in order to utilize Rodhaj.", + ephemeral=True, + ) + return + elif potential_member: # Since we are checking join times, if we don't have the proper member, we can only skip it. + joined_at = potential_member.joined_at or discord.utils.utcnow() + if (joined_at - interaction.created_at) < guild_settings.account_age: + await interaction.response.send_message( + "This account joined the server too soon in order to utilize Rodhaj.", + ephemeral=True, + ) + return + + if not status.title.is_set() or not status.tags.is_set(): + dict_status = {"title": status.title, "tags": status.tags} + formatted_status = "\n".join( + f"{self.tick(v.is_set())} - {k.title()}" for k, v in dict_status.items() + ) + + embed = ErrorEmbed() + embed.title = "\U00002757 Unfinished Ticket" + embed.description = ( + "Hold up! It seems like you have an unfinished ticket! " + "It's important to have a finished ticket " + "as it allows staff to work more efficiently. " + "Please refer to the checklist given below for which parts " + "that are incomplete.\n\n" + f"{formatted_status}" + "\n\nNote: In order to know, refer to the status checklist. " + 'This can be found by clicking the "See Checklist" button. ' + ) + embed.set_footer(text="\U00002705 = Complete | \U0000274c = Incomplete") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + files = [await attachment.to_file() for attachment in self.attachments] + ticket = TicketThread( + title=title, + user=author, + location_id=self.guild.id, + mention=guild_settings.mention, + content=self.content, + tags=applied_tags, + files=files, + created_at=discord.utils.utcnow(), + ) + created_ticket = await self.cog.create_ticket(ticket) + + if created_ticket is None: + await interaction.response.send_message( + "Rodhaj is not set up yet. Please contact the admin or staff", + ephemeral=True, + ) + return + + self.bot.dispatch( + "ticket_create", + self.guild, + self.ctx.author, + created_ticket.ticket, + safe_content(self.content), + ) + + self.cog.reserved_tags.pop(self.ctx.author.id, None) + self.cog.in_progress_tickets.pop(self.ctx.author.id, None) + + if self.message: + self.triggered.set() + + embed = discord.Embed( + title="\U0001f3ab Ticket created", + color=discord.Color.from_rgb(124, 252, 0), + ) + embed.description = "The ticket has been successfully created. Please continue to DM Rodhaj in order to send the message to the ticket, where an assigned staff will help you." + await self.message.edit(embed=embed, view=None, delete_after=15.0) + + @discord.ui.button( + label="Cancel", + style=discord.ButtonStyle.red, + emoji="<:redTick:596576672149667840>", + row=1, + ) + async def cancel( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await interaction.response.defer() + await interaction.delete_original_response() + self.stop() + + async def on_timeout(self) -> None: + # This is the only way you can really edit the original message + # There is a bug here, where the message first gets edited and the timeout gets called + # thus editing an unknown message + # --- + # In order to fix the issue with the invalid message, + # an event called triggered is used. This asyncio.Event + # is used in order to determine whether the event was triggered + # and if it is not, that means that it truly is an actual timeout that caused it + # not the user confirming and then the timeout being called later + if self.message and self.triggered.is_set() is False: + embed = ErrorEmbed() + embed.title = "\U00002757 Timed Out" + embed.description = ( + "Timed out waiting for a response. Not creating a ticket. " + "In order to create a ticket, please resend your message and properly confirm" + ) + await self.message.edit(embed=embed, view=None, delete_after=15.0) + + +### Actual cog + + class Tickets(commands.Cog): """The main central ticket management hub""" diff --git a/bot/cogs/utilities.py b/bot/cogs/utilities.py index e327301..b03418c 100644 --- a/bot/cogs/utilities.py +++ b/bot/cogs/utilities.py @@ -10,19 +10,17 @@ import psutil import pygit2 from discord.ext import commands -from discord.utils import format_dt -from libs.utils import Embed, human_timedelta, is_docker from pygit2.enums import SortMode +from utils.checks import is_docker +from utils.embeds import Embed +from utils.time import human_timedelta if TYPE_CHECKING: - from libs.utils import RoboContext + from utils.context import RoboContext from bot.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 Utilities(commands.Cog): def __init__(self, bot: Rodhaj) -> None: self.bot = bot @@ -32,6 +30,8 @@ def __init__(self, bot: Rodhaj) -> None: def display_emoji(self) -> discord.PartialEmoji: return discord.PartialEmoji(name="\U0001f9f0") + ### Utility logic + def get_bot_uptime(self, *, brief: bool = False) -> str: return human_timedelta( self.bot.uptime, accuracy=None, brief=brief, suffix=False @@ -48,7 +48,9 @@ def format_commit(self, commit: pygit2.Commit) -> str: ) # [`hash`](url) message (offset) - offset = format_dt(commit_time.astimezone(datetime.timezone.utc), "R") + offset = discord.utils.format_dt( + commit_time.astimezone(datetime.timezone.utc), "R" + ) commit_id = str(commit.id) return f"[`{short_sha2}`](https://github.com/transprogrammer/rodhaj/commit/{commit_id}) {short} ({offset})" @@ -72,6 +74,8 @@ async def fetch_num_active_tickets(self) -> int: return 0 return value + ### Commands + @commands.hybrid_command(name="about") async def about(self, ctx: RoboContext) -> None: """Shows some stats for Rodhaj""" diff --git a/bot/launcher.py b/bot/launcher.py index 88a02a7..626b599 100644 --- a/bot/launcher.py +++ b/bot/launcher.py @@ -3,11 +3,9 @@ from pathlib import Path import asyncpg -import discord from aiohttp import ClientSession -from libs.utils import KeyboardInterruptHandler, RodhajLogger -from libs.utils.config import RodhajConfig -from rodhaj import Rodhaj, init +from rodhaj import KeyboardInterruptHandler, Rodhaj, RodhajLogger, init +from utils.config import RodhajConfig if os.name == "nt": from winloop import run @@ -20,31 +18,24 @@ TOKEN = config["rodhaj"]["token"] POSTGRES_URI = config["postgres_uri"] -intents = discord.Intents.default() -intents.message_content = True -intents.members = True - async def main() -> None: - async with ClientSession() as session, asyncpg.create_pool( - dsn=POSTGRES_URI, - min_size=25, - max_size=25, - init=init, - command_timeout=30, - ) as pool: - async with Rodhaj( - config=config, intents=intents, session=session, pool=pool - ) as bot: + async with ( + ClientSession() as session, + asyncpg.create_pool( + dsn=POSTGRES_URI, + min_size=25, + max_size=25, + init=init, + command_timeout=30, + ) as pool, + ): + async with Rodhaj(config=config, session=session, pool=pool) as bot: bot.loop.add_signal_handler(signal.SIGTERM, KeyboardInterruptHandler(bot)) bot.loop.add_signal_handler(signal.SIGINT, KeyboardInterruptHandler(bot)) await bot.start(TOKEN) -def launch() -> None: +if __name__ == "__main__": with RodhajLogger(): run(main()) - - -if __name__ == "__main__": - launch() diff --git a/bot/libs/tickets/__init__.py b/bot/libs/tickets/__init__.py deleted file mode 100644 index d61aee4..0000000 --- a/bot/libs/tickets/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package for various utils, views, and others dedicated for handling tickets""" diff --git a/bot/libs/tickets/structs.py b/bot/libs/tickets/structs.py deleted file mode 100644 index 173e640..0000000 --- a/bot/libs/tickets/structs.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import asyncio -import datetime -from typing import NamedTuple, Optional, TypedDict, Union - -import asyncpg -import discord -import msgspec - - -class StatusChecklist(msgspec.Struct): - title: asyncio.Event = asyncio.Event() - tags: asyncio.Event = asyncio.Event() - - -class ReservedTags(TypedDict): - question: bool - serious: bool - private: bool - - -class ThreadWithGuild(NamedTuple): - thread: discord.Thread - source_guild: discord.Guild - - -class TicketThread(msgspec.Struct): - title: str - user: Union[discord.User, discord.Member] - location_id: int - mention: str - content: str - tags: list[str] - files: list[discord.File] - created_at: datetime.datetime - - -class PartialTicket: - __slots__ = ("id", "thread_id", "owner_id", "location_id", "locked") - - def __init__(self, record: Optional[asyncpg.Record] = None): - self.id = None - - if record: - self.id = record["id"] - self.thread_id = record["thread_id"] - self.owner_id = record["owner_id"] - self.location_id = record["location_id"] - self.locked = record["locked"] - - -class PartialConfig: - __slots__ = ("id", "ticket_channel_id", "logging_channel_id") - - def __init__(self, record: Optional[asyncpg.Record] = None): - self.id = None - - if record: - self.id = record["id"] - self.ticket_channel_id = record["ticket_channel_id"] - self.logging_channel_id = record["logging_channel_id"] diff --git a/bot/libs/tickets/utils.py b/bot/libs/tickets/utils.py deleted file mode 100644 index 6cafb70..0000000 --- a/bot/libs/tickets/utils.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional, Union - -import asyncpg -import discord -from async_lru import alru_cache - -from .structs import PartialTicket, ThreadWithGuild - -if TYPE_CHECKING: - from bot.rodhaj import Rodhaj - - -async def register_user( - user_id: int, connection: Union[asyncpg.Pool, asyncpg.Connection] -): - """Registers the user into the database - - Args: - user_id (int): ID of the user - connection (Union[asyncpg.Pool, asyncpg.Connection]): A connection (can be a pool) to the PostgreSQL server (through asyncpg) - - Returns: - bool: `True` if the user has been successfully registered into the database, - `False` if the user is already in the database - """ - query = """ - INSERT INTO user_config (id) - VALUES ($1) ON CONFLICT (id) DO NOTHING; - """ - status = await connection.execute(query, user_id) - if status[-1] == "0": - return False - return True - - -@alru_cache(maxsize=256) -async def get_partial_ticket( - bot: Rodhaj, user_id: int, pool: Optional[asyncpg.Pool] = None -) -> PartialTicket: - """Provides an `PartialTicket` object in order to perform various actions - - The `PartialTicket` represents a partial record of an ticket found in the - PostgreSQL database. - - If the `PartialTicket` instance has the attribute `id` set to `None`, then this means - that there is no ticket found. If an ticket is found, then the partial information - of it is filled. - - Args: - bot (Rodhaj): An instance of `Rodhaj` - user_id (int): ID of the user - pool (asyncpg.Pool): Pool of connections from asyncpg. Defaults to `None` - - Returns: - PartialTicket: An representation of a "partial" ticket - """ - query = """ - SELECT id, thread_id, owner_id, location_id, locked - FROM tickets - WHERE owner_id = $1; - """ - pool = pool or bot.pool - rows = await pool.fetchrow(query, user_id) - if rows is None: - # In order to prevent caching invalid tickets, we need to invalidate the cache. - # By invalidating the cache, we basically "ignore" the invalid - # ticket. This essentially still leaves us with the performance boosts - # of the LRU cache, while also properly invalidating invalid tickets - get_partial_ticket.cache_invalidate(bot, user_id, pool) - return PartialTicket() - return PartialTicket(rows) - - -@alru_cache(maxsize=64) -async def get_cached_thread( - bot: Rodhaj, user_id: int, connection: Optional[asyncpg.Pool] = None -) -> Optional[ThreadWithGuild]: - """Obtains an cached thread from the tickets channel - - This has a small LRU cache (size of 64) so the cache is forced to refresh its - internal data. - - Args: - bot (Rodhaj): Instance of `RodHaj` - user_id (int): ID of the user - connection (Optional[asyncpg.Pool]): Pool of connections from asyncpg. Defaults to `None` - - Returns: - Optional[ThreadWithGuild]: The thread with the guild the thread belongs to. - `None` if not found. - """ - query = """ - SELECT guild_config.ticket_channel_id, tickets.thread_id, tickets.location_id - FROM tickets - INNER JOIN guild_config ON guild_config.id = tickets.location_id - WHERE tickets.owner_id = $1; - """ - connection = connection or bot.pool - record = await connection.fetchrow(query, user_id) - if record is None: - return None - forum_channel = bot.get_channel(record["ticket_channel_id"]) or ( - await bot.fetch_channel(record["ticket_channel_id"]) - ) - if isinstance(forum_channel, discord.ForumChannel): - thread = forum_channel.get_thread(record["thread_id"]) - if thread is None: - get_cached_thread.cache_invalidate(bot, user_id, connection) - return None - return ThreadWithGuild(thread, thread.guild) - - -def safe_content(content: str, amount: int = 4000) -> str: - """Safely sends the content by reducing the length - to avoid errors - - Args: - content (str): Content to be sent - - Returns: - str: A safe version of the content - """ - if len(content) > amount: - return content[: amount - 3] + "..." - return content diff --git a/bot/libs/tickets/views.py b/bot/libs/tickets/views.py deleted file mode 100644 index 0590e2d..0000000 --- a/bot/libs/tickets/views.py +++ /dev/null @@ -1,365 +0,0 @@ -from __future__ import annotations - -import asyncio -import uuid -from typing import TYPE_CHECKING, Optional - -import discord -from libs.tickets.structs import ReservedTags, TicketThread -from libs.utils import Embed, ErrorEmbed, RoboModal, RoboView - -from .utils import register_user, safe_content - -if TYPE_CHECKING: - from libs.utils.context import RoboContext - - from bot.cogs.config import Config - from bot.cogs.tickets import Tickets - from bot.rodhaj import Rodhaj - - -# Note that these emoji codepoints appear a lot: -# \U00002705 - U+2705 White Heavy Check Mark -# \U0000274c - U+274c Cross Mark -class TicketTitleModal(RoboModal, title="Ticket Title"): - def __init__(self, ctx: RoboContext, ticket_cog: Tickets, *args, **kwargs): - super().__init__(ctx=ctx, *args, **kwargs) - - self.title_input = discord.ui.TextInput( - label="Title", - style=discord.TextStyle.long, - placeholder="Input a title...", - min_length=20, - max_length=100, - ) - self.input: Optional[str] = None - self.ticket_cog = ticket_cog - self.add_item(self.title_input) - - async def on_submit( - self, interaction: discord.Interaction[Rodhaj] - ) -> Optional[str]: - self.input = self.title_input.value - self.ticket_cog.in_progress_tickets[interaction.user.id].title.set() - await interaction.response.send_message( - f"The title of the ticket is set to:\n`{self.title_input.value}`", - ephemeral=True, - ) - return self.input - - -class TicketTagsSelect(discord.ui.Select): - def __init__(self, tickets_cog: Tickets): - options = [ - discord.SelectOption( - label="Question", - value="question", - description="Represents one or more question", - emoji=discord.PartialEmoji(name="\U00002753"), - ), - discord.SelectOption( - label="Serious", - value="serious", - description="Represents a serious concern or question(s)", - emoji=discord.PartialEmoji(name="\U0001f610"), - ), - discord.SelectOption( - label="Private", - value="private", - description="Represents a private concern or matter", - emoji=discord.PartialEmoji(name="\U0001f512"), - ), - ] - super().__init__( - placeholder="Select a tag...", - min_values=1, - max_values=3, - options=options, - row=0, - ) - self.tickets_cog = tickets_cog - self.prev_selected: Optional[set] = None - - def tick(self, status) -> str: - if status is True: - return "\U00002705" - return "\U0000274c" - - async def callback(self, interaction: discord.Interaction[Rodhaj]) -> None: - values = self.values - in_progress_tag = self.tickets_cog.reserved_tags.get(interaction.user.id) - if in_progress_tag is None: - await interaction.response.send_message( - "Are there really any tags cached?", ephemeral=True - ) - return - - output_tag = ReservedTags(question=False, serious=False, private=False) - - if interaction.user.id in self.tickets_cog.reserved_tags: - output_tag = in_progress_tag - - current_selected = set(self.values) - - if self.prev_selected is not None: - missing = self.prev_selected - current_selected - added = current_selected - self.prev_selected - - combined = missing.union(added) - - for tag in combined: - output_tag[tag] = not in_progress_tag[tag] - else: - for tag in values: - output_tag[tag] = not in_progress_tag[tag] - - self.tickets_cog.reserved_tags[interaction.user.id] = output_tag - self.tickets_cog.in_progress_tickets[interaction.user.id].tags.set() - self.prev_selected = set(self.values) - formatted_str = "\n".join( - f"{self.tick(v)} - {k.title()}" for k, v in output_tag.items() - ) - result = f"The following have been modified:\n\n{formatted_str}" - - embed = Embed(title="Modified Tags") - embed.description = result - embed.set_footer(text="\U00002705 = Selected | \U0000274c = Unselected") - await interaction.response.send_message(embed=embed, ephemeral=True) - - -class TicketConfirmView(RoboView): - def __init__( - self, - attachments: list[discord.Attachment], - bot: Rodhaj, - ctx: RoboContext, - cog: Tickets, - config_cog: Config, - content: str, - guild: discord.Guild, - delete_after: bool = True, - ) -> None: - super().__init__(ctx=ctx, timeout=300.0) - self.attachments = attachments - self.bot = bot - self.ctx = ctx - self.cog = cog - self.config_cog = config_cog - self.content = content - self.guild = guild - self.delete_after = delete_after - self.triggered = asyncio.Event() - self.pool = self.bot.pool - self._modal = None - self.add_item(TicketTagsSelect(cog)) - - def tick(self, status) -> str: - if status is True: - return "\U00002705" - return "\U0000274c" - - async def delete_response(self, interaction: discord.Interaction): - await interaction.response.defer() - if self.delete_after: - await interaction.delete_original_response() - - self.stop() - - async def get_or_fetch_member(self, member_id: int) -> Optional[discord.Member]: - member = self.guild.get_member(member_id) - if member is not None: - return member - - members = await self.guild.query_members( - limit=1, user_ids=[member_id], cache=True - ) - if not members: - return None - return members[0] - - @discord.ui.button( - label="Checklist", - style=discord.ButtonStyle.primary, - emoji=discord.PartialEmoji(name="\U00002753"), - row=1, - ) - async def see_checklist( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - status = self.cog.in_progress_tickets.get(interaction.user.id) - - if status is None: - await interaction.response.send_message( - "Unable to view checklist", ephemeral=True - ) - return - - dict_status = {"title": status.title, "tags": status.tags} - formatted_status = "\n".join( - f"{self.tick(v.is_set())} - {k.title()}" for k, v in dict_status.items() - ) - - embed = Embed() - embed.title = "\U00002753 Status Checklist" - embed.description = f"The current status is shown below:\n\n{formatted_status}" - embed.set_footer(text="\U00002705 = Completed | \U0000274c = Incomplete") - await interaction.response.send_message(embed=embed, ephemeral=True) - - @discord.ui.button( - label="Set Title", - style=discord.ButtonStyle.primary, - emoji=discord.PartialEmoji(name="\U0001f58b"), - row=1, - ) - async def set_title( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - self._modal = TicketTitleModal(self.ctx, self.cog) - await interaction.response.send_modal(self._modal) - - @discord.ui.button( - label="Confirm", - style=discord.ButtonStyle.green, - emoji="<:greenTick:596576670815879169>", - row=1, - ) - async def confirm( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - await register_user(self.ctx.author.id, self.pool) - author = self.ctx.author - - thread_display_id = uuid.uuid4() - thread_name = f"{author.display_name} | {thread_display_id}" - title = self._modal.input if self._modal and self._modal.input else thread_name - - tags = self.cog.reserved_tags.get(interaction.user.id) - status = self.cog.in_progress_tickets.get(interaction.user.id) - if tags is None or status is None: - await interaction.response.send_message( - "Unable to obtain reserved tags and in progress tags", - ephemeral=True, - ) - return - - applied_tags = [k for k, v in tags.items() if v is True] - - guild_settings = await self.config_cog.get_guild_settings(self.guild.id) - potential_member = await self.get_or_fetch_member(author.id) - - if not guild_settings: - await interaction.response.send_message( - "Unable to find guild settings", ephemeral=True - ) - return - - if (self.guild.created_at - interaction.created_at) < guild_settings.guild_age: - await interaction.response.send_message( - "The guild is too young in order to utilize Rodhaj.", - ephemeral=True, - ) - return - elif potential_member: # Since we are checking join times, if we don't have the proper member, we can only skip it. - joined_at = potential_member.joined_at or discord.utils.utcnow() - if (joined_at - interaction.created_at) < guild_settings.account_age: - await interaction.response.send_message( - "This account joined the server too soon in order to utilize Rodhaj.", - ephemeral=True, - ) - return - - if not status.title.is_set() or not status.tags.is_set(): - dict_status = {"title": status.title, "tags": status.tags} - formatted_status = "\n".join( - f"{self.tick(v.is_set())} - {k.title()}" for k, v in dict_status.items() - ) - - embed = ErrorEmbed() - embed.title = "\U00002757 Unfinished Ticket" - embed.description = ( - "Hold up! It seems like you have an unfinished ticket! " - "It's important to have a finished ticket " - "as it allows staff to work more efficiently. " - "Please refer to the checklist given below for which parts " - "that are incomplete.\n\n" - f"{formatted_status}" - "\n\nNote: In order to know, refer to the status checklist. " - 'This can be found by clicking the "See Checklist" button. ' - ) - embed.set_footer(text="\U00002705 = Complete | \U0000274c = Incomplete") - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - files = [await attachment.to_file() for attachment in self.attachments] - ticket = TicketThread( - title=title, - user=author, - location_id=self.guild.id, - mention=guild_settings.mention, - content=self.content, - tags=applied_tags, - files=files, - created_at=discord.utils.utcnow(), - ) - created_ticket = await self.cog.create_ticket(ticket) - - if created_ticket is None: - await interaction.response.send_message( - "Rodhaj is not set up yet. Please contact the admin or staff", - ephemeral=True, - ) - return - - self.bot.dispatch( - "ticket_create", - self.guild, - self.ctx.author, - created_ticket.ticket, - safe_content(self.content), - ) - - self.cog.reserved_tags.pop(self.ctx.author.id, None) - self.cog.in_progress_tickets.pop(self.ctx.author.id, None) - - if self.message: - self.triggered.set() - - embed = discord.Embed( - title="\U0001f3ab Ticket created", - color=discord.Color.from_rgb(124, 252, 0), - ) - embed.description = "The ticket has been successfully created. Please continue to DM Rodhaj in order to send the message to the ticket, where an assigned staff will help you." - await self.message.edit(embed=embed, view=None, delete_after=15.0) - - @discord.ui.button( - label="Cancel", - style=discord.ButtonStyle.red, - emoji="<:redTick:596576672149667840>", - row=1, - ) - async def cancel( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - await interaction.response.defer() - await interaction.delete_original_response() - self.stop() - - async def on_timeout(self) -> None: - # This is the only way you can really edit the original message - # There is a bug here, where the message first gets edited and the timeout gets called - # thus editing an unknown message - # --- - # In order to fix the issue with the invalid message, - # an event called triggered is used. This asyncio.Event - # is used in order to determine whether the event was triggered - # and if it is not, that means that it truly is an actual timeout that caused it - # not the user confirming and then the timeout being called later - if self.message and self.triggered.is_set() is False: - embed = ErrorEmbed() - embed.title = "\U00002757 Timed Out" - embed.description = ( - "Timed out waiting for a response. Not creating a ticket. " - "In order to create a ticket, please resend your message and properly confirm" - ) - await self.message.edit(embed=embed, view=None, delete_after=15.0) - return diff --git a/bot/libs/utils/handler.py b/bot/libs/utils/handler.py deleted file mode 100644 index 7d4afea..0000000 --- a/bot/libs/utils/handler.py +++ /dev/null @@ -1,18 +0,0 @@ -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/logger.py b/bot/libs/utils/logger.py deleted file mode 100644 index 294706b..0000000 --- a/bot/libs/utils/logger.py +++ /dev/null @@ -1,46 +0,0 @@ -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("rodhaj") - - def __enter__(self) -> None: - max_bytes = 32 * 1024 * 1024 # 32 MiB - self.log.setLevel(logging.INFO) - logging.getLogger("discord").setLevel(logging.INFO) - logging.getLogger("watchfiles").setLevel(logging.WARNING) - handler = RotatingFileHandler( - filename="rodhaj.log", - encoding="utf-8", - mode="w", - maxBytes=max_bytes, - backupCount=5, - ) - fmt = logging.Formatter( - fmt="%(asctime)s %(levelname)s\t%(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/migrations.py b/bot/migrations.py index abeb8c9..5112954 100644 --- a/bot/migrations.py +++ b/bot/migrations.py @@ -9,8 +9,8 @@ import asyncpg import click -from libs.utils.config import RodhajConfig from typing_extensions import Self +from utils.config import RodhajConfig path = Path(__file__).parent / "config.yml" config = RodhajConfig(path) @@ -256,7 +256,7 @@ async def log(reverse): ) for rev in revs: as_yellow = click.style(f"V{rev.version:>03}", fg="yellow") - click.echo(f'{as_yellow} {rev.description.replace("_", " ")}') + click.echo(f"{as_yellow} {rev.description.replace('_', ' ')}") if __name__ == "__main__": diff --git a/bot/rodhaj.py b/bot/rodhaj.py index 7b8f526..17e53ba 100644 --- a/bot/rodhaj.py +++ b/bot/rodhaj.py @@ -1,8 +1,11 @@ from __future__ import annotations +import asyncio import logging +from logging.handlers import RotatingFileHandler from pathlib import Path -from typing import TYPE_CHECKING, Optional, Union +from types import TracebackType +from typing import TYPE_CHECKING, Optional, Type, TypeVar, Union import asyncpg import discord @@ -11,19 +14,27 @@ from cogs import EXTENSIONS, VERSION from cogs.config import Blocklist, GuildWebhookDispatcher from cogs.ext.prometheus import Metrics +from cogs.tickets import ( + PartialConfig, + ReservedTags, + StatusChecklist, + TicketConfirmView, + get_cached_thread, + get_partial_ticket, +) +from discord import app_commands from discord.ext import commands -from libs.tickets.structs import PartialConfig, ReservedTags, StatusChecklist -from libs.tickets.utils import get_cached_thread, get_partial_ticket -from libs.tickets.views import TicketConfirmView -from libs.utils import RoboContext, RodhajCommandTree, RodhajHelp -from libs.utils.config import RodhajConfig -from libs.utils.prefix import get_prefix -from libs.utils.reloader import Reloader +from utils import RoboContext, RodhajCommandTree, RodhajHelp +from utils.config import RodhajConfig +from utils.prefix import get_prefix +from utils.reloader import Reloader if TYPE_CHECKING: from cogs.config import Config from cogs.tickets import Tickets - from libs.utils.context import RoboContext + from utils.context import RoboContext + +BE = TypeVar("BE", bound=BaseException) async def init(conn: asyncpg.Connection): @@ -43,22 +54,78 @@ def _decode_jsonb(value): ) +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()) + + +class RodhajLogger: + def __init__(self) -> None: + self.self = self + self.log = logging.getLogger("rodhaj") + self.log.setLevel(logging.INFO) + + def __enter__(self) -> None: + max_bytes = 32 * 1024 * 1024 # 32 MiB + handler = RotatingFileHandler( + filename="rodhaj.log", + encoding="utf-8", + mode="w", + maxBytes=max_bytes, + backupCount=5, + ) + fmt = logging.Formatter( + fmt="{asctime} [{levelname:<8}]{:^4}{message}", + datefmt="[%Y-%m-%d %H:%M:%S]", + style="{", + ) + 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) + + class Rodhaj(commands.Bot): """Main bot for Rodhaj""" def __init__( self, config: RodhajConfig, - intents: discord.Intents, session: ClientSession, pool: asyncpg.Pool, *args, **kwargs, ): + intents = discord.Intents( + emojis=True, + guilds=True, + members=True, + message_content=True, + messages=True, + reactions=True, + ) super().__init__( activity=discord.Activity( type=discord.ActivityType.watching, name="a game" ), + allowed_installs=app_commands.AppInstallationType(guild=True, user=False), allowed_mentions=discord.AllowedMentions( everyone=False, replied_user=False ), diff --git a/bot/libs/utils/__init__.py b/bot/utils/__init__.py similarity index 78% rename from bot/libs/utils/__init__.py rename to bot/utils/__init__.py index b558372..88132f9 100644 --- a/bot/libs/utils/__init__.py +++ b/bot/utils/__init__.py @@ -9,11 +9,8 @@ from .embeds import ( Embed as Embed, ErrorEmbed as ErrorEmbed, - LoggingEmbed as LoggingEmbed, ) -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 from .tree import RodhajCommandTree as RodhajCommandTree diff --git a/bot/libs/utils/checks.py b/bot/utils/checks.py similarity index 98% rename from bot/libs/utils/checks.py rename to bot/utils/checks.py index ae46d71..9736c1c 100644 --- a/bot/libs/utils/checks.py +++ b/bot/utils/checks.py @@ -12,7 +12,7 @@ T = TypeVar("T", commands.Command, commands.Group) if TYPE_CHECKING: - from libs.utils.context import RoboContext + from utils.context import RoboContext async def check_guild_permissions( diff --git a/bot/libs/utils/config.py b/bot/utils/config.py similarity index 100% rename from bot/libs/utils/config.py rename to bot/utils/config.py diff --git a/bot/libs/utils/context.py b/bot/utils/context.py similarity index 100% rename from bot/libs/utils/context.py rename to bot/utils/context.py diff --git a/bot/libs/utils/embeds.py b/bot/utils/embeds.py similarity index 86% rename from bot/libs/utils/embeds.py rename to bot/utils/embeds.py index 66209f2..3149c04 100644 --- a/bot/libs/utils/embeds.py +++ b/bot/utils/embeds.py @@ -9,13 +9,6 @@ def __init__(self, **kwargs): super().__init__(**kwargs) -class LoggingEmbed(discord.Embed): - def __init__(self, **kwargs): - kwargs.setdefault("color", discord.Color.from_rgb(212, 252, 255)) - kwargs.setdefault("timestamp", discord.utils.utcnow()) - super().__init__(**kwargs) - - class ErrorEmbed(discord.Embed): def __init__(self, **kwargs): kwargs.setdefault("color", discord.Color.from_rgb(214, 6, 6)) diff --git a/bot/libs/utils/help.py b/bot/utils/help.py similarity index 100% rename from bot/libs/utils/help.py rename to bot/utils/help.py diff --git a/bot/libs/utils/modals.py b/bot/utils/modals.py similarity index 100% rename from bot/libs/utils/modals.py rename to bot/utils/modals.py diff --git a/bot/libs/utils/pages/__init__.py b/bot/utils/pages/__init__.py similarity index 100% rename from bot/libs/utils/pages/__init__.py rename to bot/utils/pages/__init__.py diff --git a/bot/libs/utils/pages/modals.py b/bot/utils/pages/modals.py similarity index 100% rename from bot/libs/utils/pages/modals.py rename to bot/utils/pages/modals.py diff --git a/bot/libs/utils/pages/paginator.py b/bot/utils/pages/paginator.py similarity index 98% rename from bot/libs/utils/pages/paginator.py rename to bot/utils/pages/paginator.py index 8817168..0d56e95 100644 --- a/bot/libs/utils/pages/paginator.py +++ b/bot/utils/pages/paginator.py @@ -109,11 +109,9 @@ async def show_checked_page( ) -> None: max_pages = self.source.get_max_pages() try: - if max_pages is None: + if not max_pages or max_pages > page_number >= 0: # 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 @@ -189,6 +187,7 @@ async def go_to_previous_page( async def go_to_current_page( self, interaction: discord.Interaction, button: discord.ui.Button ): + # Current button does nothing pass @discord.ui.button(label="Next", style=discord.ButtonStyle.blurple) diff --git a/bot/libs/utils/pages/simple_pages.py b/bot/utils/pages/simple_pages.py similarity index 100% rename from bot/libs/utils/pages/simple_pages.py rename to bot/utils/pages/simple_pages.py diff --git a/bot/libs/utils/pages/sources.py b/bot/utils/pages/sources.py similarity index 100% rename from bot/libs/utils/pages/sources.py rename to bot/utils/pages/sources.py diff --git a/bot/libs/utils/prefix.py b/bot/utils/prefix.py similarity index 100% rename from bot/libs/utils/prefix.py rename to bot/utils/prefix.py diff --git a/bot/libs/utils/reloader.py b/bot/utils/reloader.py similarity index 100% rename from bot/libs/utils/reloader.py rename to bot/utils/reloader.py diff --git a/bot/libs/utils/time.py b/bot/utils/time.py similarity index 99% rename from bot/libs/utils/time.py rename to bot/utils/time.py index a274e0e..7f55a27 100644 --- a/bot/libs/utils/time.py +++ b/bot/utils/time.py @@ -14,9 +14,10 @@ units["seconds"].append("secs") if TYPE_CHECKING: - from libs.utils.context import RoboContext from typing_extensions import Self + from utils.context import RoboContext + def human_join(seq: Sequence[str], delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/bot/libs/utils/tree.py b/bot/utils/tree.py similarity index 100% rename from bot/libs/utils/tree.py rename to bot/utils/tree.py diff --git a/bot/libs/utils/views.py b/bot/utils/views.py similarity index 100% rename from bot/libs/utils/views.py rename to bot/utils/views.py diff --git a/docker/prometheus.yml b/docker/prometheus.yml index 776aeec..dd29b6c 100644 --- a/docker/prometheus.yml +++ b/docker/prometheus.yml @@ -1,5 +1,5 @@ global: - scrape_interval: 15s + scrape_interval: 15s evaluation_interval: 15s scrape_configs: