diff --git a/bot/bot.py b/bot/bot.py index 74e0a6f..6d6f3a2 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -125,9 +125,9 @@ async def send_restart_message(self: commands.Bot): embed = simple_embed(message=f"Build version: [{commit_hash}]({github_repo_link}/commit/{commit_hash})", title="") embed.set_footer(text=commit_message) - await self.sys_log_channel.send( - embed=embed, - ) + # await self.sys_log_channel.send( + # embed=embed, + # ) except discord.Forbidden: pass diff --git a/bot/cogs/afk.py b/bot/cogs/afk.py index e9e233a..ee7edc7 100644 --- a/bot/cogs/afk.py +++ b/bot/cogs/afk.py @@ -14,7 +14,7 @@ from bot.bot import Bot class AFK(commands.Cog): - def __init__(self, bot: Bot) -> None: + def __init__(self, bot: "Bot") -> None: self.bot = bot self.manager = bot.afk_manager self.cleanup_expired.start() @@ -22,8 +22,8 @@ def __init__(self, bot: Bot) -> None: def cog_unload(self): self.cleanup_expired.cancel() - @app_commands.command(name="setafk", description="Set AFK status.") - async def setafk( + @app_commands.command(name="set_afk", description="Set AFK status.") + async def set_afk( self, interaction: discord.Interaction, hours: Optional[int] = None, diff --git a/bot/cogs/anti_raid.py b/bot/cogs/anti_raid.py index c872d10..e1af54c 100644 --- a/bot/cogs/anti_raid.py +++ b/bot/cogs/anti_raid.py @@ -145,6 +145,11 @@ async def handle_bot_trap_raid(self, member: discord.Member): reason=self.BOT_TRAP_BAN_REASON, delete_message_days=1, ) + await self.bot.progression_manager.set_ban_status( + user_id=member.id, + guild_id=guild.id, + status=True + ) except discord.Forbidden: return diff --git a/bot/cogs/button_utility.py b/bot/cogs/button_utility.py index b33de8d..39069e4 100644 --- a/bot/cogs/button_utility.py +++ b/bot/cogs/button_utility.py @@ -6,10 +6,132 @@ from bot.constants import ( challenger_role_id, accepting_team_invites_role_id, tortoise_guild_id, - join_a_team_channel_id, teams_dashboard_message_id + join_a_team_channel_id, teams_dashboard_message_id, server_link, bot_avatar_url ) from bot.utils.checks import tortoise_bot_developer_only -from bot.utils.embed_handler import info, failure +from bot.utils.embed_handler import info, failure, success + + +class TicketReasonSelect(discord.ui.Select): + """Dropdown menu for selecting the ticket/ban appeal reason.""" + + def __init__(self, cog: "TortoiseDM"): + options = [ + discord.SelectOption( + label="Accidentally Selected 'I am Bot' Option", + value="accidental_trap_victim", + description="I accidentally selected 'I am Bot' option while joining.", + emoji="πŸ€–" + ), + discord.SelectOption(label="Unfair Ban", value="unfair_ban", description="I feel my ban was unjust.", + emoji="βš–οΈ"), + discord.SelectOption(label="Apology / Second Chance", value="apology", + description="I admit my mistake and want to apologize.", emoji="πŸ™"), + discord.SelectOption(label="Compromised Account", value="compromised", + description="My account was hacked when the violation occurred.", emoji="πŸ›‘οΈ"), + discord.SelectOption(label="Other Reason", value="other", description="Any other reason not listed above.", + emoji="πŸ“"), + ] + super().__init__( + placeholder="Choose the reason for your appeal...", + min_values=1, + max_values=1, + options=options + ) + self.cog = cog + + async def callback(self, interaction: discord.Interaction): + user = interaction.user + reason = self.values[0] + + reason_mappings = { + "accidental_trap_victim": "Accidentally Selected 'I am Bot' Option", + "unfair_ban": "Unfair Ban Appeal", + "apology": "Apology / Second Chance Request", + "compromised": "Compromised Account Appeal", + "other": "Other / Unspecified Reason" + } + chosen_reason = reason_mappings.get(reason, "Unspecified Reason") + + self.disabled = True + + if reason == "accidental_trap_victim": + await interaction.response.edit_message(view=self.view) + + is_banned = await self.cog.bot.progression_manager.is_auto_banned(user_id=user.id, + guild_id=tortoise_guild_id) + + if is_banned: + guild = self.cog.bot.get_guild(tortoise_guild_id) + + try: + ban_entry = await guild.fetch_ban(discord.Object(id=user.id)) + target_user = ban_entry.user + + await guild.unban(target_user, reason="Auto unbanned via Honeypot Trap Appeal panel.") + await self.cog.bot.safe_send( + target_user, + content=server_link, + embed=info( + "You have been unbanned in Tortoise Community\n" + "Please use the invite link to rejoin the server\n", + self.cog.bot.user, + "Ban Lifted!", + "Welcome back to Tortoise Programming Community!", + ) + ) + + await self.cog.bot.progression_manager.set_ban_status(user_id=user.id, + guild_id=tortoise_guild_id, + status=False) + + await interaction.followup.send(f"βœ… Successfully unbanned. You may rejoin!", ephemeral=True) + + except discord.NotFound: + await interaction.followup.send("You are not currently recorded on the server ban list.", + ephemeral=True) + except discord.HTTPException: + await interaction.followup.send( + "❌ Something went wrong while attempting to unban. Try again later.", ephemeral=True) + else: + await interaction.followup.send( + embed=failure( + "Our records indicate you weren't banned by the automated bot trap.\nPlease select a different appeal reason."), + ephemeral=True + ) + + else: + await interaction.response.edit_message( + content="⏳ Processing your request and opening a ticket...", + view=self.view + ) + + try: + embed = info( + f"Your ban appeal request is logged.\n" + f"**Reason:** {chosen_reason}\n" + "Please wait for a moderator to respond.\n\n", + self.cog.bot.user, + "Ticket Created!" + ) + embed.set_footer(text="NOTE: Please remain in this server until this ticket is closed.") + await user.send(embed=embed) + except discord.HTTPException: + await interaction.followup.send( + "❌ I couldn't send you a Direct Message. Please enable DMs from server members and try again.", + ephemeral=True + ) + return + + await self.cog.create_mod_mail(user, reason=chosen_reason, source="panel", ping=False) + + +class TicketReasonView(discord.ui.View): + """Temporary ephemeral view containing the reason dropdown.""" + + def __init__(self, cog: "TortoiseDM"): + super().__init__(timeout=60) + self.add_item(TicketReasonSelect(cog)) class ModMailStartView(discord.ui.View): @@ -45,30 +167,11 @@ async def start_modmail(self, interaction: discord.Interaction, button: discord. cog.cool_down.add_to_cool_down(user.id) await interaction.response.send_message( - "πŸ“© Opening mod mail in your DMs...", - ephemeral=True, - delete_after=5, + "πŸ“© Please select the reason for your ban appeal below:", + view=TicketReasonView(cog), + ephemeral=True ) - try: - embed = info( - "Your ban appeal request is logged.\n" - "Please wait for a moderator to respond.\n\n", - user, - "Ticket Created!" - ) - embed.set_footer(text="NOTE: Please remain in this server until this ticket is closed.") - await user.send(embed=embed) - except discord.HTTPException: - await interaction.followup.send( - "I couldn't DM you. Please enable DMs.", - ephemeral=True - ) - return - - await cog.create_mod_mail(user, source="panel") - - class NotifyButton(discord.ui.View): """Persistent button view for challenge notifications.""" @@ -207,7 +310,17 @@ async def post_challenge_notification( description="Post the mod mail contact panel." ) @app_commands.check(tortoise_bot_developer_only) - async def post_panel(self, interaction: discord.Interaction): + async def post_panel(self, interaction: discord.Interaction, channel_id: str): + await interaction.response.defer(ephemeral=True) + try: + target_id = int(channel_id) + except ValueError: + await interaction.followup.send("❌ Please provide a valid numerical Channel ID.", ephemeral=True) + return + + channel = interaction.guild.get_channel(target_id) + + await channel.purge(limit=1) embed = discord.Embed( title="Ban appeal", @@ -215,12 +328,15 @@ async def post_panel(self, interaction: discord.Interaction): color=discord.Color.dark_green() ) - embed.set_footer(text="Tortoise Programming Community", icon_url=self.bot.user.avatar.url) + embed.set_footer(text="Tortoise Programming Community", icon_url=bot_avatar_url) - await interaction.response.send_message( + await channel.send( embed=embed, view=ModMailStartView() ) + await interaction.followup.send( + embed=success("Done") + ) @app_commands.command( name="post_team_invites_notification", diff --git a/bot/cogs/github.py b/bot/cogs/github.py index d968335..cd07ff5 100644 --- a/bot/cogs/github.py +++ b/bot/cogs/github.py @@ -13,7 +13,7 @@ STATIC_PROJECTS_DATA = [ { - "name": "Tortoise-Bot", + "name": "tortoise-bot", "html_url": "https://github.com/Tortoise-Community/Tortoise-Bot", "web_link": "https://github.com/Tortoise-Community/Tortoise-Bot", "forks_count": 21, @@ -24,7 +24,7 @@ "short_desc": "Fully functional Bot for Discord coded in Discord.py", }, { - "name": "Runtime-Bot", + "name": "runtime-bot", "html_url": "https://github.com/Tortoise-Community/Runtime-Bot", "web_link": "https://github.com/Tortoise-Community/Runtime-Bot", "forks_count": 1, @@ -35,7 +35,7 @@ "short_desc": "Discord bot for executing code directly in chat using the Hermes sandbox engine.", }, { - "name": "Snappy-Bot", + "name": "snappy-bot", "html_url": "https://github.com/Tortoise-Community/Snappy-Bot", "web_link": "https://github.com/Tortoise-Community/Snappy-Bot", "forks_count": 1, @@ -46,20 +46,20 @@ "short_desc": "Snappy is a lightweight Discord bot built using discord.py v2+", }, { - "name": "Backend", - "html_url": "https://github.com/Tortoise-Community/Backend", - "web_link": "https://github.com/Tortoise-Community/Backend", + "name": "site-backend", + "html_url": "https://github.com/Tortoise-Community/Site-Backend", + "web_link": "https://github.com/Tortoise-Community/Site-Backend", "forks_count": 1, "commit_count": 573, "stargazers_count": 8, "contributors_count": 0, - "language": "Python", + "language": "Django", "short_desc": "Website build with django for the Tortoise Community discord server", }, { - "name": "Frontend", - "html_url": "https://github.com/Tortoise-Community/Frontend", - "web_link": "https://github.com/Tortoise-Community/Frontend", + "name": "site-frontend", + "html_url": "https://github.com/Tortoise-Community/Site-Frontend", + "web_link": "https://github.com/Tortoise-Community/Site-Frontend", "forks_count": 1, "commit_count": 119, "stargazers_count": 1, @@ -68,15 +68,15 @@ "short_desc": "Web frontend built with React for Tortoise Community discord server", }, { - "name": "BladeList", - "html_url": "https://github.com/Bladelist/Bladelist", - "web_link": "https://github.com/Bladelist/Bladelist", + "name": "code-studio", + "html_url": "https://github.com/Tortoise-Community/Code-Studio", + "web_link": "https://github.com/Tortoise-Community/Code-Studio", "forks_count": 7, "commit_count": 290, "stargazers_count": 9, "contributors_count": 0, - "language": "Django", - "short_desc": "An open-source Discord Bot and Server Listing site built with Django.", + "language": "React", + "short_desc": "An open-source platform for practising DSA with community-driven resources", }, ] diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 527a85d..e2e13db 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -334,10 +334,10 @@ async def unban(self, interaction: discord.Interaction, user_id: str, reason: st await interaction.guild.unban(user=user, reason=reason) try: await user.send( + content=constants.server_link, embed=info( "You have been unbanned in Tortoise Community\n" - "Please use the below link to rejoin the server\n" - f"πŸ‘‰ [Invite Link]({constants.server_link}) ", + "Please use the invite link to rejoin the server\n", self.bot.user, "Ban Lifted!", "Welcome back to Tortoise Programming Community!", diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 792eed7..36c4694 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -5,21 +5,34 @@ import aiohttp import asyncio import io +import urllib.parse from discord.ext import commands from discord import Member, Message, app_commands, Guild from bot import constants from bot.utils.embed_handler import info, moderation_log_embed, warning, success, infraction_embed from bot.utils.message_handler import RemovableMessage -from bot.constants import allowed_file_extensions +from bot.constants import allowed_file_extensions, online_viewer_url from bot.utils.checks import tortoise_bot_developer_only from bot.utils.misc import get_user_avatar from bot.utils.custom_types import FakeInteraction - logger = logging.getLogger(__name__) +class PDFViewerButtonView(discord.ui.View): + + def __init__(self, bot: commands.Bot, original_msg_id: int): + super().__init__(timeout=None) + self.bot = bot + self.original_msg_id = original_msg_id + self.open_button.custom_id = f"pdf_view_ctx:{original_msg_id}" + + @discord.ui.button(label="View Online", style=discord.ButtonStyle.secondary, emoji="πŸ”—") + async def open_button(self, interaction: discord.Interaction, button: discord.ui.Button): + pass + + class Security(commands.Cog): def __init__(self, bot): self.bot = bot @@ -27,6 +40,8 @@ def __init__(self, bot): self._trusted = None self._log_channel = None + self.interaction_cooldowns = {} + @property def guild(self): if self._guild is None: @@ -73,6 +88,7 @@ async def security_check(self, message: Message): return if message.attachments: + await self.process_pdf_attachments(message) deleted = await self.deal_with_attachments(message) if deleted: self.bot.suppressed_deletes.add(message.id) @@ -97,6 +113,93 @@ def is_security_whitelisted(self, message: Message) -> bool: return True return False + async def process_pdf_attachments(self, message: Message): + for attachment in message.attachments: + if attachment.filename.lower().endswith('.pdf'): + + embed = discord.Embed( + title="Open in Web Viewer", + description=( + f"You can safely view **{attachment.filename}** within your web browser " + f"without needing to download the file locally.\n\n" + f"-# Click the button below to generate a fresh link." + ), + color=0xffb101 + ) + embed.set_footer(text="We do not recommend downloading documents from discord.") + + view = PDFViewerButtonView(self.bot, message.id) + + try: + await message.channel.send(embed=embed, view=view, reference=message) + except discord.HTTPException: + pass + + @commands.Cog.listener() + async def on_interaction(self, interaction: discord.Interaction): + if interaction.type != discord.InteractionType.component: + return + + custom_id = interaction.data.get("custom_id", "") + if not custom_id.startswith("pdf_view_ctx:"): + return + + user_id = interaction.user.id + now = discord.utils.utcnow().timestamp() + + if user_id not in self.interaction_cooldowns: + self.interaction_cooldowns[user_id] = [] + + self.interaction_cooldowns[user_id] = [t for t in self.interaction_cooldowns[user_id] if now - t < 30] + + if len(self.interaction_cooldowns[user_id]) >= 3: + await interaction.response.send_message( + embed=warning("You are clicking this too fast. Please wait a few seconds before trying again."), + ephemeral=True + ) + return + + self.interaction_cooldowns[user_id].append(now) + + await interaction.response.defer(ephemeral=True) + + try: + original_msg_id = int(custom_id.split(":")[1]) + origin_message = await interaction.channel.fetch_message(original_msg_id) + except (discord.NotFound, discord.Forbidden, ValueError, IndexError): + await interaction.followup.send( + embed=warning( + "Could not open file: The original upload message was deleted or is no longer accessible."), + ephemeral=True + ) + return + + target_attachment = None + for attachment in origin_message.attachments: + if attachment.filename.lower().endswith('.pdf'): + target_attachment = attachment + break + + if not target_attachment: + await interaction.followup.send( + embed=warning( + "Could not open file: The PDF asset could not be found inside that message payload context."), + ephemeral=True + ) + return + + encoded_url = urllib.parse.quote(target_attachment.url, safe='') + final_viewer_url = f"{online_viewer_url}/{encoded_url}" + success_embed = info( + f"[Click here]({final_viewer_url}) to open **{target_attachment.filename}** in Web Viewer\n\n", + self.bot.user, + "" + ) + success_embed.set_footer(text="This temporary link remains valid for 24 hours") + await interaction.followup.send( + embed=success_embed, + ephemeral=True + ) async def archive_and_delete_message( self, @@ -222,7 +325,7 @@ async def take_action_ban(self, member, reason, content=None): infraction_type=constants.Infraction.ban, reason=reason, is_dm=True, - can_appeal=True + permanent=False ) embed.set_footer(text="⚠️ This was an automated action. If you'd like to appeal, join the appeal server.") @@ -364,21 +467,21 @@ def extract_content(message: discord.Message) -> str: @commands.Cog.listener() async def on_message_edit(self, msg_before, msg_after): - if msg_before.content == msg_after.content: + if msg_before.content == msg_after.content and len(msg_before.attachments) == len(msg_after.attachments): return elif self.is_security_whitelisted(msg_after): return - # Log that the message was edited for security reasons - msg = ( - f"**Channel**\n{msg_before.channel.mention}\n\n" - f"**Before**\n{msg_before.content}\n\n" - f"**After**\n{msg_after.content}\n\n" - f"[jump]({msg_after.jump_url})" - ) - embed = info(msg, msg_before.guild.me, title="Message edited") - embed.set_footer(text=f"Author: {msg_before.author}", icon_url=get_user_avatar(msg_before.author)) - await self.log_channel.send(embed=embed) + if msg_before.content != msg_after.content: + msg = ( + f"**Channel**\n{msg_before.channel.mention}\n\n" + f"**Before**\n{msg_before.content}\n\n" + f"**After**\n{msg_after.content}\n\n" + f"[jump]({msg_after.jump_url})" + ) + embed = info(msg, msg_before.guild.me, title="Message edited") + embed.set_footer(text=f"Author: {msg_before.author}", icon_url=get_user_avatar(msg_before.author)) + await self.log_channel.send(embed=embed) # Check if the new message violates our security await self.security_check(msg_after) @@ -402,5 +505,6 @@ async def enable_advanced_protection(self, interaction: discord.Interaction): self.bot.advanced_protection = True await interaction.followup.send(embed=success(f"Advanced Protectionβ„’ Enabled."), ephemeral=False) + async def setup(bot): await bot.add_cog(Security(bot)) diff --git a/bot/cogs/teams.py b/bot/cogs/teams.py index 2405b93..e7995c8 100644 --- a/bot/cogs/teams.py +++ b/bot/cogs/teams.py @@ -10,7 +10,10 @@ from bot.utils.embed_handler import success, failure, warning, info, authored_sm from bot.utils.checks import tortoise_bot_developer_only +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from bot.bot import Bot class CreateTeamModal(discord.ui.Modal, title="Create Team"): @@ -232,7 +235,7 @@ def __init__(self, cog): style=discord.ButtonStyle.blurple, custom_id="team_request_join_start" ) - async def request_join(self, interaction: discord.Interaction, button: discord.ui.Button): + async def request_join(self, interaction: discord.Interaction, button: discord.ui.Button): # noqa existing = await self.cog.team.get_user_team(interaction.guild.id, interaction.user.id) if existing: return await interaction.response.send_message( @@ -246,7 +249,7 @@ async def request_join(self, interaction: discord.Interaction, button: discord.u ) view = TeamSelectionView(self.cog, teams) - await interaction.response.send_message( + return await interaction.response.send_message( "Select a team you wish to join:", view=view, ephemeral=True ) @@ -314,7 +317,7 @@ async def on_submit(self, interaction: discord.Interaction): class TeamCog(commands.Cog): team_group = app_commands.Group(name="team", description="All management commands related to teams.") - def __init__(self, bot): + def __init__(self, bot: "Bot"): self.bot = bot self.team = bot.team_manager self.log_channel = None @@ -447,7 +450,7 @@ async def _handle_team_setup(self, interaction: discord.Interaction, custom_id: ephemeral=True ) - await interaction.response.send_modal( + return await interaction.response.send_modal( CreateTeamModal(self, guild.id, invite_id) ) @@ -527,7 +530,7 @@ async def send_team_setup(self, interaction, member: discord.Member): await msg.edit(view=view) - await interaction.followup.send( + return await interaction.followup.send( embed=success("Setup invite sent."), ephemeral=True ) @@ -557,7 +560,7 @@ async def delete_team(self, interaction, role: discord.Role): ) await self.update_dashboard(guild) - await interaction.followup.send(embed=success("Team deleted successfully."), ephemeral=True) + return await interaction.followup.send(embed=success("Team deleted successfully."), ephemeral=True) @team_group.command(name="invite") @@ -752,7 +755,7 @@ async def remove_member(self, interaction, member: discord.Member): if not success_flag: return await interaction.followup.send(embed=failure(err)) - await interaction.followup.send( + return await interaction.followup.send( embed=success(f"{member.mention} removed from team.") ) @@ -792,7 +795,7 @@ async def leave_team(self, interaction: discord.Interaction): if not success_flag: return await interaction.followup.send(embed=failure(err)) - await interaction.followup.send( + return await interaction.followup.send( embed=success("You left the team.") ) @@ -952,6 +955,7 @@ async def team_members(self, interaction: discord.Interaction): leader_display = leader_instance.mention if leader_instance else f"Unknown User (`{team['leader_id']}`)" member_mentions = [] + members_left = [] for row in db_members: user_id = row["user_id"] if user_id == team["leader_id"]: @@ -961,7 +965,7 @@ async def team_members(self, interaction: discord.Interaction): if member_m: member_mentions.append(f"`{member_m.display_name}`") else: - member_mentions.append(f"Unknown User (`{user_id}`)") + members_left.append(user_id) members_display = "\n".join(member_mentions) if member_mentions else "*No other members have joined yet.*" @@ -972,6 +976,12 @@ async def team_members(self, interaction: discord.Interaction): f"Team Profile: {team['name']}" ) + if members_left: + try: + await self.team.remove_members_bulk(team["team_id"], members_left) + except Exception: + pass + return await interaction.followup.send(embed=embed) diff --git a/bot/cogs/tortoise_dm.py b/bot/cogs/tortoise_dm.py index caecf9b..1cc131f 100644 --- a/bot/cogs/tortoise_dm.py +++ b/bot/cogs/tortoise_dm.py @@ -26,6 +26,32 @@ class UnsupportedFileExtension(Exception): class UnsupportedFileEncoding(ValueError): pass + +class ModMailReasonModal(discord.ui.Modal, title="Contact Staff (Mod Mail)"): + reason = discord.ui.Label( + text="Reason for contacting staff", + description=( + "⚠️ Mod mail is strictly for reporting scams, bots or server related issues." + ), + component=discord.ui.TextInput( + style=discord.TextStyle.long, + min_length=10, + max_length=1024, + required=True, + placeholder="Please describe your issue here..." + ) + ) + + def __init__(self, cog: "TortoiseDM"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + user = interaction.user + await interaction.response.defer(ephemeral=True) + await self.cog.create_mod_mail(user, reason=self.reason.component.value, source="dm") + + class DutyScheduleModal(discord.ui.Modal, title="Set Daily Mod Mail Schedule"): start_time = discord.ui.TextInput( label="Start Time (24h format)", @@ -113,12 +139,6 @@ async def callback(self, interaction: discord.Interaction): user = interaction.user cog = interaction.client.get_cog("TortoiseDM") - # Remove buttons immediately - if interaction.message: - view = self.view - view.clear_items() - await interaction.message.edit(view=view) - if cog.is_any_session_active(user.id): await interaction.response.send_message("Session already active.", ephemeral=True) return @@ -127,9 +147,21 @@ async def callback(self, interaction: discord.Interaction): msg = f"You are on cooldown. You can retry after {cog.cool_down.retry_after(user.id)}s" await interaction.response.send_message(embed=failure(msg), ephemeral=True) return - else: - cog.cool_down.add_to_cool_down(user.id) + if self.label == "Contact staff (Mod Mail)": + await interaction.response.send_modal(ModMailReasonModal(cog)) + if interaction.message: + view = self.view + view.clear_items() + await interaction.message.edit(view=view) + return + + if interaction.message: + view = self.view + view.clear_items() + await interaction.message.edit(view=view) + + cog.cool_down.add_to_cool_down(user.id) await interaction.response.defer(ephemeral=True) await self.callback_func(user) @@ -158,6 +190,8 @@ async def accept(self, interaction: discord.Interaction, button: discord.ui.Butt await interaction.response.send_message("User not found.", ephemeral=True) return + await interaction.response.defer(ephemeral=True) + self.clear_items() await self.cog.update_staff_embed_from_message( interaction.message, @@ -180,22 +214,25 @@ async def accept(self, interaction: discord.Interaction, button: discord.ui.Butt await interaction.followup.send("Mod mail failed: moderator DMs closed.", ephemeral=True) return - await user.send( - embed=authored( - ( - f"{mod.name} has accepted your mod mail request.\n" - "Reply here in DMs to chat with them.\n" - "This mod mail will be logged, by continuing you agree to that." - ), - author=mod + try: + await user.send( + embed=authored( + ( + f"{mod.name} has accepted your mod mail request.\n" + "Reply here in DMs to chat with them.\n" + "This mod mail will be logged, by continuing you agree to that." + ), + author=mod + ) ) - ) + except discord.HTTPException: + await interaction.followup.send("Failed to notify the user. Their DMs might be closed.", ephemeral=True) + return self.cog.pending_mod_mails.remove(user_id) self.cog.active_mod_mails[user_id] = mod.id embed = success("Mod Mail initialized. Check your DMs") - await interaction.response.send_message(embed=embed, ephemeral=True) - + await interaction.followup.send(embed=embed, ephemeral=True) first_timeout = 21_600 regular_timeout = 1800 @@ -214,8 +251,14 @@ def mod_mail_check(msg): except TimeoutError: timeout_embed = failure("Mod mail closed due to inactivity.") log.add_embed(timeout_embed) - await mod.send(embed=timeout_embed) - await user.send(embed=timeout_embed) + try: + await mod.send(embed=timeout_embed) + except discord.HTTPException: + pass + try: + await user.send(embed=timeout_embed) + except discord.HTTPException: + pass del self.cog.active_mod_mails[user_id] logs = await self.cog.mod_mail_report_channel.send( file=discord.File(StringIO(str(log)), filename=log.filename) @@ -244,8 +287,14 @@ def mod_mail_check(msg): if mail_msg.content.lower() == "close" and mail_msg.author.id == mod.id: close_embed = success(f"Mod mail successfully closed by {mail_msg.author}.") log.add_embed(close_embed) - await mod.send(embed=close_embed) - await user.send(embed=close_embed) + try: + await mod.send(embed=close_embed) + except discord.HTTPException: + pass + try: + await user.send(embed=close_embed) + except discord.HTTPException: + pass del self.cog.active_mod_mails[user_id] logs = await self.cog.mod_mail_report_channel.send( file=discord.File(StringIO(str(log)), filename=log.filename) @@ -260,9 +309,43 @@ def mod_mail_check(msg): break if mail_msg.author == user: - await mod.send(mail_msg.content) + try: + await mod.send(mail_msg.content) + except discord.HTTPException: + pass elif mail_msg.author == mod: - await user.send(mail_msg.content) + guild_member = (self.cog.tortoise_guild.get_member(user_id) + or self.cog.ban_appeal_guild.get_member(user_id)) + if guild_member is None: + left_embed = failure("Mod mail closed: The user has left the server.") + log.add_embed(left_embed) + try: + await mod.send(embed=left_embed) + except discord.HTTPException: + pass + + del self.cog.active_mod_mails[user_id] + logs = await self.cog.mod_mail_report_channel.send( + file=discord.File(StringIO(str(log)), filename=log.filename) + ) + await self.cog.update_staff_embed( + user_id, + description=logs.jump_url, + footer_append="❌ Closed: User left the server.", + color=discord.Color.red() + ) + del self.cog.modmail_messages[user_id] + break + + try: + await user.send(mail_msg.content) + except discord.HTTPException: + dm_closed_embed = failure("Could not deliver message: The user closed their DMs.") + log.add_embed(dm_closed_embed) + try: + await mod.send(embed=dm_closed_embed) + except discord.HTTPException: + pass class TortoiseDM(commands.Cog): @@ -271,6 +354,7 @@ class TortoiseDM(commands.Cog): def __init__(self, bot): self.bot = bot self._tortoise_guild = None + self._ban_appeal_guild = None self._admin_role = None self._moderator_role = None self._mod_mail_ping_role = None @@ -325,7 +409,7 @@ def __init__(self, bot): self.user_suggestions_channel = None self.mod_mail_report_channel = None self.code_submissions_channel = None - self.staff_applications_channel= None + self.staff_applications_channel = None self.staff_channel = None @commands.Cog.listener() @@ -347,6 +431,12 @@ def tortoise_guild(self): self._tortoise_guild = self.bot.get_guild(constants.tortoise_guild_id) return self._tortoise_guild + @property + def ban_appeal_guild(self): + if self._ban_appeal_guild is None: + self._ban_appeal_guild = self.bot.get_guild(constants.ban_appeal_server_id) + return self._ban_appeal_guild + @property def admin_role(self): if self._admin_role is None: @@ -544,9 +634,12 @@ async def update_staff_embed( except Exception: pass - async def create_mod_mail(self, user: discord.User, source: str = "dm"): + async def create_mod_mail(self, user: discord.User, reason: str = "No reason provided.", source: str = "dm", ping=True): if user.id in self.pending_mod_mails: - await user.send(embed=failure("You already have a pending mod mail, please be patient.")) + try: + await user.send(embed=failure("You already have a pending mod mail, please be patient.")) + except discord.Forbidden: + pass return source_text = { @@ -554,16 +647,22 @@ async def create_mod_mail(self, user: discord.User, source: str = "dm"): "panel": "created a ban appeal request." }.get(source, source) - submission_embed = authored_sm(f"{user.name} {source_text}", author=user) + submission_embed = authored(f"{user.name} {source_text}", author=user) + submission_embed.add_field(name="Provided Reason", value=reason, inline=False) + submission_embed.color = discord.Color.orange() + view = ModMailAcceptView(self, user.id) msg = await self.staff_channel.send( - self.mod_mail_ping_role.mention, + self.mod_mail_ping_role.mention if ping else None, embed=submission_embed, view=view ) self.modmail_messages[user.id] = msg.id self.pending_mod_mails.add(user.id) + + self.cool_down.add_to_cool_down(user.id) + if source == "dm": embed = info("Mail is initialized and the moderators have been contacted.\n" "You'll be notified once someone from the team responds.", @@ -571,7 +670,10 @@ async def create_mod_mail(self, user: discord.User, source: str = "dm"): embed.set_footer( text="NOTE: Response time may vary; No need to wait here." ) - await user.send(embed=embed) + try: + await user.send(embed=embed) + except discord.Forbidden: + pass async def create_event_submission(self, user: discord.User): user_reply = await self._get_user_reply(self.active_event_submissions, user, "Event Submission") diff --git a/bot/constants.py b/bot/constants.py index c6740c3..e0b78b8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -21,7 +21,9 @@ appeal_server_link = "https://discord.com/invite/YxEzEqMNY8" server_link = "https://discord.com/invite/Ex8xeWD" online_compiler_link = "https://execute.tortoisecommunity.org" -runtime_bot_link = "https://runtime-bot.tortoisecommunity.org/" +runtime_bot_link = "https://runtime-bot.tortoisecommunity.org" +online_viewer_url = "https://viewer.tortoisecommunity.org" +bot_avatar_url = "https://lairesit.sirv.com/Tortoise/tortoise.png" # Channel IDs welcome_channel_id = 738731842538176522 diff --git a/bot/manager.py b/bot/manager.py index d4e89f1..5678c3a 100644 --- a/bot/manager.py +++ b/bot/manager.py @@ -31,6 +31,7 @@ async def setup(self): messages INTEGER NOT NULL DEFAULT 0, active BOOLEAN NOT NULL DEFAULT FALSE, active_plus BOOLEAN NOT NULL DEFAULT FALSE, + auto_banned BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (guild_id, user_id) ) """ @@ -50,6 +51,32 @@ async def setup(self): """ ) + async def set_ban_status(self, guild_id: int, user_id: int, status: bool): + + await self.db.pool.execute( + """ + INSERT INTO activity (guild_id, user_id, auto_banned) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, user_id) + DO UPDATE + SET auto_banned = EXCLUDED.auto_banned + """, + guild_id, + user_id, + status, + ) + + async def is_auto_banned(self, guild_id: int, user_id: int) -> bool: + + return await self.db.pool.fetchval( + """ + SELECT auto_banned + FROM activity + WHERE guild_id = $1 AND user_id = $2 + """, + guild_id, + user_id + ) or False async def add_messages_bulk(self, guild_id: int, cache: dict[int, int]): @@ -597,6 +624,19 @@ async def remove_member(self, team_id: int, user_id: int): WHERE team_id=$1 AND user_id=$2 """, team_id, user_id) + async def remove_members_bulk(self, team_id: int, user_ids: list[int]): + if not user_ids: + return + + await self.db.pool.execute( + """ + DELETE FROM team_members + WHERE team_id = $1 AND user_id = ANY($2) + """, + team_id, + user_ids + ) + async def get_user_team(self, guild_id: int, user_id: int): return await self.db.pool.fetchrow(""" SELECT * FROM team_members @@ -661,6 +701,7 @@ async def get_team_members(self, team_id: int): SELECT user_id FROM team_members WHERE team_id=$1 + ORDER BY joined_at ASC """, team_id) async def create_join_request(self, guild_id: int, team_id: int, user_id: int, reason: str = None) -> bool: