diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8dd399a --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..66d32d7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,42 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/github-workflow.json + +name: ModMail CD + +on: + workflow_run: + workflows: ["ModMail CI"] + types: + - completed + workflow_dispatch: + +jobs: + lint-playbook: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Run ansible-lint + uses: ansible-community/ansible-lint-action@main + run-ansible-playbook: + runs-on: ubuntu-latest + needs: [lint-playbook] + steps: + - uses: actions/checkout@v3 + - name: Install Ansible via Apt + run: > + sudo apt update && + sudo apt install software-properties-common && + sudo apt-add-repository --yes --update ppa:ansible/ansible && + sudo apt install ansible + - name: Write Inventory to File + env: + INVENTORY: ${{ secrets.INVENTORY }} + run: 'echo "$INVENTORY" > inventory' + - name: Install SSH Key + uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.ANSIBLE_KEY }} + name: ansible + known_hosts: ${{ secrets.KNOWN_HOSTS }} + - name: Run Ansible Playbook + run: | + ansible-playbook -i inventory playbook.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a8d8ac3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,39 @@ +name: ModMail CI + +on: + push: + branches: ["main"] + + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-publish-latest: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set Up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/.gitignore b/.gitignore index d9ecc4f..b276478 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,7 @@ celerybeat.pid *.sage.py # Environments +modmail.env .env .venv env/ @@ -129,4 +130,4 @@ dmypy.json .pyre/ config.json -modmail.db \ No newline at end of file +modmail.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..986859a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +If you are reading this page, then thank you for your interest in contributing towards the bot. +We are grateful for any help, however, please ensure you follow the guidelines laid out below +and ensure that any code you produce for modmail.py is licensed with the GNU GPL v3. + +# Contributions + +Not all contribution PRs (read below) will be accepted. +For ideas as to what to contribute, please refer to the GitHub issues or contact a member of the development team. + +Bug fixes and optimisations are also greatly appreciated! + +# VCS + +1. Create a new branch (and fork if applicable). Label it appropriately. +2. Make the changes on that branch. +3. Commit to and push the changes. +4. Create a PR from your branch to `main`. +5. A maintainer will then review your PR. + +If you have questions, please ask a maintainer. diff --git a/README.md b/README.md index d8abf14..2331b7d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,30 @@ ModMail, but in Python +# Configuration + +- `token`: The bot's user token retrieved from Discord Developer Portal. +- `application_id`: The bot's application ID retrieved from Discord Developer Portal. +- `guild`: The guild ID. +- `channel`: Modmail channel ID in specified guild (must be `TextChannel`). +- `prefix`: The bot prefix (needed for slash command sync command). +- `status`: The bot status. +- `id_prefix`: The bot prefix for persistent views (e.g., `mm`) + +## Sample `config.json` + +```json +{ + "token": "abc123", + "application_id": 1234567890, + "guild": 1234567890, + "channel": 1234567890, + "prefix": "]", + "status": "DM to contact", + "id_prefix": "mm" +} +``` + # Running the bot 1. Navigate to the root directory. @@ -11,7 +35,7 @@ cd /modmail.py ``` 2. Copy the `config.example.json`, rename to `config.json`, and replace the relevant values. -If you want to inject the config at runtime using environment variables, don't replace the values. + If you want to inject the config at runtime using environment variables, don't replace the values. 3. Build the bot using Docker. @@ -28,6 +52,7 @@ docker container run --name modmail \ ``` As aforementioned, you can also inject environment variables. + ``` docker container run --name modmail \ -v database:/database \ @@ -37,3 +62,7 @@ docker container run --name modmail \ -e MODMAIL_PREFIX=! \ modmail-py ``` + +# Contributions + +For information regarding contributing to this project, please read [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/cogs/command_actions.py b/cogs/command_actions.py deleted file mode 100644 index dd4792b..0000000 --- a/cogs/command_actions.py +++ /dev/null @@ -1,160 +0,0 @@ -import datetime, asyncio, discord -import utils.umember as umember, db, utils.ticket_embed as ticket_embed -from utils.embed_reactions import embed_reactions -from discord.ext import commands - -class CommandActions(commands.Cog): - """Cog to contain command action methods.""" - - def __init__(self, bot, modmail_channel): - """Constructs necessary attributes for all command action methods. - - Args: - bot (commands.Bot): The bot object. - modmail_channel (discord.Channel): The modmail channel specified in config. - """ - - self.bot = bot - self.modmail_channel = modmail_channel - print("\nCog 'Command Actions' loaded") - - async def cog_check(self, ctx: commands.Context): - if ctx.channel != self.modmail_channel: - return False - - if ctx.author == self.bot: - return False - - return True - - @commands.command(name="open") - @commands.guild_only() - async def open_ticket(self, ctx, member): - """Opens ticket for specified user if no tickets are currently open.""" - - member = umember.assert_member(ctx.guild, member) - - ticket = db.get_ticket_by_user(member.id) - - if ticket['ticket_id'] != -1: - await ctx.send('There is already a ticket open for {0}'.format(member)) - return - - ticket_id = db.open_ticket(member.id) - ticket = db.get_ticket(ticket_id) - - ticket_message = await ctx.send(embed=ticket_embed.channel_embed(ctx.guild, ticket['ticket_id'])) - db.update_ticket_message(ticket['ticket_id'], ticket_message.id) - - await post_reactions(ticket_message) - - @commands.command(name="refresh") - @commands.guild_only() - async def refresh_ticket(self, ctx, member): - """Resends embed for specified user if there is a ticket that is already open.""" - - member = umember.assert_member(ctx.guild, member) - - ticket = db.get_ticket_by_user(member.id) - - if ticket['ticket_id'] == -1: - await self.message.channel.send('There is no ticket open for {0}.'.format(member)) - return - - ticket_message = await ctx.send(embed=ticket_embed.channel_embed(ctx.guild, ticket['ticket_id'])) - db.update_ticket_message(ticket['ticket_id'], ticket_message.id) - - if ticket['message_id'] is not None and ticket['message_id'] != -1: - old_ticket_message = await ctx.channel.fetch_message(ticket['message_id']) - await old_ticket_message.delete() - - await post_reactions(ticket_message) - - @commands.command(name="close") - @commands.guild_only() - async def close_ticket(self, ctx, member): - """Closes ticket for specified user given that a ticket is already open.""" - - member = umember.assert_member(ctx.guild, member) - - ticket = db.get_ticket_by_user(member.id) - - if ticket['ticket_id'] == -1: - await ctx.send('There is no ticket open for {0}.'.format(member)) - return - - embed_commands = embed_reactions(self.bot, ctx.guild, self.modmail_channel, ctx.author, ticket) - await embed_commands.message_close() - - @commands.command(name="timeout") - @commands.guild_only() - async def timeout_ticket(self, ctx, member): - """Times out specified user.""" - - member = umember.assert_member(ctx.guild, member) - - embed_commands = embed_reactions(self.bot, ctx.guild, self.modmail_channel, ctx.author) - await embed_commands.message_timeout(member) - - @commands.command(name="untimeout") - @commands.guild_only() - async def untimeout_ticket(self, ctx, member): - """Removes timeout for specified user given that user is currently timed out.""" - - member = umember.assert_member(ctx.guild, member) - - timeout = db.get_timeout(member.id) - current_time = int(datetime.datetime.now().timestamp()) - - if timeout == False or (timeout != False and current_time > timeout['timestamp']): - await ctx.send('{0} is not currently timed out.'.format(member)) - return - - confirmation = await ctx.send(embed=ticket_embed.untimeout_confirmation(member, timeout['timestamp'])) - await confirmation.add_reaction('✅') - await confirmation.add_reaction('❎') - - def untimeout_check(reaction, user): - return user == ctx.author and confirmation == reaction.message and (str(reaction.emoji) == '✅' or str(reaction.emoji) == '❎') - - try: - reaction, user = await self.bot.wait_for('reaction_add', timeout=60.0, check=untimeout_check) - if str(reaction.emoji) == '✅': - timestamp = int(datetime.datetime.now().timestamp()) - db.set_timeout(member.id, timestamp) - await member.send(embed=ticket_embed.user_untimeout()) - await self.modmail_channel.send('{0} has been successfully untimed out.'.format(member)) - except asyncio.TimeoutError: - pass - except discord.errors.Forbidden: - await self.modmail_channel.send("Could not send timeout message to specified user due to privacy settings. Timeout has not been set.") - - await confirmation.delete() - - # ! Fix errors - async def cog_command_error(self, ctx, error): - if type(error) == commands.errors.CheckFailure: - print("Command executed in wrong channel.") - elif type(error) == commands.errors.MissingRequiredArgument: - await ctx.send("A valid user (and one who is still on the server) was not specified.") - else: - await ctx.send(str(error) + "\nIf you do not understand, contact a bot dev.") - - -async def post_reactions(message): - """Adds specified reactions to message. - - Args: - message (discord.Message): The specified message. - """ - - message_reactions = ['🗣️', '❎', '⏲️'] - - for reaction in message_reactions: - try: - await message.add_reaction(reaction) - except discord.errors.NotFound: - pass - -def setup(bot): - bot.add_cog(CommandActions(bot)) \ No newline at end of file diff --git a/cogs/commands.py b/cogs/commands.py new file mode 100644 index 0000000..beddae7 --- /dev/null +++ b/cogs/commands.py @@ -0,0 +1,180 @@ +from typing import Literal, Optional + +from discord.ext import commands +from discord import app_commands +import discord + +import db +from utils import actions +from utils.config import Config + +import logging + +logger = logging.getLogger(__name__) + +modmail_config = Config() + + +class Commands(commands.Cog): + """Cog to contain command action methods.""" + + def __init__(self, bot: commands.Bot, modmail_channel: discord.TextChannel) -> None: + """Constructs necessary attributes for all command action methods. + + Args: + bot (commands.Bot): The bot object. + modmail_channel (discord.TextChannel): The modmail channel specified in config. + """ + + self.bot = bot + self.modmail_channel = modmail_channel + + async def cog_check(self, ctx: commands.Context): + if ctx.channel != self.modmail_channel: + await ctx.send("Command must be used in the modmail channel.") + return False + + if ctx.author == self.bot: + await ctx.send("Bots cannot use commands.") + return False + + return True + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.channel != self.modmail_channel: + await interaction.response.send_message( + "Command must be used in the modmail channel." + ) + return False + + if ( + interaction.data + and "resolved" in interaction.data + and "users" in interaction.data["resolved"] + and len(interaction.data["resolved"]["users"]) > 0 + ): + user_id = next(iter(interaction.data["resolved"]["users"])) + user = interaction.data["resolved"]["users"][user_id] + if "bot" in user and user["bot"]: + await interaction.response.send_message("Invalid user specified.") + return False + + return True + + @commands.command(name="sync") + @commands.has_permissions(manage_guild=True) + @commands.guild_only() + async def sync(self, ctx: commands.Context, spec: Optional[Literal["~"]] = None): + """ + Syncs commands to the current guild or globally. + + Args: + ctx (commands.Context): The command context. + spec (Optional[Literal["~"]]): If "~", syncs globally. Defaults to None. + """ + if spec == "~": + synced = await ctx.bot.tree.sync() + else: + ctx.bot.tree.copy_global_to(guild=ctx.guild) + synced = await ctx.bot.tree.sync(guild=ctx.guild) + + await ctx.send( + f"Synced {len(synced)} commands {'to the current guild' if spec is None else 'globally'}." + ) + return + + @app_commands.command(name="open") + @commands.guild_only() + async def open_ticket( + self, interaction: discord.Interaction, member: discord.Member + ): + """Opens ticket for specified user if no tickets are currently open.""" + + await actions.message_open(self.bot, interaction, member) + + @app_commands.command(name="refresh") + @commands.guild_only() + async def refresh_ticket( + self, interaction: discord.Interaction, member: discord.Member + ): + """Resends embed for specified user if there is a ticket that is already open.""" + + await actions.message_refresh(self.bot, interaction, member) + + @app_commands.command(name="close") + @commands.guild_only() + async def close_ticket( + self, interaction: discord.Interaction, member: discord.Member + ): + """Closes ticket for specified user given that a ticket is already open.""" + + ticket = await db.get_ticket_by_user(member.id) + + if not ticket: + await interaction.response.send_message( + f"There is no ticket open for {member.name}.", ephemeral=True + ) + return + + await actions.message_close(interaction, ticket, member) + + @app_commands.command(name="timeout") + @commands.guild_only() + async def timeout_ticket( + self, interaction: discord.Interaction, member: discord.Member + ): + """Times out specified user.""" + await actions.message_timeout(interaction, member) + + @app_commands.command(name="untimeout") + @commands.guild_only() + async def untimeout_ticket( + self, interaction: discord.Interaction, member: discord.Member + ): + """Removes timeout for specified user given that user is currently timed out.""" + + await actions.message_untimeout(interaction, member) + + async def cog_command_error( + self, ctx: commands.Context, error: commands.CommandError + ): + if isinstance(error, commands.errors.CheckFailure): + logger.error("Checks failed for interaction.") + if isinstance(error, commands.errors.MissingRequiredArgument): + await ctx.send( + "A valid user (and one who is still on the server) was not specified." + ) + else: + await ctx.send( + str(error) + "\nIf you do not understand, contact a bot dev." + ) + + async def cog_app_command_error( + self, interaction: discord.Interaction, error: app_commands.AppCommandError + ): + if isinstance(error, app_commands.errors.CheckFailure): + logger.error("Checks failed for interaction.") + else: + await interaction.response.send_message( + str(error) + "\nIf you do not understand, contact a bot dev." + ) + logger.error(error) + + +async def setup(bot: commands.Bot): + """Setup function for the listeners cog. + + Args: + bot (commands.Bot): The bot. + """ + try: + modmail_channel = await bot.fetch_channel(modmail_config.channel) + except Exception as e: + raise ValueError( + "The channel specified in config was not found. Please check your config." + ) from e + + if not isinstance(modmail_channel, discord.TextChannel): + raise TypeError("The channel specified in config was not a text channel.") + + await bot.add_cog(Commands(bot, modmail_channel)) diff --git a/cogs/listeners.py b/cogs/listeners.py index 56c106e..c5521c9 100644 --- a/cogs/listeners.py +++ b/cogs/listeners.py @@ -1,30 +1,35 @@ import datetime + from discord.ext import commands import discord -import utils.ticket_embed as ticket_embed, db -import utils.uformatter as uformatter -from cogs.command_actions import post_reactions +import db +from utils import uformatter, ticket_embed +from utils.config import Config + +import logging + +logger = logging.getLogger(__name__) + +modmail_config = Config() + class Listeners(commands.Cog): """Cog to contain all main listener methods.""" - def __init__(self, bot, guild, modmail_channel): + def __init__(self, bot: commands.Bot, modmail_channel: discord.TextChannel) -> None: """Constructs necessary attributes for all command action methods. Args: bot (commands.Bot): The bot object. - guild (discord.Guild): The specified guild in config. - modmail_channel (discord.Channel): The specified channel in config. + modmail_channel (discord.TextChannel): The specified channel in config. """ self.bot = bot - self.guild = guild self.modmail_channel = modmail_channel - print("\nCog 'Listeners' loaded") - + @commands.Cog.listener() - async def on_message(self, message): + async def on_message(self, message: discord.Message): """Listener for both DM and server messages. Args: @@ -32,56 +37,99 @@ async def on_message(self, message): """ if message.guild is None and not message.author.bot: + # Handle if user is not in guild + if not ( + self.modmail_channel.guild.get_member(message.author.id) + or await self.modmail_channel.guild.fetch_member(message.author.id) + ): + try: + await message.author.send( + "Unable to send message. Please ensure you have joined the server." + ) + except discord.errors.Forbidden: + pass + return + await self.handle_dm(message) - return - async def handle_dm(self, message): + async def handle_dm(self, message: discord.Message): """Handle DM messages. Args: message (discord.Message): The current message. """ - + user = message.author - timeout = db.get_timeout(user.id) + timeout = await db.get_timeout(user.id) current_time = int(datetime.datetime.now().timestamp()) - if timeout != False and current_time < timeout['timestamp']: - await user.send(embed=ticket_embed.user_timeout(timeout['timestamp'])) + if timeout and current_time < timeout.timestamp: + await user.send(embed=ticket_embed.user_timeout(timeout.timestamp)) return - + response = uformatter.format_message(message) - if not response.strip(): + if not response: return - + # ! Fix for longer messages if len(response) > 1000: - await message.channel.send('Your message is too long. Please shorten your message or send in multiple parts.') + await message.channel.send( + "Your message is too long. Please shorten your message or send in multiple parts." + ) return - ticket = db.get_ticket_by_user(user.id) + ticket = await db.get_ticket_by_user(user.id) - if ticket['ticket_id'] == -1: - ticket_id = db.open_ticket(user.id) - ticket = db.get_ticket(ticket_id) + if not ticket: + ticket_id = await db.open_ticket(user.id) + ticket = await db.get_ticket(ticket_id) + logger.info(f"Opened new ticket for: {user.id}") try: - if ticket['message_id'] is not None and ticket['message_id'] != -1: - old_ticket_message = await self.modmail_channel.fetch_message(ticket['message_id']) + if ticket and ticket.message_id is not None: + old_ticket_message = await self.modmail_channel.fetch_message( + ticket.message_id + ) await old_ticket_message.delete() except discord.errors.NotFound: - await message.channel.send('You are being rate limited. Please wait a few seconds before trying again.') + await message.channel.send( + "You are being rate limited. Please wait a few seconds before trying again." + ) return - db.add_ticket_response(ticket['ticket_id'], user.id, response, False) + # `ticket` truthiness has been checked prior to the following lines + await db.add_ticket_response(ticket.ticket_id, user.id, response, False) + + embeds = await ticket_embed.channel_embed(self.modmail_channel.guild, ticket) + + message_embed, buttons_view = await ticket_embed.MessageButtonsView( + self.bot, embeds + ).return_paginated_embed() + + ticket_message = await self.modmail_channel.send( + embed=message_embed, view=buttons_view + ) + await message.add_reaction("📨") + + await db.update_ticket_message(ticket.ticket_id, ticket_message.id) + + +async def setup(bot: commands.Bot): + """Setup function for the listeners cog. - ticket_message = await self.modmail_channel.send(embed=ticket_embed.channel_embed(self.guild, ticket['ticket_id'])) - await message.add_reaction('📨') + Args: + bot (commands.Bot): The bot. + """ + try: + modmail_channel = await bot.fetch_channel(modmail_config.channel) + except Exception as e: + raise ValueError( + "The channel specified in config was not found. Please check your config." + ) from e - db.update_ticket_message(ticket['ticket_id'], ticket_message.id) - await post_reactions(ticket_message) + if not isinstance(modmail_channel, discord.TextChannel): + raise TypeError("The channel specified in config was not a text channel.") -def setup(bot): - bot.add_cog(Listeners(bot)) \ No newline at end of file + await bot.add_cog(Listeners(bot, modmail_channel)) diff --git a/config.example.json b/config.example.json index 6563df7..7b7afab 100644 --- a/config.example.json +++ b/config.example.json @@ -1,7 +1,9 @@ { - "token": "", - "guild": 0, - "channel": 0, - "prefix": "", - "status": "" + "token": "", + "application_id": 0, + "guild": 0, + "channel": 0, + "prefix": "", + "status": "", + "id_prefix": "" } diff --git a/db.py b/db.py index cdc4fda..5694438 100644 --- a/db.py +++ b/db.py @@ -1,135 +1,193 @@ -import sqlite3 - -def database(func): - def wrapper(*args,**kwargs): - conn = sqlite3.connect('/database/modmail.db') - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - ret = func(cursor, *args, *kwargs) - conn.commit() - conn.close() - return ret +from contextlib import asynccontextmanager +from dataclasses import dataclass +import functools +from typing import Awaitable, Callable, Concatenate, Optional, ParamSpec, TypeVar +from aiosqlite import connect, Row, Cursor + + +# For development/local testing, use "modmail.db" +# For production and working with Docker, use "/database/modmail.db" +PATH = "/database/modmail.db" + +P = ParamSpec("P") +R = TypeVar("R") + + +@asynccontextmanager +async def db_ops(): + conn = await connect(PATH) + conn.row_factory = Row + cursor = await conn.cursor() + yield cursor + await conn.commit() + await conn.close() + + +def async_db_cursor( + func: Callable[Concatenate[Cursor, P], Awaitable[R]] +) -> Callable[P, Awaitable[R]]: + @functools.wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + async with db_ops() as cursor: + return await func(cursor, *args, **kwargs) + return wrapper -@database -def get_ticket(cursor, ticket_id): + +@dataclass +class Ticket: + ticket_id: int + user: int + open: int + message_id: Optional[int] + + +@dataclass +class TicketResponse: + user: int + response: str + timestamp: int + as_server: bool + + +@dataclass +class Timeout: + timeout_id: int + timestamp: int + + +@async_db_cursor +async def get_ticket(cursor: Cursor, ticket_id: int) -> Optional[Ticket]: sql = """ SELECT ticket_id, user, open, message_id FROM mm_tickets WHERE ticket_id=? """ - cursor.execute(sql, [ticket_id]) - ticket = cursor.fetchone() + await cursor.execute(sql, [ticket_id]) + ticket = await cursor.fetchone() if ticket is None or len(ticket) == 0: - return -1 + return None else: - return ticket + return Ticket(*ticket) -@database -def get_ticket_by_user(cursor, user): + +@async_db_cursor +async def get_ticket_by_user(cursor: Cursor, user: int) -> Optional[Ticket]: sql = """ SELECT ticket_id, user, open, message_id FROM mm_tickets WHERE user=? AND open=1 """ - cursor.execute(sql, [user]) - ticket = cursor.fetchone() + await cursor.execute(sql, [user]) + ticket = await cursor.fetchone() if ticket is None or len(ticket) == 0: - return {'ticket_id': -1, 'user': -1, 'open':0, 'message_id':-1} + return None else: - return ticket + return Ticket(*ticket) + -@database -def get_ticket_by_message(cursor, message_id): +@async_db_cursor +async def get_ticket_by_message(cursor: Cursor, message_id: int) -> Optional[Ticket]: sql = """ SELECT ticket_id, user, open, message_id FROM mm_tickets WHERE message_id=? """ - cursor.execute(sql, [message_id]) - ticket = cursor.fetchone() + await cursor.execute(sql, [message_id]) + ticket = await cursor.fetchone() if ticket is None or len(ticket) == 0: - return {'ticket_id': -1, 'user': -1, 'open':0, 'message_id':-1} + return None else: - return ticket + return Ticket(*ticket) -@database -def open_ticket(cursor, user): + +@async_db_cursor +async def open_ticket(cursor: Cursor, user: int) -> Optional[int]: sql = """ INSERT INTO mm_tickets (user) VALUES (?) """ - cursor.execute(sql, [user]) + await cursor.execute(sql, [user]) return cursor.lastrowid -@database -def update_ticket_message(cursor, ticket_id, message_id): + +@async_db_cursor +async def update_ticket_message( + cursor: Cursor, ticket_id: int, message_id: int +) -> bool: sql = """ UPDATE mm_tickets SET message_id=? WHERE ticket_id=? """ - cursor.execute(sql, [message_id, ticket_id]) + await cursor.execute(sql, [message_id, ticket_id]) return cursor.rowcount != 0 -@database -def close_ticket(cursor, ticket_id): + +@async_db_cursor +async def close_ticket(cursor: Cursor, ticket_id: int) -> bool: sql = """ UPDATE mm_tickets SET open=0 WHERE ticket_id=? """ - cursor.execute(sql,[ticket_id]) - + await cursor.execute(sql, [ticket_id]) return cursor.rowcount != 0 -@database -def get_ticket_responses(cursor, ticket_id): + +@async_db_cursor +async def get_ticket_responses(cursor: Cursor, ticket_id: int) -> list[TicketResponse]: sql = """ SELECT user, response, timestamp, as_server FROM mm_ticket_responses WHERE ticket_id=? """ - cursor.execute(sql, [ticket_id]) - return cursor.fetchall() + await cursor.execute(sql, [ticket_id]) + rows = await cursor.fetchall() + return [TicketResponse(*row) for row in rows] + -@database -def add_ticket_response(cursor, ticket_id, user, response, as_server): +@async_db_cursor +async def add_ticket_response( + cursor: Cursor, ticket_id: int, user: int, response: str, as_server: bool +) -> Optional[int]: sql = """ INSERT INTO mm_ticket_responses (ticket_id, user, response, as_server) VALUES (?, ?, ?, ?) """ - cursor.execute(sql, [ticket_id, user, response, as_server]) - return True + await cursor.execute(sql, [ticket_id, user, response, as_server]) + return cursor.lastrowid -@database -def get_timeout(cursor, user): + +@async_db_cursor +async def get_timeout(cursor: Cursor, user: int) -> Optional[Timeout]: sql = """ - SELECT timestamp + SELECT timeout_id, timestamp FROM mm_timeouts WHERE user=? """ - cursor.execute(sql , [user]) - timeout = cursor.fetchone() + await cursor.execute(sql, [user]) + timeout = await cursor.fetchone() if timeout is None or len(timeout) == 0: - return False + return None else: - return timeout + return Timeout(*timeout) + -@database -def set_timeout(cursor, user, timestamp): +@async_db_cursor +async def set_timeout(cursor: Cursor, user: int, timestamp: int) -> Optional[int]: sql = """ INSERT OR REPLACE INTO mm_timeouts (user, timestamp) VALUES (?, ?) """ - cursor.execute(sql, [user, timestamp]) - return True + await cursor.execute(sql, [user, timestamp]) + return cursor.lastrowid -@database -def init(cursor): - #Create modmail tickets table +@async_db_cursor +async def init(cursor: Cursor): + # Create modmail tickets table sql = """ CREATE TABLE IF NOT EXISTS mm_tickets ( ticket_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -138,17 +196,17 @@ def init(cursor): message_id INTEGER ); """ - result = cursor.execute(sql) + await cursor.execute(sql) - #Create modmail ticket user index + # Create modmail ticket user index sql = "CREATE INDEX IF NOT EXISTS mm_tickets_user ON mm_tickets(user);" - result = cursor.execute(sql) + await cursor.execute(sql) - #Create modmail ticket message index + # Create modmail ticket message index sql = "CREATE INDEX IF NOT EXISTS mm_tickets_message ON mm_tickets(message_id);" - result = cursor.execute(sql) + await cursor.execute(sql) - #Create modmail ticket repsonses table + # Create modmail ticket repsonses table sql = """ CREATE TABLE IF NOT EXISTS mm_ticket_responses ( response_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -160,17 +218,17 @@ def init(cursor): FOREIGN KEY (ticket_id) REFERENCES mm_tickets (ticket_id) ); """ - result = cursor.execute(sql) + await cursor.execute(sql) - #Create modmail ticket response ticket id index + # Create modmail ticket response ticket id index sql = "CREATE INDEX IF NOT EXISTS mm_ticket_responses_ticket_id ON mm_ticket_responses(ticket_id);" - result = cursor.execute(sql) + await cursor.execute(sql) - #Create modmail ticket response user index + # Create modmail ticket response user index sql = "CREATE INDEX IF NOT EXISTS mm_ticket_responses_user ON mm_ticket_responses(user);" - result = cursor.execute(sql) + await cursor.execute(sql) - #Create modmail timeouts table + # Create modmail timeouts table sql = """ CREATE TABLE IF NOT EXISTS mm_timeouts ( timeout_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -178,10 +236,10 @@ def init(cursor): timestamp TIMESTAMP DEFAULT (strftime('%s', 'now')) NOT NULL ); """ - result = cursor.execute(sql) + await cursor.execute(sql) - #Create modmail timeout user index + # Create modmail timeout user index sql = "CREATE UNIQUE INDEX IF NOT EXISTS mm_timeouts_user ON mm_timeouts(user);" - result = cursor.execute(sql) + await cursor.execute(sql) - return True \ No newline at end of file + return True diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..018a9bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.8" + +services: + modmail: + image: ghcr.io/ib-ai/modmail.py:latest + env_file: modmail.env + restart: on-failure + volumes: + - mm-data:/database + +volumes: + mm-data: diff --git a/modmail.py b/modmail.py index cc77bbd..4838ca0 100644 --- a/modmail.py +++ b/modmail.py @@ -1,154 +1,74 @@ -import os - -import discord, json +import discord from discord.ext import commands -import db, utils.embed_reactions as embed_reactions -from cogs.command_actions import CommandActions -from cogs.listeners import Listeners - -with open('./config.json', 'r') as config_json: - config = json.load(config_json) - - # Load from environment variable overrides - if "MODMAIL_TOKEN" in os.environ: - config["token"] = os.getenv("MODMAIL_TOKEN") - if "MODMAIL_GUILD" in os.environ: - config["guild"] = int(os.getenv("MODMAIL_GUILD")) - if "MODMAIL_CHANNEL" in os.environ: - config["channel"] = int(os.getenv("MODMAIL_CHANNEL")) - if "MODMAIL_PREFIX" in os.environ: - config["prefix"] = os.getenv("MODMAIL_PREFIX") - if "MODMAIL_STATUS" in os.environ: - config["status"] = os.getenv("MODMAIL_STATUS") +import db +from utils.config import Config +from utils.ticket_embed import MessageButtonsView + +import logging + +logger = logging.getLogger("bot") +logger.setLevel(logging.INFO) + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", +) intents = discord.Intents.default() intents.members = True -bot = commands.Bot(intents=intents, command_prefix=config['prefix']) -guild = None -modmail_channel = None - -def bot_ready(func): - def wrapper(*args,**kwargs): - if guild is None or modmail_channel is None: - print('Bot not ready yet') - return - - ret = func(cursor, *args, *kwargs) - return ret - return wrapper - -@bot_ready -@bot.event -async def on_raw_reaction_add(payload): - """Handles reactions to messages. - - Args: - payload (discord.RawReactionActionEvent): Payload for raw reaction methods. - """ - # Ignore if self - if payload.user_id == bot.user.id: - return - - # Ignore if not in guild - if not payload.guild_id or not payload.guild_id == config['guild']: - return - - # Ignore if not in modmail channel - if not payload.channel_id == config['channel']: - return - - # Ignore if not unicode emoji - if not payload.emoji.is_unicode_emoji(): - return - - # Get member object - reaction_user = payload.member - - # Ignore if bot - if reaction_user.bot: - return - - # Get unicode emoji - emoji = payload.emoji.name - - # Get message object - message = await modmail_channel.fetch_message(payload.message_id) - - await handle_reaction(emoji, message, reaction_user) - -async def handle_reaction(emoji, message, reaction_user): - """Handles reactions for ModMail embeds. - - Args: - emoji (discord.PartialEmoji): The emoji being used. - message (discord.Message): The current message. - reaction_user (discord.Member): The user who triggered the reaction event. - """ - ticket = db.get_ticket_by_message(message.id) - if ticket['ticket_id'] == -1: - return - - await message.remove_reaction(emoji, reaction_user) - - embed_actions = embed_reactions.embed_reactions(bot, guild, modmail_channel, reaction_user, ticket) - - if str(emoji) == '🗣️': - await embed_actions.message_reply() - elif str(emoji) == '❎': - await embed_actions.message_close() - elif str(emoji) == '⏲️': - ticket_user = await bot.fetch_user(ticket['user']) - await embed_actions.message_timeout(ticket_user) - -@bot.event -async def on_ready(): - global guild, modmail_channel - - guild = bot.get_guild(config['guild']) - - if guild is None: - print('Failed to find Guild from provided ID.') - await bot.close() - return - - modmail_channel = guild.get_channel(config['channel']) - - if modmail_channel is None: - print('Failed to find Modmail Channel from provided ID.') - await bot.close() - return - - await bot.change_presence(activity=discord.Game(name=config['status']), status=discord.Status.online) - - bot.add_cog(Listeners(bot, guild, modmail_channel)) - bot.add_cog(CommandActions(bot, modmail_channel)) - -def ready(): - if db.init(): - print('Database sucessfully initialized!') - else: - print('Error while initializing database!') - return False - - if "guild" not in config: - print('No Guild ID provided.') - return False - - if "channel" not in config: - print('No Channel ID provided.') - return False - - if "prefix" not in config: - print('Failed to find prefix in config.') - return False - - return True - -success = ready() - -if success: - bot.run(config['token']) +intents.message_content = True + +member_cache = discord.MemberCacheFlags() + +modmail_config = Config() + +INITIAL_COGS = ["commands", "listeners"] + + +class Modmail(commands.Bot): + def __init__(self): + super().__init__( + intents=intents, + command_prefix=modmail_config.prefix, + description=modmail_config.status, + application_id=modmail_config.application_id, + member_cache_flags=member_cache, + ) + + async def setup_hook(self): + await db.init() + logger.info("Database sucessfully initialized!") + + for cog in INITIAL_COGS: + try: + await bot.load_extension(f"cogs.{cog}") + logger.debug(f"Imported cog '{cog}'.") + except ( + commands.errors.NoEntryPointError, + commands.errors.ExtensionNotFound, + commands.errors.ExtensionFailed, + ) as e: + logger.fatal(f"Failed to import cog '{cog}'.") + raise SystemExit(e) + + logger.info("Loaded all cogs.") + + self.add_view(MessageButtonsView(bot, [])) + logger.info("Added all views.") + + async def on_ready(self): + await bot.change_presence( + activity=discord.Game(name=modmail_config.status), + status=discord.Status.online, + ) + + logger.info(f"Bot '{bot.user.name}' is now connected.") + + async def on_command_error(self, ctx: commands.Context, exception) -> None: + await super().on_command_error(ctx, exception) + await ctx.send(exception) + -else: - print('Error during starting process') +bot = Modmail() +bot.run(modmail_config.token.get_secret_value()) diff --git a/playbook.yml b/playbook.yml new file mode 100644 index 0000000..cde46c1 --- /dev/null +++ b/playbook.yml @@ -0,0 +1,18 @@ +--- +- hosts: all + become: true + tasks: + - name: Stop and Remove Existing Containers + community.docker.docker_compose: + project_src: /ibobots/modmail + state: absent + + - name: Pull Latest Docker Image + community.docker.docker_compose: + project_src: /ibobots/modmail + pull: true + + - name: Deploy Docker Image + community.docker.docker_compose: + project_src: /ibobots/modmail + build: false diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8fb8be0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "modmail.py" +version = "2.0.0" +readme = "README.md" +description = "A Discord bot for managing modmail written in Python." diff --git a/requirements.txt b/requirements.txt index 503dba9..18efe52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,19 @@ -discord.py \ No newline at end of file +aiohttp==3.8.4 +aiosignal==1.3.1 +aiosqlite==0.19.0 +annotated-types==0.5.0 +async-timeout==4.0.2 +attrs==23.1.0 +charset-normalizer==3.1.0 +confz==2.0.1 +discord.py==2.3.1 +frozenlist==1.3.3 +idna==3.4 +multidict==6.0.4 +pydantic==2.3.0 +pydantic_core==2.6.3 +python-dotenv==1.0.0 +PyYAML==6.0.1 +toml==0.10.2 +typing_extensions==4.7.1 +yarl==1.9.2 diff --git a/utils/actions.py b/utils/actions.py new file mode 100644 index 0000000..4e88df5 --- /dev/null +++ b/utils/actions.py @@ -0,0 +1,301 @@ +import asyncio +import datetime +from typing import Optional + +import discord +from discord.ext import commands + +import db +from utils import ticket_embed, uformatter + +import logging + +logger = logging.getLogger(__name__) + + +async def waiter( + bot: commands.Bot, interaction: discord.Interaction +) -> Optional[discord.Message]: + """ + Waits for a message from the user who initiated the interaction. + + Args: + bot (commands.Bot): The bot object. + interaction (discord.Interaction): The interaction object. + + Returns: + Optional[discord.Message]: The message sent by the user. + """ + + def check(message: discord.Message) -> bool: + return ( + message.author == interaction.user + and message.channel == interaction.channel + ) + + try: + message = await bot.wait_for("message", check=check) + except asyncio.TimeoutError: + return None + + return message + + +async def message_open( + bot: commands.Bot, interaction: discord.Interaction, member: discord.Member +): + """ + Sends message embed and opens a ticket for the specified user, if not open already. + + Args: + bot (commands.Bot): The bot object. + interaction (discord.Interaction): The interaction object. + member (discord.Member): The member to open a ticket for. + """ + ticket = await db.get_ticket_by_user(member.id) + + if ticket: + await interaction.response.send_message( + f"There is already a ticket open for {member.name}.", ephemeral=True + ) + return + + ticket_id = await db.open_ticket(member.id) + ticket = await db.get_ticket(ticket_id) + + embeds = await ticket_embed.channel_embed(interaction.guild, ticket) + + message_embed, buttons_view = await ticket_embed.MessageButtonsView( + bot, embeds + ).return_paginated_embed() + await interaction.response.send_message(embed=message_embed, view=buttons_view) + + ticket_message = await interaction.original_response() + logger.debug(f"Ticket message: {ticket_message}") + await db.update_ticket_message(ticket.ticket_id, ticket_message.id) + + +async def message_refresh( + bot: commands.Bot, interaction: discord.Interaction, member: discord.Member +): + """ + Resends message embed for the specified user if open. + + Args: + bot (commands.Bot): The bot object. + interaction (discord.Interaction): The interaction object. + member (discord.Member): The member to refresh the ticket for. + """ + ticket = await db.get_ticket_by_user(member.id) + + if not ticket: + await interaction.response.send_message( + f"There is no ticket open for {member.name}.", ephemeral=True + ) + return + + embeds = await ticket_embed.channel_embed(interaction.guild, ticket) + + message_embed, buttons_view = await ticket_embed.MessageButtonsView( + bot, embeds + ).return_paginated_embed() + await interaction.response.send_message(embed=message_embed, view=buttons_view) + + message = await interaction.original_response() + await db.update_ticket_message(ticket.ticket_id, message.id) + + if ticket.message_id is not None: + try: + old_ticket_message = await interaction.channel.fetch_message( + ticket.message_id + ) + await old_ticket_message.delete() + except discord.errors.NotFound: + # Pass if original ticket message has been deleted already + pass + + +async def message_close( + interaction: discord.Interaction, ticket: db.Ticket, user: discord.Member +): + """ + Sends close confirmation embed, and if confirmed, will close the ticket. + + Args: + interaction (discord.Interaction): The interaction object. + ticket (db.Ticket): The ticket object. + user (discord.Member): The member to close the ticket for. + """ + + close_embed, confirmation_view = ticket_embed.close_confirmation(user) + + await interaction.response.send_message(embed=close_embed, view=confirmation_view) + confirmation_view.message = await interaction.original_response() + + await confirmation_view.wait() + + if not confirmation_view.value: + return + elif confirmation_view.value: + await db.close_ticket(ticket.ticket_id) + ticket_message = await interaction.channel.fetch_message(ticket.message_id) + await ticket_message.delete() + + await interaction.channel.send( + embed=ticket_embed.closed_ticket(interaction.user, user) + ) + logger.info(f"Ticket for user {user.id} closed by {interaction.user.id}") + + +async def message_reply( + bot: commands.Bot, interaction: discord.Interaction, ticket: db.Ticket +): + """ + Adds staff reply to ticket and updates original embed. + + Args: + bot (commands.Bot): The bot object. + interaction (discord.Interaction): The interaction object. + ticket (db.Ticket): The ticket object. + """ + + ticket_user = interaction.guild.get_member(ticket.user) + + if not ticket_user: + await interaction.response.send_message( + "Cannot reply to ticket as user is not in the server." + ) + return + + task = bot.loop.create_task(waiter(bot, interaction)) + + reply_embed, cancel_view = ticket_embed.reply_cancel(ticket_user, task) + await interaction.response.send_message(embed=reply_embed, view=cancel_view) + cancel_view.message = await interaction.original_response() + + await task + await cancel_view.view_cleanup() + + try: + message = task.result() + + if not message: + return + + response = uformatter.format_message(message) + + if not response.strip(): + return + + # ! Fix for longer messages + if len(response) > 1000: + await interaction.channel.send( + "Your message is too long. Please shorten your message or send in multiple parts." + ) + return + + try: + await ticket_user.send( + embed=ticket_embed.user_embed(interaction.guild, response) + ) + await db.add_ticket_response( + ticket.ticket_id, interaction.user.id, response, True + ) + ticket_message = await interaction.channel.fetch_message(ticket.message_id) + + embeds = await ticket_embed.channel_embed(interaction.guild, ticket) + + channel_embed, buttons_view = await ticket_embed.MessageButtonsView( + bot, embeds + ).return_paginated_embed() + + await ticket_message.edit(embed=channel_embed, view=buttons_view) + except discord.errors.Forbidden: + await interaction.channel.send( + "Could not send ModMail message to specified user due to privacy settings." + ) + + except Exception as e: + raise RuntimeError(e) + except asyncio.CancelledError: + return + + +async def message_timeout(interaction: discord.Interaction, member: discord.Member): + """Sends timeout confirmation embed, and if confirmed, will timeout the specified ticket user. + + Args: + interaction (discord.Interaction): The interaction object. + member (discord.Member): The member to timeout. + """ + + timeout_embed, confirmation_view = ticket_embed.timeout_confirmation(member) + + await interaction.response.send_message(embed=timeout_embed, view=confirmation_view) + confirmation_view.message = await interaction.original_response() + + await confirmation_view.wait() + + if confirmation_view.value is None: + return + elif confirmation_view.value: + timeout = datetime.datetime.now() + datetime.timedelta(days=1) + timestamp = int(timeout.timestamp()) + await db.set_timeout(member.id, timestamp) + logger.info(f"User {member.id} timed out by {interaction.user.id}") + + await interaction.channel.send( + f"{member.name} has been successfully timed out for 24 hours. They will be able to message ModMail again after ." + ) + + try: + await member.send(embed=ticket_embed.user_timeout(timestamp)) + except discord.errors.Forbidden: + await interaction.channel.send( + "Could not send timeout message to specified user due to privacy settings." + ) + + +async def message_untimeout(interaction: discord.Interaction, member: discord.Member): + """ + Sends untimeout confirmation embed, and if confirmed, will remove the timeout for the specified ticket user. + + Args: + interaction (discord.Interaction): The interaction object. + member (discord.Member): The member to remove the timeout for. + """ + timeout = await db.get_timeout(member.id) + current_time = int(datetime.datetime.now().timestamp()) + + if not timeout or (current_time > timeout.timestamp): + await interaction.response.send_message( + f"{member.name} is not currently timed out.", ephemeral=True + ) + return + + untimeout_embed, confirmation_view = ticket_embed.untimeout_confirmation( + member, timeout.timestamp + ) + + await interaction.response.send_message( + embed=untimeout_embed, view=confirmation_view + ) + confirmation_view.message = await interaction.original_response() + + await confirmation_view.wait() + + if confirmation_view.value is None: + return + elif confirmation_view.value: + timestamp = int(datetime.datetime.now().timestamp()) + await db.set_timeout(member.id, timestamp) + logger.info(f"Timeout removed for {member.id}.") + + await interaction.channel.send(f"Timeout has been removed for {member.name}.") + + try: + await member.send(embed=ticket_embed.user_untimeout()) + except discord.errors.Forbidden: + await interaction.channel.send( + "Could not send untimeout message to specified user due to privacy settings." + ) diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..6934d59 --- /dev/null +++ b/utils/config.py @@ -0,0 +1,20 @@ +from pathlib import Path +from confz import BaseConfig, EnvSource, FileFormat, FileSource +from pydantic import SecretStr + +_path = Path(__file__).parent / "../config.json" + + +class Config(BaseConfig): + token: SecretStr + application_id: int + guild: int + channel: int + prefix: str + status: str + id_prefix: str + + CONFIG_SOURCES = [ + FileSource(_path, format=FileFormat.JSON, optional=True), + EnvSource(prefix="MODMAIL_", allow_all=True), + ] diff --git a/utils/embed_reactions.py b/utils/embed_reactions.py deleted file mode 100644 index 5da2ad1..0000000 --- a/utils/embed_reactions.py +++ /dev/null @@ -1,143 +0,0 @@ -import asyncio, datetime -import discord -import db, utils.uformatter as uformatter, utils.ticket_embed as ticket_embed - -class embed_reactions(): - """Class to contain channel embed reaction methods.""" - - def __init__(self, bot, guild, modmail_channel, reaction_user, ticket=None): - """Constructs necessary attributes for all embed reaction methods. - - Args: - bot (discord.Bot): The bot object. - guild (discord.Guild): The current guild. - modmail_channel (discord.Channel): The ModMail guild channel. - reaction_user (discord.User): The user who triggered the reaction. - ticket (DB Object, optional): Object containing values for a specific ticket. Defaults to None. - """ - - self.bot = bot - self.guild = guild - self.modmail_channel = modmail_channel - self.reaction_user = reaction_user - self.ticket = ticket - - async def message_reply(self): - """Sends reply embed, and if confirmed, will add the staff message to the ticket embeds.""" - - ticket_user = self.guild.get_member(self.ticket['user']) - - if not ticket_user: - await self.modmail_channel.send("Cannot reply to ticket as user is not in the server.") - return - - cancel = await self.modmail_channel.send(embed=ticket_embed.reply_cancel(ticket_user)) - await cancel.add_reaction('❎') - - def reply_cancel(reaction, user): - return user == self.reaction_user and cancel == reaction.message and str(reaction.emoji) == '❎' - def reply_message(message): - return message.author == self.reaction_user and message.channel == self.modmail_channel - - try: - tasks = [ - asyncio.create_task(self.bot.wait_for('reaction_add', timeout=60.0, check=reply_cancel), name='cancel'), - asyncio.create_task(self.bot.wait_for('message', timeout=60.0,check=reply_message), name='respond') - ] - - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - - event: asyncio.Task = list(done)[0] - - for task in pending: - try: - task.cancel() - except asyncio.CancelledError: - pass - - - if event.get_name() == 'respond': - message = event.result() - response = uformatter.format_message(message) - - if not response.strip(): - return - - # ! Fix for longer messages - if len(response) > 1000: - await message.channel.send('Your message is too long. Please shorten your message or send in multiple parts.') - return - # Removed - can add as config option later - # await message.delete() - await ticket_user.send(embed=ticket_embed.user_embed(self.guild, response)) - db.add_ticket_response(self.ticket['ticket_id'], self.reaction_user.id, response, True) - ticket_message = await self.modmail_channel.fetch_message(self.ticket['message_id']) - await ticket_message.edit(embed=ticket_embed.channel_embed(self.guild, self.ticket['ticket_id'])) - - except asyncio.TimeoutError: - pass - except discord.errors.Forbidden: - await self.modmail_channel.send("Could not send ModMail message to specified user due to privacy settings.") - except Exception as e: - raise RuntimeError(e) - - await cancel.delete() - - async def message_close(self): - """Sends close confirmation embed, and if confirmed, will close the ticket.""" - - ticket_user = await self.bot.fetch_user(self.ticket['user']) - - if not ticket_user: - ticket_user = "user" - - confirmation = await self.modmail_channel.send(embed=ticket_embed.close_confirmation(ticket_user)) - await confirmation.add_reaction('✅') - await confirmation.add_reaction('❎') - - def close_check(reaction, user): - return user == self.reaction_user and confirmation == reaction.message and (str(reaction.emoji) == '✅' or str(reaction.emoji) == '❎') - - try: - reaction, user = await self.bot.wait_for('reaction_add', timeout=60.0, check=close_check) - if str(reaction.emoji) == '✅': - db.close_ticket(self.ticket['ticket_id']) - ticket_message = await self.modmail_channel.fetch_message(self.ticket['message_id']) - await ticket_message.delete() - await self.modmail_channel.send(embed=ticket_embed.closed_ticket(self.reaction_user, ticket_user)) - except asyncio.TimeoutError: - pass - - await confirmation.delete() - - async def message_timeout(self, ticket_user): - """Sends timeout confirmation embed, and if confirmed, will timeout the specified ticket user. - - Args: - ticket_user (discord.User): The ticket user. - """ - - confirmation = await self.modmail_channel.send(embed=ticket_embed.timeout_confirmation(ticket_user)) - await confirmation.add_reaction('✅') - await confirmation.add_reaction('❎') - - def timeout_check(reaction, user): - return user == self.reaction_user and confirmation == reaction.message and (str(reaction.emoji) == '✅' or str(reaction.emoji) == '❎') - - try: - reaction, user = await self.bot.wait_for('reaction_add', timeout=60.0, check=timeout_check) - if str(reaction.emoji) == '✅': - # Change below value to custom - timeout = datetime.datetime.now() + datetime.timedelta(days=1) - timestamp = int(timeout.timestamp()) - db.set_timeout(ticket_user.id, timestamp) - await self.modmail_channel.send('{0} has been successfully timed out for 24 hours. They will be able to message ModMail again after .'.format(ticket_user, timestamp)) - await ticket_user.send(embed=ticket_embed.user_timeout(timestamp)) - except asyncio.TimeoutError: - pass - except discord.errors.Forbidden: - pass - except Exception as e: - raise RuntimeError(e) - - await confirmation.delete() diff --git a/utils/pagination.py b/utils/pagination.py new file mode 100644 index 0000000..c12a184 --- /dev/null +++ b/utils/pagination.py @@ -0,0 +1,77 @@ +from typing import Union, Optional +from collections.abc import Collection + +import discord + +NAME_SIZE_LIMIT = 256 +VALUE_SIZE_LIMIT = 1024 + + +def paginated_embed_menus( + names: Collection[str], + values: Collection[str], + pagesize: int = 10, + *, + inline: Union[Collection[bool], bool] = False, + embed_dict: Optional[dict] = None, +) -> Collection[discord.Embed]: + """ + Generates embeds for a paginated embed view. + + Args: + names (Collection[str]): Names of fields to be added/paginated. + values (Collection[str]): Values of fields to be added/paginated. + pagesize (int, optional): Maximum number of items per page. Defaults to 10. + inline (Union[Collection[bool], bool], optional): Whether embed fields should be inline or not. Defaults to False. + embed_dict (Optional[dict], optional): Partial embed dictionary (for setting a title, description, etc.). Footer and fields must not be set. Defaults to None. + + Returns: + Collection[discord.Embed]: Collection of embeds for paginated embed view. + """ + N = len(names) + if N != len(values): + raise ValueError( + "names and values for paginated embed menus must be of equal length." + ) + if isinstance(inline, bool): + inline = [inline] * N + elif N != len(inline): + raise ValueError( + "'inline' must be boolean or a collection of booleans of equal length to names/values for paginated embed menus." + ) + + if embed_dict: + if "title" in embed_dict and len(embed_dict["title"]) > 256: + raise ValueError("title cannot be over 256 characters") + if "description" in embed_dict and len(embed_dict["description"]) > 4096: + raise ValueError("desription cannot be over 4096 characters") + if "footer" in embed_dict: + raise ValueError("embed_dict 'footer' key must not be set.") + if "fields" in embed_dict: + raise ValueError("embed_dict 'fields' key must not be set.") + else: + embed_dict = {"description": "Here is a list of entries."} # default + + if N == 0: + return [discord.Embed.from_dict(embed_dict)] + + embeds: Collection[discord.Embed] = [] + current: discord.Embed = discord.Embed.from_dict(embed_dict) + pages = 1 + items = 0 + for name, value, inline_field in zip(names, values, inline): + if ( + items == pagesize or len(current) + len(name) + len(value) > 5090 + ): # leave 10 chars for footers + embeds.append(current) + current = discord.Embed.from_dict(embed_dict) + pages += 1 + items = 0 + + current.add_field(name=name, value=value, inline=inline_field) + items += 1 + embeds.append(current) + for page, embed in enumerate(embeds): + embed.set_footer(text=f"Page {page+1}/{pages}") + + return embeds diff --git a/utils/ticket_embed.py b/utils/ticket_embed.py index 78d3541..53265a1 100644 --- a/utils/ticket_embed.py +++ b/utils/ticket_embed.py @@ -1,7 +1,188 @@ +import asyncio +from typing import Collection, Optional, Union + import discord -import db +from discord.ext import commands +from discord.utils import format_dt -def user_embed(guild, message): +import db +from utils import actions +from utils.config import Config +from utils.pagination import paginated_embed_menus + +import logging + +logger = logging.getLogger(__name__) + +modmail_config = Config() + + +class ConfirmationView(discord.ui.View): + """Confirmation view for yes/no operations.""" + + def __init__(self, message: Optional[discord.Message] = None, timeout: int = 60): + super().__init__(timeout=timeout) + self.message = message + self.value = None + + async def on_timeout(self) -> None: + await self.message.delete() + await super().on_timeout() + + @discord.ui.button(label="Yes", style=discord.ButtonStyle.green) + async def yes(self, interaction: discord.Interaction, button: discord.ui.Button): + self.value = True + await self.message.delete() + self.stop() + + @discord.ui.button(label="No", style=discord.ButtonStyle.red) + async def no(self, interaction: discord.Interaction, button: discord.ui.Button): + self.value = False + await self.message.delete() + self.stop() + + +class CancelView(discord.ui.View): + """Cancel view for cancelling operations if requested by the user.""" + + def __init__( + self, + task: asyncio.Task, + message: Optional[discord.Message] = None, + timeout: int = 60, + ) -> None: + super().__init__(timeout=timeout) + self.message = message + self.task = task + + async def view_cleanup(self) -> None: + self.stop() + if self.message: + await self.message.delete() + + self.task.cancel() + + async def on_timeout(self) -> None: + await self.view_cleanup() + await super().on_timeout() + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.red) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.view_cleanup() + + +class MessageButtonsView(discord.ui.View): + """Message buttons view for ticket messages.""" + + def __init__(self, bot: commands.Bot, embeds: Collection[discord.Embed]): + super().__init__(timeout=None) + self.bot = bot + self.embeds = embeds + self.current_page = len(self.embeds) - 1 + + @discord.ui.button(emoji="💬", custom_id=f"{modmail_config.id_prefix}:reply") + async def mail_reply(self, interaction: discord.Interaction, _): + """ + Replies to the ticket. + """ + ticket = await db.get_ticket_by_message(interaction.message.id) + await actions.message_reply(self.bot, interaction, ticket) + + @discord.ui.button(emoji="❎", custom_id=f"{modmail_config.id_prefix}:close") + async def mail_close(self, interaction: discord.Interaction, _): + """ + Closes the ticket. + """ + ticket = await db.get_ticket_by_message(interaction.message.id) + member = interaction.guild.get_member( + ticket.user + ) or await interaction.guild.fetch_member(ticket.user) + await actions.message_close(interaction, ticket, member) + + @discord.ui.button(emoji="⏲️", custom_id=f"{modmail_config.id_prefix}:timeout") + async def mail_timeout(self, interaction: discord.Interaction, _): + """ + Times out the user of the ticket. + """ + ticket = await db.get_ticket_by_message(interaction.message.id) + member = interaction.guild.get_member( + ticket.user + ) or await interaction.guild.fetch_member(ticket.user) + await actions.message_timeout(interaction, member) + + @discord.ui.button( + emoji="⬅️", + style=discord.ButtonStyle.blurple, + custom_id=f"{modmail_config.id_prefix}:previous_page", + ) + async def previous_page(self, interaction: discord.Interaction, _): + """ + Goes to the previous page. + """ + if len(self.embeds) == 0: + await interaction.response.send_message( + "Please refresh this ticket to be able to use pagination.", + ephemeral=True, + ) + return + + if self.current_page > 0: + self.current_page -= 1 + self.update_pagination_buttons() + await self.update_view(interaction) + + @discord.ui.button( + emoji="➡️", + style=discord.ButtonStyle.blurple, + custom_id=f"{modmail_config.id_prefix}:next_page", + ) + async def next_page(self, interaction: discord.Interaction, _): + """ + Goes to the next page. + """ + if len(self.embeds) == 0: + await interaction.response.send_message( + "Please refresh this ticket to be able to use pagination.", + ephemeral=True, + ) + return + + if self.current_page < len(self.embeds) - 1: + self.current_page += 1 + self.update_pagination_buttons() + await self.update_view(interaction) + + def update_pagination_buttons(self): + """ + Updates the buttons based on the current page. + """ + for i in self.children: + i.disabled = False + if self.current_page == 0: + self.children[3].disabled = True + if self.current_page == len(self.embeds) - 1: + self.children[4].disabled = True + + async def update_view(self, interaction: discord.Interaction): + """ + Updates the embed and view. + """ + await interaction.response.edit_message( + embed=self.embeds[self.current_page], view=self + ) + + async def return_paginated_embed( + self, + ) -> tuple[discord.Embed, discord.ui.View | None]: + """ + Returns the first embed and containing view. + """ + self.update_pagination_buttons() # Disable buttons only one embed + + return self.embeds[self.current_page], self + + +def user_embed(guild: discord.Guild, message: str) -> discord.Embed: """Returns formatted embed for user DMs. Args: @@ -13,76 +194,94 @@ def user_embed(guild, message): """ message_embed = discord.Embed( - title="New Mail from {0}".format(guild.name), - description=message + title=f"New Mail from {guild.name}", description=message ) return message_embed -def channel_embed(guild, ticket_id): - """Returns formatted embed for channel. + +async def channel_embed( + guild: discord.Guild, ticket: db.Ticket +) -> Collection[discord.Embed]: + """Returns formatted embed for modmail channel. Args: guild (discord.Guild): The guild. - ticket_id (int): The ticket id. + ticket (db.Ticket): The ticket. Returns: - discord.Embed: Channel embed containing message and user content. + Collection[discord.Embed]: Collection of embeds for the ticket. """ - ticket = db.get_ticket(ticket_id) - - ticket_member = guild.get_member(ticket['user']) - - message_embed = discord.Embed( - title="ModMail Conversation for {0}".format(ticket_member), - description="User {0} has **{1}** roles\n Joined Discord: **{2}**\n Joined Server: **{3}**" - .format(ticket_member.mention, len(ticket_member.roles), ticket_member.created_at.strftime("%B %d %Y"), ticket_member.joined_at.strftime("%B %d %Y")) + ticket_member = guild.get_member(ticket.user) or await guild.fetch_member( + ticket.user ) - responses = db.get_ticket_responses(ticket_id) + responses = await db.get_ticket_responses(ticket.ticket_id) + + names = [] + values = [] for response in responses: - author = 'user' - if response['as_server']: - author = '{0} as server'.format(guild.get_member(response['user'])) - message_embed.add_field(name=", {1} wrote".format(response['timestamp'], author), value=response['response'], inline=False) + author = "user" + if response.as_server: + author = f"{guild.get_member(response.user)} as server" + names.append(f", {author} wrote") + values.append(response.response) + + embed_dict = { + "title": f"ModMail Conversation for {ticket_member.name}", + "description": f"User {ticket_member.mention} has **{len(ticket_member.roles) - 1}** roles\n Joined Discord: **{format_dt(ticket_member.created_at, 'D')}**\n Joined Server: **{format_dt(ticket_member.joined_at, 'D')}**", + } + + embeds = paginated_embed_menus(names, values, embed_dict=embed_dict) + + return embeds - return message_embed -def close_confirmation(member): +def close_confirmation(member: discord.Member) -> tuple[discord.Embed, discord.ui.View]: """Returns embed for ticket close confirmation. Args: member (discord.Member): The ticket user. Returns: - discord.Embed: Channel embed for close confirmation. + tuple[discord.Embed, discord.ui.View]: Tuple containing channel embed and view for close confirmation. """ + confirmation_view = ConfirmationView() + message_embed = discord.Embed( - description="Do you want to close the ModMail conversation for **{0}**?".format(member) + description=f"Do you want to close the ModMail conversation for **{member.name}**?" ) - return message_embed + return message_embed, confirmation_view -def timeout_confirmation(member): + +def timeout_confirmation( + member: discord.Member, +) -> tuple[discord.Embed, discord.ui.View]: """Returns embed for ticket timeout confirmation. Args: member (discord.Member): The ticket user. Returns: - discord.Embed: Channel embed for timeout confirmation. + tuple[discord.Embed, discord.ui.View]: Tuple containing channel embed and view for timeout confirmation. """ + confirmation_view = ConfirmationView() + message_embed = discord.Embed( - description="Do you want to timeout **{0}** for 24 hours?".format(member) + description=f"Do you want to timeout **{member.name}** for 24 hours?" ) - return message_embed + return message_embed, confirmation_view + -def untimeout_confirmation(member, timeout): +def untimeout_confirmation( + member: discord.Member, timeout: int +) -> tuple[discord.Embed, discord.ui.View]: """Returns embed for ticket untimeout confirmation. Args: @@ -90,36 +289,45 @@ def untimeout_confirmation(member, timeout): timeout (int): The timeout as Epoch milliseconds. Returns: - discord.Embed: Channel embed for untimeout confirmation. + tuple[discord.Embed, discord.ui.View]: Tuple containing channel embed and view for untimeout confirmation. """ + confirmation_view = ConfirmationView() message_embed = discord.Embed( - description="Do you want to untimeout **{0}** (they are currently timed out until )?".format(member, timeout) + description=f"Do you want to untimeout **{member.name}** (they are currently timed out until )?" ) - return message_embed + return message_embed, confirmation_view -def reply_cancel(member): + +def reply_cancel( + member: discord.Member, task: asyncio.Task +) -> tuple[discord.Embed, discord.ui.View]: """Returns embed for replying to ticket with cancel reaction. Args: member (discord.Member): The ticket user. + task (asyncio.Task): The task for the reply (e.g., waiting for user message). Returns: - discord.Embed: Channel embed for ticket reply. + tuple[discord.Embed, discord.ui.View]: Tuple containing channel embed and view for reply cancellation. """ + cancel_view = CancelView(task) message_embed = discord.Embed( - description="Replying to ModMail conversation for **{0}**".format(member) + description=f"Replying to ModMail conversation for **{member.name}**" ) - return message_embed + return message_embed, cancel_view -def closed_ticket(staff, member): + +def closed_ticket( + staff: Union[discord.User, discord.Member], member: discord.Member +) -> discord.Embed: """Returns embed for closed ticket. Args: - staff (discord.Member): The staff member who closed the ticket. + staff (Union[discord.User, discord.Member]): The staff member who closed the ticket. member (discord.Member): The ticket user. Returns: @@ -127,12 +335,13 @@ def closed_ticket(staff, member): """ message_embed = discord.Embed( - description="**{0}** closed the ModMail conversation for **{1}**".format(staff, member) + description=f"**{staff.name}** closed the ModMail conversation for **{member.name}**" ) return message_embed -def user_timeout(timeout): + +def user_timeout(timeout: int) -> discord.Embed: """Returns embed for user timeout in DMs. Args: @@ -143,20 +352,21 @@ def user_timeout(timeout): """ message_embed = discord.Embed( - description="You have been timed out. You will be able to message ModMail again after .".format(timeout) + description=f"You have been timed out. You will be able to message ModMail again after ()." ) return message_embed -def user_untimeout(): + +def user_untimeout() -> discord.Embed: """Returns embed for user untimeout in DMs. Returns: discord.Embed: Channel embed for user untimeout. """ - + message_embed = discord.Embed( - description="Your timeout has been removed. You can message ModMail again.".format() + description="Your timeout has been removed. You can message ModMail again." ) - return message_embed \ No newline at end of file + return message_embed diff --git a/utils/uformatter.py b/utils/uformatter.py index 0910d68..f097d5a 100644 --- a/utils/uformatter.py +++ b/utils/uformatter.py @@ -1,11 +1,24 @@ -def format_message(message): +import discord + + +def format_message(message: discord.Message) -> str: + """ + Formats message to include attachments as links. + + Args: + message (discord.Message): Message to format. + + Returns: + str: Formatted message. + """ attachments = message.attachments - formatted_message = '{0}'.format(message.content) + formatted_message = f"{message.content}".strip() for attachment in attachments: - type = 'Unknown' - if attachment.content_type.startswith('image'): - type = 'Image' - elif attachment.content_type.startswith('video'): - type = 'Video' - formatted_message += '\n[{0} Attachment]({1})'.format(type, attachment.url) - return formatted_message \ No newline at end of file + attachment_type = "Unknown" + if attachment.content_type.startswith("image"): + attachment_type = "Image" + elif attachment.content_type.startswith("video"): + attachment_type = "Video" + formatted_message += f"\n[{attachment_type} Attachment]({attachment.url})" + + return formatted_message diff --git a/utils/umember.py b/utils/umember.py deleted file mode 100644 index 0223733..0000000 --- a/utils/umember.py +++ /dev/null @@ -1,26 +0,0 @@ -import re - -ID_PATTERN = re.compile('^\\d{17,20}$') -MEMBER_MENTION_PATTERN = re.compile('^<@!?\\d{17,20}>$') -NAME_PATTERN = re.compile('^.{2,32}#[0-9]{4}$') - -def get_member(guild, input): - member = None - - if ID_PATTERN.match(input) or MEMBER_MENTION_PATTERN.match(input): - member = guild.get_member(int(re.sub('[^0-9]', '', input))) - elif NAME_PATTERN.match(input): - member = guild.get_member_named(input) - - if member is not None and not member.bot: - return member - else: - return None - -def assert_member(guild, argument): - member = get_member(guild, argument) - - if member is None: - raise RuntimeError("Please specify a valid user.") - - return member