diff --git a/.gitignore b/.gitignore index a33a735..91885b3 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,5 @@ cython_debug/ .vs/ pyproject.toml + +treegen.py diff --git a/datamanager/__init__.py b/datamanager/__init__.py new file mode 100644 index 0000000..08a1ac9 --- /dev/null +++ b/datamanager/__init__.py @@ -0,0 +1,4 @@ +from .datamanager import DataManager + +async def setup(bot): + await bot.add_cog(DataManager(bot)) diff --git a/datamanager/datamanager.py b/datamanager/datamanager.py new file mode 100644 index 0000000..f9511dc --- /dev/null +++ b/datamanager/datamanager.py @@ -0,0 +1,128 @@ +import discord +from redbot.core import commands, Config +import datetime +from typing import Dict, Optional, Literal +from discord.ext import tasks + +SUPPORTED_COGS = Literal["ModMail", "Hiring", "ServiceReview", "StaffMsg", "Logging"] + +class DataManager(commands.Cog): + """ + A cog to automatically manage and purge old data from other cogs. + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567894, force_registration=True) + default_guild = { + "policies": {} # {cog_name: days} + } + self.config.register_guild(**default_guild) + self.data_purge_loop.start() + + def cog_unload(self): + self.data_purge_loop.cancel() + + @tasks.loop(hours=24) + async def data_purge_loop(self): + """Periodically purges old data based on configured policies.""" + await self.bot.wait_until_ready() + all_guilds = self.bot.guilds + for guild in all_guilds: + policies = await self.config.guild(guild).policies() + if not policies: + continue + + for cog_name, days in policies.items(): + cog = self.bot.get_cog(cog_name) + if not cog or not hasattr(cog, "config"): + continue + + retention_delta = datetime.timedelta(days=days) + + # Determine the correct config group to purge + # This needs to match what we defined in the other cogs + data_group_name = "" + if cog_name == "ModMail": + data_group_name = "closed_threads" + elif cog_name == "Hiring": + data_group_name = "closed_applications" + elif cog_name == "ServiceReview": + data_group_name = "reviews" + elif cog_name in ["StaffMsg", "Logging"]: + data_group_name = "logged_events" + + if not data_group_name: + continue + + async with cog.config.guild(guild).get_attr(data_group_name)() as data_log: + to_delete = [] + for entry_id, timestamp_str in data_log.items(): + try: + entry_timestamp = datetime.datetime.fromisoformat(timestamp_str) + if (datetime.datetime.now(datetime.timezone.utc) - entry_timestamp) > retention_delta: + to_delete.append(entry_id) + except (ValueError, TypeError): + continue # Skip invalid timestamps + + for entry_id in to_delete: + del data_log[entry_id] + + # --- SETTINGS COMMANDS --- + @commands.group(aliases=["dmset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def datamanagerset(self, ctx: commands.Context): + """Configure DataManager settings.""" + pass + + @datamanagerset.command(name="policy") + async def set_purge_policy(self, ctx: commands.Context, cog_name: SUPPORTED_COGS, days: int): + """ + Set the data retention policy for a cog. + + Use 0 days to disable purging for a cog. + """ + if not ctx.guild: + return + if days < 0: + await ctx.send("Please provide a non-negative number of days.") + return + + async with self.config.guild(ctx.guild).policies() as policies: + if days == 0: + if cog_name in policies: + del policies[cog_name] + await ctx.send(f"Purge policy for `{cog_name}` has been removed.") + else: + await ctx.send(f"No purge policy was set for `{cog_name}`.") + else: + policies[cog_name] = days + await ctx.send(f"Data from `{cog_name}` will now be purged after {days} days.") + + @datamanagerset.command(name="view") + async def view_policies(self, ctx: commands.Context): + """View the current data retention policies.""" + if not ctx.guild: + return + + policies = await self.config.guild(ctx.guild).policies() + if not policies: + await ctx.send("No data retention policies have been set for this server.") + return + + embed = discord.Embed( + title="Data Retention Policies", + color=await ctx.embed_color() + ) + description = "Data from the following cogs will be automatically purged after the specified duration:" + for cog_name, days in policies.items(): + description += f"\n- **{cog_name}**: {days} days" + + embed.description = description + await ctx.send(embed=embed) + + +async def setup(bot): + await bot.add_cog(DataManager(bot)) + diff --git a/datamanager/info.json b/datamanager/info.json new file mode 100644 index 0000000..2ecae4d --- /dev/null +++ b/datamanager/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Data Manager cog! Please use the setup commands to configure your data retention policies.", + "name": "DataManager", + "short": "A cog to manage long-term data retention.", + "description": "Provides tools to automatically purge or archive old data from other cogs to prevent database bloat.", + "tags": [ + "data", + "database", + "utility", + "admin" + ], + "requirements": [], + "end_user_data_statement": "This cog reads and deletes data from other cogs based on administrator configuration. It does not store any unique user data itself." +} \ No newline at end of file diff --git a/hiring/hiring.py b/hiring/hiring.py new file mode 100644 index 0000000..2719ab9 --- /dev/null +++ b/hiring/hiring.py @@ -0,0 +1,263 @@ +import discord +from redbot.core import commands, Config, app_commands +from typing import Literal, Optional, TYPE_CHECKING, Type +import datetime + +if TYPE_CHECKING: + from redbot.core.bot import Red + +# --- Modals for the Application Forms --- + +class StaffApplicationModal(discord.ui.Modal, title="Staff Application"): + chosen_plan = discord.ui.TextInput(label="What plan are you interested in?") + tox_level = discord.ui.TextInput(label="What is your toxicity level tolerance?") + describe_server = discord.ui.TextInput(label="Please describe your server.", style=discord.TextStyle.paragraph) + server_link = discord.ui.TextInput(label="Server Invite Link") + + def __init__(self, ticket_channel: discord.TextChannel): + super().__init__() + self.ticket_channel = ticket_channel + + async def on_submit(self, interaction: discord.Interaction): + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) + return + + embed = discord.Embed(title="New Staff Application", color=discord.Color.blue()) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Chosen Plan", value=self.chosen_plan.value, inline=False) + embed.add_field(name="Toxicity Level", value=self.tox_level.value, inline=False) + embed.add_field(name="Server Description", value=self.describe_server.value, inline=False) + embed.add_field(name="Server Link", value=self.server_link.value, inline=False) + await self.ticket_channel.send(embed=embed) + await interaction.response.send_message("Your staff application has been submitted.", ephemeral=True) + +class PMApplicationModal(discord.ui.Modal, title="PM Application"): + ad = discord.ui.TextInput(label="Your Ad", style=discord.TextStyle.paragraph) + reqs = discord.ui.TextInput(label="Your Requirements", style=discord.TextStyle.paragraph) + tox_level = discord.ui.TextInput(label="What is your toxicity level tolerance?") + chosen_plan = discord.ui.TextInput(label="What plan are you interested in?") + + def __init__(self, ticket_channel: discord.TextChannel): + super().__init__() + self.ticket_channel = ticket_channel + + async def on_submit(self, interaction: discord.Interaction): + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) + return + + embed = discord.Embed(title="New PM Application", color=discord.Color.green()) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Ad", value=f"```\n{self.ad.value}\n```", inline=False) + embed.add_field(name="Requirements", value=f"```\n{self.reqs.value}\n```", inline=False) + embed.add_field(name="Toxicity Level", value=self.tox_level.value, inline=False) + embed.add_field(name="Chosen Plan", value=self.chosen_plan.value, inline=False) + await self.ticket_channel.send(embed=embed) + await interaction.response.send_message("Your PM application has been submitted.", ephemeral=True) + +class HPMApplicationModal(discord.ui.Modal, title="HPM Application"): + ad = discord.ui.TextInput(label="Your Ad", style=discord.TextStyle.paragraph) + reqs = discord.ui.TextInput(label="Your Requirements", style=discord.TextStyle.paragraph) + + def __init__(self, ticket_channel: discord.TextChannel): + super().__init__() + self.ticket_channel = ticket_channel + + async def on_submit(self, interaction: discord.Interaction): + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) + return + + embed = discord.Embed(title="New HPM Application", color=discord.Color.purple()) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Ad", value=f"```\n{self.ad.value}\n```", inline=False) + embed.add_field(name="Requirements", value=f"```\n{self.reqs.value}\n```", inline=False) + await self.ticket_channel.send(embed=embed) + await interaction.response.send_message("Your HPM application has been submitted.", ephemeral=True) + +# --- Views with Buttons --- + +async def create_ticket(interaction: discord.Interaction, ticket_type: str, modal_class: Type[discord.ui.Modal]): + """Helper function to create a ticket, used by multiple views.""" + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("The Hiring cog is not loaded.", ephemeral=True) + return + + guild = interaction.guild + if not guild: + await interaction.response.send_message("This action can only be performed in a server.", ephemeral=True) + return + + category_id_raw = await cog.config.guild(guild).get_raw(f"{ticket_type}_category") + if not isinstance(category_id_raw, int): + await interaction.response.send_message(f"The category for '{ticket_type}' applications has not been set correctly.", ephemeral=True) + return + + category = guild.get_channel(category_id_raw) + if not isinstance(category, discord.CategoryChannel): + await interaction.response.send_message(f"The configured category for '{ticket_type}' is invalid.", ephemeral=True) + return + + assert isinstance(interaction.user, discord.Member) + + try: + thread_name = f"{ticket_type}-application-{interaction.user.name}" + overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True) + } + ticket_channel = await category.create_text_channel(name=thread_name, overwrites=overwrites) + + modal_instance = modal_class(ticket_channel) # type: ignore + await interaction.response.send_modal(modal_instance) + + async with cog.config.guild(guild).closed_applications() as closed_apps: + closed_apps[str(ticket_channel.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() + + except discord.Forbidden: + if not interaction.response.is_done(): + await interaction.response.send_message("I don't have permission to create channels in that category.", ephemeral=True) + except Exception as e: + if not interaction.response.is_done(): + await interaction.response.send_message(f"An unexpected error occurred: {e}", ephemeral=True) + +class HireView(discord.ui.View): + def __init__(self): + super().__init__(timeout=None) + + @discord.ui.button(label="Staff", style=discord.ButtonStyle.primary, custom_id="staff_apply_button_persistent") + async def staff_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await create_ticket(interaction, "staff", StaffApplicationModal) + + @discord.ui.button(label="PM", style=discord.ButtonStyle.secondary, custom_id="pm_apply_button_persistent") + async def pm_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await create_ticket(interaction, "pm", PMApplicationModal) + + @discord.ui.button(label="HPM", style=discord.ButtonStyle.success, custom_id="hpm_apply_button_persistent") + async def hpm_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await create_ticket(interaction, "hpm", HPMApplicationModal) + + +class WorkView(discord.ui.View): + def __init__(self): + super().__init__(timeout=None) + + @discord.ui.button(label="Apply for PM Position", style=discord.ButtonStyle.green, custom_id="work_apply_pm_persistent") + async def work_apply_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await create_ticket(interaction, "pm", PMApplicationModal) + + +# --- Main Cog Class --- + +class Hiring(commands.Cog): + """ + A cog for handling staff hiring applications. + """ + def __init__(self, bot: "Red"): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567891, force_registration=True) + default_guild = { + "staff_category": None, + "pm_category": None, + "hpm_category": None, + "work_channel": None, + "closed_applications": {} + } + self.config.register_guild(**default_guild) + + async def cog_load(self): + self.bot.add_view(HireView()) + self.bot.add_view(WorkView()) + + @commands.hybrid_command() # type: ignore + @app_commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def hire(self, ctx: commands.Context): + """Sends the hiring application view.""" + view = HireView() + embed = discord.Embed( + title="Hiring Applications", + description="Please select the position you are applying for below.", + color=await ctx.embed_color() + ) + await ctx.send(embed=embed, view=view) + + @commands.hybrid_command() # type: ignore + @app_commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def work(self, ctx: commands.Context): + """Posts the persistent PM application button.""" + guild = ctx.guild + if not guild: + return + + work_channel_id = await self.config.guild(guild).work_channel() + if not work_channel_id: + await ctx.send("The work channel has not been set. Please use `/hiringset workchannel`.") + return + + channel = guild.get_channel(work_channel_id) + if not isinstance(channel, discord.TextChannel): + await ctx.send("The configured work channel is invalid.") + return + + view = WorkView() + embed = discord.Embed( + title="Partnership Manager Applications", + description="Click the button below to apply for a Partnership Manager (PM) position.", + color=discord.Color.green() + ) + try: + await channel.send(embed=embed, view=view) + await ctx.send(f"Application message posted in {channel.mention}.", ephemeral=True) + except discord.Forbidden: + await ctx.send("I don't have permission to send messages in that channel.", ephemeral=True) + + + @commands.group(aliases=["hset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def hiringset(self, ctx: commands.Context): + """Configure Hiring settings.""" + pass + + @hiringset.command(name="staffcategory") + async def set_staff_category(self, ctx: commands.Context, category: discord.CategoryChannel): + """Set the category for Staff applications.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).staff_category.set(category.id) + await ctx.send(f"Staff application category set to **{category.name}**.") + + @hiringset.command(name="pmcategory") + async def set_pm_category(self, ctx: commands.Context, category: discord.CategoryChannel): + """Set the category for PM applications.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).pm_category.set(category.id) + await ctx.send(f"PM application category set to **{category.name}**.") + + @hiringset.command(name="hpmcategory") + async def set_hpm_category(self, ctx: commands.Context, category: discord.CategoryChannel): + """Set the category for HPM applications.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).hpm_category.set(category.id) + await ctx.send(f"HPM application category set to **{category.name}**.") + + @hiringset.command(name="workchannel") + async def set_work_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for the /work command announcements.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).work_channel.set(channel.id) + await ctx.send(f"Work announcement channel set to {channel.mention}.") + +async def setup(bot): + await bot.add_cog(Hiring(bot)) + diff --git a/kofishop/kofishop.py b/kofishop/kofishop.py new file mode 100644 index 0000000..83ac51c --- /dev/null +++ b/kofishop/kofishop.py @@ -0,0 +1,156 @@ +import discord +from redbot.core import commands, Config, app_commands +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from redbot.core.bot import Red + +class OrderModal(discord.ui.Modal, title="Commission Order Form"): + commission_type = discord.ui.TextInput(label="What type of commission?") + payment_status = discord.ui.TextInput(label="Is this a Free or Paid commission?") + description = discord.ui.TextInput(label="Description", style=discord.TextStyle.paragraph) + questions = discord.ui.TextInput(label="Any questions?", style=discord.TextStyle.paragraph, required=False) + + def __init__(self, cog: "KofiShop"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + return + + channel_id = await self.cog.config.guild(guild).order_channel() + if not channel_id: + await interaction.response.send_message("Order channel not set.", ephemeral=True) + return + + channel = guild.get_channel(channel_id) + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message("Invalid order channel.", ephemeral=True) + return + + embed = discord.Embed(title=f"New Order from {interaction.user.name}", color=discord.Color.blurple()) + embed.add_field(name="Commission Type", value=self.commission_type.value, inline=False) + embed.add_field(name="Payment Status", value=self.payment_status.value, inline=False) + embed.add_field(name="Description", value=self.description.value, inline=False) + if self.questions.value: + embed.add_field(name="Questions", value=self.questions.value, inline=False) + + await channel.send(embed=embed) + await interaction.response.send_message("Your order has been submitted!", ephemeral=True) + +class ReviewModal(discord.ui.Modal, title="Shop Review"): + item_name = discord.ui.TextInput(label="What item/commission are you reviewing?") + rating = discord.ui.TextInput(label="Rating (out of 10)", max_length=2) + review_text = discord.ui.TextInput(label="Your Review", style=discord.TextStyle.paragraph, max_length=1000) + + def __init__(self, cog: "KofiShop"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + return + + channel_id = await self.cog.config.guild(guild).review_channel() + if not channel_id: + await interaction.response.send_message("Review channel not set.", ephemeral=True) + return + + channel = guild.get_channel(channel_id) + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message("Invalid review channel.", ephemeral=True) + return + + embed = discord.Embed(title=f"New Review for {self.item_name.value}", color=discord.Color.gold()) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Rating", value=f"{self.rating.value}/10") + embed.add_field(name="Review", value=self.review_text.value, inline=False) + + await channel.send(embed=embed) + await interaction.response.send_message("Thank you for your review!", ephemeral=True) + + +class KofiShop(commands.Cog): + """ + An interactive front-end for a Ko-fi store. + """ + def __init__(self, bot: "Red"): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567894, force_registration=True) + default_guild = { + "order_channel": None, + "review_channel": None, + "waitlist_channel": None + } + self.config.register_guild(**default_guild) + + @app_commands.command() + @app_commands.guild_only() + async def order(self, interaction: discord.Interaction): + """Place an order for a commission.""" + await interaction.response.send_modal(OrderModal(self)) + + @app_commands.command() + @app_commands.guild_only() + async def review(self, interaction: discord.Interaction): + """Leave a review for a completed commission.""" + await interaction.response.send_modal(ReviewModal(self)) + + @app_commands.command() + @app_commands.guild_only() + async def waitlist(self, interaction: discord.Interaction, *, item: str): + """Add an item to the waitlist.""" + guild = interaction.guild + if not guild: + return + + channel_id = await self.config.guild(guild).waitlist_channel() + if not channel_id: + await interaction.response.send_message("Waitlist channel not set.", ephemeral=True) + return + + channel = guild.get_channel(channel_id) + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message("Invalid waitlist channel.", ephemeral=True) + return + + embed = discord.Embed(description=f"{item} ིྀ {interaction.user.mention}✧ {interaction.channel.mention if isinstance(interaction.channel, discord.TextChannel) else ''}") + await channel.send(embed=embed) + await interaction.response.send_message("You have been added to the waitlist!", ephemeral=True) + + @commands.group(aliases=["kset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def kofiset(self, ctx: commands.Context): + """Configure KofiShop settings.""" + pass + + @kofiset.command(name="orderchannel") + async def set_order_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for new orders.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).order_channel.set(channel.id) + await ctx.send(f"Order channel set to {channel.mention}") + + @kofiset.command(name="reviewchannel") + async def set_review_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for new reviews.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).review_channel.set(channel.id) + await ctx.send(f"Review channel set to {channel.mention}") + + @kofiset.command(name="waitlistchannel") + async def set_waitlist_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for waitlist notifications.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).waitlist_channel.set(channel.id) + await ctx.send(f"Waitlist channel set to {channel.mention}") + +async def setup(bot: "Red"): + await bot.add_cog(KofiShop(bot)) diff --git a/logging/__init__.py b/logging/__init__.py new file mode 100644 index 0000000..1f518e8 --- /dev/null +++ b/logging/__init__.py @@ -0,0 +1,4 @@ +from .logging import Logging + +async def setup(bot): + await bot.add_cog(Logging(bot)) \ No newline at end of file diff --git a/logging/info.json b/logging/info.json new file mode 100644 index 0000000..455bc33 --- /dev/null +++ b/logging/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Logging cog! Please configure a log channel to begin.", + "name": "Logging", + "short": "A cog for comprehensive server event logging.", + "description": "Logs various server events (joins, leaves, message edits/deletes, etc.) to a designated channel for moderation and auditing purposes.", + "tags": [ + "logging", + "moderation", + "utility", + "events" + ], + "requirements": [], + "end_user_data_statement": "This cog does not persistently store any user data." +} \ No newline at end of file diff --git a/logging/logging.py b/logging/logging.py new file mode 100644 index 0000000..7abbbf6 --- /dev/null +++ b/logging/logging.py @@ -0,0 +1,276 @@ +import discord +from redbot.core import commands, Config +import datetime +from typing import Dict, Optional, Union + +class Logging(commands.Cog): + """ + A cog for comprehensive server event logging. + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567893, force_registration=True) + default_guild = { + "log_channel": None, + "logged_events": {}, # For DataManager + "enabled_events": { + "on_message_delete": True, + "on_message_edit": True, + "on_member_join": True, + "on_member_remove": True, + "on_member_update": True, + "on_voice_state_update": True, + "on_invite_create": True, + }, + } + self.config.register_guild(**default_guild) + + async def _send_log(self, guild: discord.Guild, embed: discord.Embed, event_name: str): + """Helper function to send logs.""" + enabled_events = await self.config.guild(guild).enabled_events() + if not enabled_events.get(event_name, False): + return + + log_channel_id = await self.config.guild(guild).log_channel() + if not log_channel_id: + return + + log_channel = guild.get_channel(log_channel_id) + if isinstance(log_channel, discord.TextChannel): + try: + log_message = await log_channel.send(embed=embed) + if event_name in ["on_message_delete", "on_message_edit"]: + async with self.config.guild(guild).logged_events() as events: + events[str(log_message.id)] = datetime.datetime.now( + datetime.timezone.utc + ).isoformat() + except discord.Forbidden: + pass # Bot lacks permissions + + # --- SETTINGS COMMANDS --- + @commands.group(aliases=["logset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def loggingset(self, ctx: commands.Context): + """Configure logging settings.""" + pass + + @loggingset.command(name="channel") + async def set_log_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where logs will be sent.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).log_channel.set(channel.id) + await ctx.send(f"Log channel has been set to {channel.mention}") + + @loggingset.command(name="toggle") + async def toggle_log_event(self, ctx: commands.Context, event: str): + """Toggle logging for a specific event.""" + if not ctx.guild: + return + + valid_events = list((await self.config.guild(ctx.guild).enabled_events()).keys()) + if event not in valid_events: + await ctx.send(f"Invalid event. Valid events are: `{'`, `'.join(valid_events)}`") + return + + async with self.config.guild(ctx.guild).enabled_events() as events: + current_status = events.get(event, False) + events[event] = not current_status + new_status = "enabled" if not current_status else "disabled" + await ctx.send(f"Logging for `{event}` has been {new_status}.") + + # --- EVENT LISTENERS --- + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message): + """Log when a message is deleted.""" + if message.author.bot or not message.guild: + return + + location = "" + if isinstance(message.channel, (discord.TextChannel, discord.Thread)): + location = message.channel.mention + elif isinstance(message.channel, discord.DMChannel): + location = f"DM with {message.channel.recipient}" + elif isinstance(message.channel, discord.GroupChannel): + location = f"Group DM ({message.channel.id})" + else: + location = f"Channel ID: {message.channel.id}" + + embed = discord.Embed( + description=f"**Message deleted in {location}**\n{message.content or 'No text content.'}", + color=discord.Color.red(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{message.author.name} ({message.author.id})", + icon_url=message.author.display_avatar.url, + ) + await self._send_log(message.guild, embed, "on_message_delete") + + @commands.Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message): + """Log when a message is edited.""" + if before.author.bot or not before.guild or before.content == after.content: + return + + location = "" + if isinstance(before.channel, (discord.TextChannel, discord.Thread)): + location = before.channel.mention + elif isinstance(before.channel, discord.DMChannel): + location = f"DM with {before.channel.recipient}" + elif isinstance(before.channel, discord.GroupChannel): + location = f"Group DM ({before.channel.id})" + else: + location = f"Channel ID: {before.channel.id}" + + embed = discord.Embed( + description=f"**Message edited in {location}** [Jump to Message]({after.jump_url})", + color=discord.Color.orange(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{before.author.name} ({before.author.id})", + icon_url=before.author.display_avatar.url, + ) + embed.add_field(name="Before", value=before.content or "Empty", inline=False) + embed.add_field(name="After", value=after.content or "Empty", inline=False) + await self._send_log(before.guild, embed, "on_message_edit") + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + """Log when a user joins the server.""" + embed = discord.Embed( + description=f"**{member.mention} has joined the server.**", + color=discord.Color.green(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{member.name} ({member.id})", icon_url=member.display_avatar.url + ) + await self._send_log(member.guild, embed, "on_member_join") + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + """Log when a user leaves the server.""" + embed = discord.Embed( + description=f"**{member.mention} has left the server.**", + color=discord.Color.dark_red(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{member.name} ({member.id})", icon_url=member.display_avatar.url + ) + await self._send_log(member.guild, embed, "on_member_remove") + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + """Log when a member's roles or nickname changes.""" + guild = after.guild + if before.nick != after.nick: + embed = discord.Embed( + description=f"**{after.mention} changed their nickname.**", + color=discord.Color.blue(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{after.name} ({after.id})", icon_url=after.display_avatar.url + ) + embed.add_field(name="Before", value=before.nick or "None", inline=False) + embed.add_field(name="After", value=after.nick or "None", inline=False) + await self._send_log(guild, embed, "on_member_update") + + if before.roles != after.roles: + added_roles = [r.mention for r in after.roles if r not in before.roles] + removed_roles = [r.mention for r in before.roles if r not in after.roles] + if added_roles or removed_roles: + embed = discord.Embed( + description=f"**{after.mention}'s roles were updated.**", + color=discord.Color.blue(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{after.name} ({after.id})", + icon_url=after.display_avatar.url, + ) + if added_roles: + embed.add_field( + name="Added Roles", value=", ".join(added_roles), inline=False + ) + if removed_roles: + embed.add_field( + name="Removed Roles", + value=", ".join(removed_roles), + inline=False, + ) + await self._send_log(guild, embed, "on_member_update") + + @commands.Cog.listener() + async def on_voice_state_update( + self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState + ): + """Log when a user's voice state changes.""" + if before.channel == after.channel: + return # Ignore mutes, deafens, etc. for now + + guild = member.guild + action = "" + if before.channel and not after.channel: + action = f"left voice channel {before.channel.mention}." + elif not before.channel and after.channel: + action = f"joined voice channel {after.channel.mention}." + elif before.channel and after.channel: + action = f"moved from {before.channel.mention} to {after.channel.mention}." + + if action: + embed = discord.Embed( + description=f"**{member.mention} {action}**", + color=discord.Color.light_grey(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{member.name} ({member.id})", icon_url=member.display_avatar.url + ) + await self._send_log(guild, embed, "on_voice_state_update") + + @commands.Cog.listener() + async def on_invite_create(self, invite: discord.Invite): + """Log when an invite is created.""" + guild = invite.guild + if not guild or not isinstance(guild, discord.Guild): + return + + inviter_str = "Unknown User" + if invite.inviter and isinstance(invite.inviter, (discord.User, discord.Member)): + inviter_str = invite.inviter.mention + elif invite.inviter: + inviter_str = f"User ({invite.inviter.id})" + + channel_str = "Unknown Channel" + if invite.channel and isinstance(invite.channel, (discord.abc.GuildChannel)): + if hasattr(invite.channel, 'mention'): + channel_str = invite.channel.mention + else: + channel_str = f"`{invite.channel.name}`" + elif invite.channel: + channel_str = f"Channel ID: {invite.channel.id}" + + + embed = discord.Embed( + title="Invite Created", + description=f"Invite `{invite.code}` to {channel_str} created by {inviter_str}.", + color=discord.Color.teal(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.add_field(name="Max Uses", value=invite.max_uses or "Unlimited") + embed.add_field( + name="Max Age", + value=f"{invite.max_age // 3600} hours" if invite.max_age else "Permanent", + ) + await self._send_log(guild, embed, "on_invite_create") + + +async def setup(bot): + await bot.add_cog(Logging(bot)) + diff --git a/modmail/modmail.py b/modmail/modmail.py index e69de29..3f73313 100644 --- a/modmail/modmail.py +++ b/modmail/modmail.py @@ -0,0 +1,90 @@ +import discord +import datetime +from redbot.core import commands, Config, app_commands +from typing import Optional + +class Modmail(commands.Cog): + """ + A private, forum-based ModMail system. + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567890, force_registration=True) + default_guild = { + "forum_channel": None, + "enabled": False, + "active_threads": {}, + "closed_threads": {} # NEW: To log closed tickets for purging + } + self.config.register_guild(**default_guild) + + # ... existing on_message listener ... + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.author.bot: + return + + # --- User to Staff DM Logic --- + if isinstance(message.channel, discord.DMChannel): + # ... existing user DM logic ... + pass + + # --- Staff to User Reply Logic --- + elif isinstance(message.channel, discord.Thread): + # ... existing staff reply logic ... + pass + + @app_commands.command(name="close") + @app_commands.guild_only() + async def modmail_close(self, interaction: discord.Interaction, *, reason: Optional[str] = "No reason provided."): + """Close the current ModMail ticket.""" + guild = interaction.guild + if not guild: + await interaction.response.send_message("This command can only be used in a server.", ephemeral=True) + return + + # NEW: Safely check if the interaction has a channel + if not interaction.channel: + await interaction.response.send_message("This command cannot be used in this context.", ephemeral=True) + return + + # ... existing logic to check if it's a modmail thread ... + + thread_id_str = str(interaction.channel.id) + async with self.config.guild(guild).active_threads() as active_threads: + user_id = active_threads.pop(thread_id_str, None) + + if user_id: + # NEW: Log the closed thread with a timestamp + async with self.config.guild(guild).closed_threads() as closed_threads: + closed_threads[thread_id_str] = datetime.datetime.now(datetime.timezone.utc).isoformat() + + # ... existing logic to send final message and archive thread ... + user = self.bot.get_user(int(user_id)) + if user: + embed = discord.Embed( + title="Ticket Closed", + description=f"Your ModMail ticket has been closed.\n**Reason:** {reason}", + color=0x8b9ed7 # Pastel Blue + ) + try: + await user.send(embed=embed) + except discord.Forbidden: + pass + + await interaction.response.send_message("Ticket has been closed and archived.", ephemeral=True) + if isinstance(interaction.channel, discord.Thread): + await interaction.channel.edit(archived=True, locked=True) + else: + await interaction.response.send_message("This does not appear to be an active ModMail ticket.", ephemeral=True) + + @commands.group(aliases=["mmset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def modmailset(self, ctx: commands.Context): + """Configure ModMail settings.""" + pass + + # ... existing modmailset subcommands ... + diff --git a/mors/__init__.py b/mors/__init__.py index e69de29..cae2379 100644 --- a/mors/__init__.py +++ b/mors/__init__.py @@ -0,0 +1,4 @@ +from .mors import MORS + +async def setup(bot): + await bot.add_cog(MORS(bot)) diff --git a/mors/info.json b/mors/info.json index b0c5a76..78fb280 100644 --- a/mors/info.json +++ b/mors/info.json @@ -1,9 +1,11 @@ { - "author": [ "unstableCogs" ], - "install_msg": "Thank you for installing the Mass Outreach & Review System! For a full command list, please see the wiki.", - "name": "MassOutreach", + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Mass Outreach & Review System cog!", + "name": "MORS", "short": "A multi-step system for mass outreach and reviews.", - "description": "Manages a complete workflow for user outreach. The process begins with /game, uses /over for time selection, and concludes with a /pudding-head command for the user to submit a mass review.", + "description": "Manages a complete workflow for user outreach, including ad submission, time selection, and final reviews.", "tags": [ "mass", "outreach", @@ -11,6 +13,6 @@ "tickets", "utility" ], - "requirements": [ "rich" ], - "end_user_data_statement": "This cog stores user IDs and ticket information temporarily for the duration of the outreach process. Submitted reviews are stored persistently." + "requirements": [], + "end_user_data_statement": "This cog stores user IDs, ticket information, and submitted reviews persistently for record-keeping purposes." } \ No newline at end of file diff --git a/mors/mors.py b/mors/mors.py index e69de29..847be9a 100644 --- a/mors/mors.py +++ b/mors/mors.py @@ -0,0 +1,444 @@ +import discord +import asyncio +from redbot.core import commands, Config, app_commands +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from redbot.core.bot import Red + +# --- UI Components --- + +class AdSubmissionModal(discord.ui.Modal, title="MORS Ad Submission"): + ad_text = discord.ui.TextInput( + label="Your Server Advertisement", + style=discord.TextStyle.paragraph, + placeholder="Please enter the full text of your ad here.", + required=True, + max_length=2000, + ) + ad_link = discord.ui.TextInput( + label="Your Server Invite Link", + placeholder="https://discord.gg/your-invite", + required=True, + max_length=100, + ) + + def __init__(self, cog: "MORS"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + await interaction.response.send_message("Something went wrong.", ephemeral=True) + return + + conf = self.cog.config.guild(guild) + ad_channel_id = await conf.ad_channel() + + if not ad_channel_id: + await interaction.response.send_message( + "The MORS system has not been fully configured by an administrator.", + ephemeral=True + ) + return + + ad_channel = guild.get_channel(ad_channel_id) + + if not isinstance(ad_channel, discord.TextChannel): + await interaction.response.send_message( + "The configured ad channel is invalid. Please contact an administrator.", + ephemeral=True + ) + return + + try: + thread = await ad_channel.create_thread( + name=f"mors-ticket-{interaction.user.name}", + type=discord.ChannelType.private_thread + ) + await thread.add_user(interaction.user) + except (discord.Forbidden, discord.HTTPException): + await interaction.response.send_message( + "I don't have permissions to create private threads in the ad channel.", + ephemeral=True + ) + return + + await thread.send(f"Welcome {interaction.user.mention}! Your MORS ticket has been created and is awaiting admin approval.") + await thread.send(f"For easy access on mobile, your thread ID is: `{thread.id}`") + + async with conf.active_tickets() as active_tickets: + active_tickets[str(interaction.user.id)] = { + "thread_id": thread.id, + "ad_text": self.ad_text.value, + "ad_link": self.ad_link.value, + "status": "pending_approval" + } + + await interaction.response.send_message("Your submission has been received and is awaiting approval!", ephemeral=True) + +class TimeSelect(discord.ui.Select): + def __init__(self, cog: "MORS"): + self.cog = cog + options = [ + discord.SelectOption(label="30 Minutes", value="30m"), + discord.SelectOption(label="1 Hour", value="1h"), + discord.SelectOption(label="2 Hours", value="2h"), + discord.SelectOption(label="3 Hours", value="3h"), + discord.SelectOption(label="4 Hours (Bypass Only)", value="4h"), + discord.SelectOption(label="Once a Week (Bypass Only)", value="ovn"), + ] + super().__init__(placeholder="Select the advertisement duration...", min_values=1, max_values=1, options=options) + + async def callback(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild or not isinstance(interaction.user, discord.Member): + await interaction.response.send_message("An error occurred.", ephemeral=True) + return + + member = interaction.user + selected_time = self.values[0] + conf = self.cog.config.guild(guild) + + # Logic for bypass role and cooldowns will be added here + bypass_role_id = await conf.bypass_role() + has_bypass = False + if bypass_role_id: + bypass_role = guild.get_role(bypass_role_id) + if bypass_role and bypass_role in member.roles: + has_bypass = True + + if selected_time in ["4h", "ovn"] and not has_bypass: + await interaction.response.send_message("You do not have the required role for this duration.", ephemeral=True) + return + + async with conf.active_tickets() as active_tickets: + user_id_str = str(interaction.user.id) + if user_id_str not in active_tickets: + await interaction.response.send_message("You don't have an active MORS ticket.", ephemeral=True) + return + + thread_id = active_tickets[user_id_str]["thread_id"] + thread = guild.get_thread(thread_id) + + if not thread: + await interaction.response.send_message("Could not find your ticket thread.", ephemeral=True) + del active_tickets[user_id_str] + return + + try: + await thread.edit(name=f"n2p-{selected_time}-{interaction.user.name}") + # Logic to add the role will go here once the role ID is configured + except discord.Forbidden: + await interaction.response.send_message("I don't have permission to rename your ticket thread.", ephemeral=True) + return + + # Send confirmation messages + queue_channel_id = await conf.queue_channel() + if queue_channel_id: + queue_channel = guild.get_channel(queue_channel_id) + if isinstance(queue_channel, discord.TextChannel): + queue_link = f"https://discord.com/channels/{guild.id}/{queue_channel.id}" + await queue_channel.send(f"{thread.mention} :mooncatblue: {interaction.user.mention} queued for __{selected_time}__ *!*") + await thread.send(f":81407babybluebutterfly: you're in the [queue]({queue_link}) *!* \n-# **p.s using invites command is __required__ for sep to state invites. no reply to pings within 3 days or inv cmnd done will get you suspended from massing.**") + + await interaction.response.send_message(f"You have selected: {selected_time}", ephemeral=True) + if self.view: + self.view.stop() + + +class TimeSelectionView(discord.ui.View): + def __init__(self, cog: "MORS"): + super().__init__(timeout=180) + self.add_item(TimeSelect(cog)) + +class ReviewModal(discord.ui.Modal, title="MORS Review Submission"): + service_type = discord.ui.TextInput(label="What service type did you use?") + rating = discord.ui.TextInput(label="Rating (1-10)", max_length=2) + review_text = discord.ui.TextInput(label="Your Review", style=discord.TextStyle.paragraph, max_length=1500) + toxicity = discord.ui.TextInput(label="Toxicity Level (ntox/stox)") + + def __init__(self, cog: "MORS"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + await interaction.response.send_message("An error occurred.", ephemeral=True) + return + + review_channel_id = await self.cog.config.guild(guild).review_channel() + if not review_channel_id: + await interaction.response.send_message("The review channel has not been configured.", ephemeral=True) + return + + review_channel = guild.get_channel(review_channel_id) + if not isinstance(review_channel, discord.TextChannel): + await interaction.response.send_message("The configured review channel is invalid.", ephemeral=True) + return + + embed = discord.Embed( + title="New MORS Review", + color=discord.Color.green() + ) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Service Type", value=self.service_type.value, inline=False) + embed.add_field(name="Rating", value=f"{self.rating.value}/10", inline=True) + embed.add_field(name="Toxicity", value=self.toxicity.value, inline=True) + embed.add_field(name="Review", value=self.review_text.value, inline=False) + + try: + await review_channel.send(embed=embed) + except discord.Forbidden: + # This would only happen if permissions changed mid-process + pass + + await interaction.response.send_message("Your review has been submitted! This ticket will close shortly.", ephemeral=True) + +class MORS(commands.Cog): + """ + Mass Outreach & Review System + """ + + def __init__(self, bot: "Red"): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567894, force_registration=True) + default_guild = { + "ad_channel": None, + "review_channel": None, + "done_channel": None, + "queue_channel": None, # Added for queue messages + "access_role": None, + "bypass_role": None, + "active_tickets": {} + } + self.config.register_guild(**default_guild) + + # --- User Commands --- + @app_commands.command() + @app_commands.guild_only() + async def game(self, interaction: discord.Interaction): + """Start the mass outreach process by submitting an ad.""" + guild = interaction.guild + if not guild: + return + + if not isinstance(interaction.user, discord.Member): + await interaction.response.send_message("This command can only be used by server members.", ephemeral=True) + return + + member = interaction.user + access_role_id = await self.config.guild(guild).access_role() + if access_role_id: + access_role = guild.get_role(access_role_id) + if access_role and access_role not in member.roles: + await interaction.response.send_message( + f"You need the {access_role.name} role to use this command.", + ephemeral=True + ) + return + + modal = AdSubmissionModal(cog=self) + await interaction.response.send_modal(modal) + + @app_commands.command() + @app_commands.guild_only() + async def over(self, interaction: discord.Interaction): + """Select your advertisement duration.""" + guild = interaction.guild + if not guild: + return + + active_tickets = await self.config.guild(guild).active_tickets() + if str(interaction.user.id) not in active_tickets: + await interaction.response.send_message( + "You need to start the process with `/game` before you can use this command.", + ephemeral=True + ) + return + + view = TimeSelectionView(cog=self) + await interaction.response.send_message("Please select your desired ad duration from the dropdown below:", view=view, ephemeral=True) + + @app_commands.command(name="pudding-head") + @app_commands.guild_only() + async def pudding_head(self, interaction: discord.Interaction): + """Submit your final review for the MORS process.""" + guild = interaction.guild + if not guild: + return + + active_tickets = await self.config.guild(guild).active_tickets() + if str(interaction.user.id) not in active_tickets: + await interaction.response.send_message( + "You do not have an active MORS ticket to review.", + ephemeral=True + ) + return + + modal = ReviewModal(cog=self) + await interaction.response.send_modal(modal) + + # --- Admin Commands --- + @app_commands.command() + @app_commands.guild_only() + @app_commands.describe(member="The user whose ad you want to post.") + @app_commands.default_permissions(manage_guild=True) + async def posted(self, interaction: discord.Interaction, member: discord.Member): + """Approves and posts a user's submitted ad.""" + guild = interaction.guild + if not guild: + return + + conf = self.config.guild(guild) + ad_channel_id = await conf.ad_channel() + if not ad_channel_id: + await interaction.response.send_message("Ad channel not configured.", ephemeral=True) + return + + ad_channel = guild.get_channel(ad_channel_id) + if not isinstance(ad_channel, discord.TextChannel): + await interaction.response.send_message("Configured ad channel is invalid.", ephemeral=True) + return + + async with conf.active_tickets() as active_tickets: + user_id_str = str(member.id) + if user_id_str not in active_tickets: + await interaction.response.send_message(f"{member.name} has no pending ad.", ephemeral=True) + return + + ticket_data = active_tickets[user_id_str] + ad_embed = discord.Embed( + title="New Server Advertisement", + description=ticket_data.get("ad_text", "No ad text provided."), + color=discord.Color.blue() + ) + ad_embed.add_field(name="Invite Link", value=ticket_data.get("ad_link", "No link provided.")) + ad_embed.set_author(name=member.name, icon_url=member.display_avatar.url) + + try: + ad_message = await ad_channel.send(embed=ad_embed) + await ad_message.pin() + active_tickets[user_id_str]["status"] = "posted" + except discord.Forbidden: + await interaction.response.send_message("I lack permissions to post or pin in the ad channel.", ephemeral=True) + return + + await interaction.response.send_message(f"Ad for {member.name} has been posted and pinned. Remember to set a reminder.", ephemeral=True) + + + @app_commands.command() + @app_commands.guild_only() + @app_commands.describe(member="The user whose ticket you want to finalize.") + @app_commands.default_permissions(manage_guild=True) + async def done(self, interaction: discord.Interaction, member: discord.Member): + """Admin command to finalize and close a MORS ticket.""" + guild = interaction.guild + if not guild: + return + + conf = self.config.guild(guild) + done_channel_id = await conf.done_channel() + if not done_channel_id: + await interaction.response.send_message("The 'done' channel is not configured.", ephemeral=True) + return + + done_channel = guild.get_channel(done_channel_id) + if not isinstance(done_channel, discord.TextChannel): + await interaction.response.send_message("The configured 'done' channel is invalid.", ephemeral=True) + return + + async with conf.active_tickets() as active_tickets: + user_id_str = str(member.id) + if user_id_str not in active_tickets: + await interaction.response.send_message(f"{member.name} does not have an active MORS ticket.", ephemeral=True) + return + + ticket_data = active_tickets.pop(user_id_str) # Remove ticket from active list + thread_id = ticket_data.get("thread_id") + ad_link = ticket_data.get("ad_link", "Not available") + + thread = guild.get_thread(thread_id) if thread_id else None + + # Send embed to done channel + done_embed = discord.Embed( + description=f"Advertisement period is complete for {member.mention}.", + color=discord.Color.teal() + ) + done_embed.add_field(name="Server Ad Link", value=ad_link, inline=False) + # Client mentioned "image", will need clarification on what image to include. + + await done_channel.send(embed=done_embed) + + if thread: + await thread.send(f"{member.mention}, your MORS process is complete! Please submit your review with `/pudding-head`.") + await thread.send("This ticket will be archived in 60 seconds.") + await interaction.response.send_message(f"Finalizing ticket for {member.name}. The thread will be archived in 60 seconds.", ephemeral=True) + + await asyncio.sleep(60) + try: + await thread.edit(archived=True, locked=True) + except discord.Forbidden: + await thread.send("I don't have permission to archive this thread.") + else: + await interaction.response.send_message(f"Finalizing ticket for {member.name}. Could not find original thread to archive.", ephemeral=True) + + + # --- Settings Commands --- + @commands.group() # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def morsset(self, ctx: commands.Context): + """Configure MORS settings.""" + pass + + @morsset.command(name="adchannel") + async def set_ad_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where ads are posted.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).ad_channel.set(channel.id) + await ctx.send(f"Ad channel has been set to {channel.mention}") + + @morsset.command(name="reviewchannel") + async def set_review_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where final reviews are sent.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).review_channel.set(channel.id) + await ctx.send(f"Review channel has been set to {channel.mention}") + + @morsset.command(name="donechannel") + async def set_done_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for the /done command embeds.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).done_channel.set(channel.id) + await ctx.send(f"Done channel has been set to {channel.mention}") + + @morsset.command(name="queuechannel") + async def set_queue_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for queue confirmation messages.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).queue_channel.set(channel.id) + await ctx.send(f"Queue channel has been set to {channel.mention}") + + @morsset.command(name="accessrole") + async def set_access_role(self, ctx: commands.Context, role: discord.Role): + """Set the role required to use the /game command.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).access_role.set(role.id) + await ctx.send(f"Access role has been set to {role.name}") + + @morsset.command(name="bypassrole") + async def set_bypass_role(self, ctx: commands.Context, role: discord.Role): + """Set the bypass role for MORS cooldowns.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).bypass_role.set(role.id) + await ctx.send(f"Bypass role has been set to {role.name}") + diff --git a/servicereview/__init__.py b/servicereview/__init__.py index e69de29..6546677 100644 --- a/servicereview/__init__.py +++ b/servicereview/__init__.py @@ -0,0 +1,4 @@ +from .servicereview import ServiceReview + +async def setup(bot): + await bot.add_cog(ServiceReview(bot)) \ No newline at end of file diff --git a/servicereview/iservice.py b/servicereview/iservice.py deleted file mode 100644 index e69de29..0000000 diff --git a/servicereview/servicereview.py b/servicereview/servicereview.py new file mode 100644 index 0000000..6abb5fe --- /dev/null +++ b/servicereview/servicereview.py @@ -0,0 +1,116 @@ +import discord +from redbot.core import commands, Config, app_commands +from typing import Optional +import datetime + +# --- Modal for the Review Form --- + +class ReviewModal(discord.ui.Modal, title="Service Review"): + service_type = discord.ui.TextInput( + label="Service Type", + placeholder="e.g., HPM, Staff, PM, Commission, etc." + ) + rating = discord.ui.TextInput( + label="Rating (1-10)", + placeholder="Please enter a number from 1 to 10.", + max_length=2 + ) + review_text = discord.ui.TextInput( + label="Your Review", + style=discord.TextStyle.paragraph, + placeholder="Please provide details about your experience.", + max_length=1500 + ) + toxicity = discord.ui.TextInput( + label="Toxicity Level (ntox/stox)", + placeholder="ntox (non-toxic) or stox (semi-toxic)" + ) + + def __init__(self, cog: "ServiceReview"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + # This should not happen due to guild_only decorator + return + + channel_id = await self.cog.config.guild(guild).review_channel() + if not channel_id: + await interaction.response.send_message( + "The review channel has not been configured by an administrator.", + ephemeral=True + ) + return + + channel = guild.get_channel(channel_id) + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message( + "The configured review channel is invalid. Please contact an administrator.", + ephemeral=True + ) + return + + embed = discord.Embed(title="New Service Review", color=discord.Color.purple()) + embed.set_author(name=f"{interaction.user.name} ({interaction.user.id})", icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Service Type", value=self.service_type.value, inline=False) + embed.add_field(name="Rating", value=f"{self.rating.value}/10", inline=True) + embed.add_field(name="Toxicity", value=self.toxicity.value, inline=True) + embed.add_field(name="Review", value=self.review_text.value, inline=False) + embed.set_footer(text=f"User ID: {interaction.user.id}") + + try: + review_message = await channel.send(embed=embed) + # **FIX:** Log the timestamp for the DataManager + async with self.cog.config.guild(guild).submitted_reviews() as reviews: + reviews[str(review_message.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() + + await interaction.response.send_message("Thank you! Your review has been submitted successfully.", ephemeral=True) + except discord.Forbidden: + await interaction.response.send_message( + "I don't have permission to send messages in the review channel. Please contact an administrator.", + ephemeral=True + ) + except discord.HTTPException as e: + await interaction.response.send_message(f"An error occurred while trying to send your review: {e}", ephemeral=True) + +# --- Main Cog Class --- + +class ServiceReview(commands.Cog): + """A cog for users to leave service reviews for staff.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567891, force_registration=True) + default_guild = { + "review_channel": None, + "submitted_reviews": {} # **FIX:** Added for DataManager logging + } + self.config.register_guild(**default_guild) + + @app_commands.command(name="srev") + @app_commands.guild_only() + async def service_review(self, interaction: discord.Interaction): + """Leave a review for a staff member or service.""" + modal = ReviewModal(self) + await interaction.response.send_modal(modal) + + @commands.group(aliases=["srset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def srevset(self, ctx: commands.Context): + """Configure ServiceReview settings.""" + pass + + @srevset.command(name="channel") + async def set_review_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where service reviews will be sent.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).review_channel.set(channel.id) + await ctx.send(f"Service review channel has been set to {channel.mention}") + +async def setup(bot): + await bot.add_cog(ServiceReview(bot)) + diff --git a/staffmsg/__init__.py b/staffmsg/__init__.py new file mode 100644 index 0000000..0b78d00 --- /dev/null +++ b/staffmsg/__init__.py @@ -0,0 +1,4 @@ +from .staffmsg import StaffMsg + +async def setup(bot): + await bot.add_cog(StaffMsg(bot)) \ No newline at end of file diff --git a/staffmsg/info.json b/staffmsg/info.json new file mode 100644 index 0000000..b42366f --- /dev/null +++ b/staffmsg/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Staff Messaging cog!", + "name": "StaffMsg", + "short": "A command for staff to send official DMs.", + "description": "Provides a permission-controlled command for staff to send direct messages to server members, with logging for accountability.", + "tags": [ + "dm", + "messaging", + "staff", + "utility" + ], + "requirements": [], + "end_user_data_statement": "This cog may store message content and user IDs in a log file or channel for moderation purposes." +} \ No newline at end of file diff --git a/staffmsg/staffmsg.py b/staffmsg/staffmsg.py new file mode 100644 index 0000000..9149cf0 --- /dev/null +++ b/staffmsg/staffmsg.py @@ -0,0 +1,188 @@ +import discord +from redbot.core import commands, Config, app_commands +from typing import Optional +import datetime + +# --- Modal for the Message Form --- + +class MessageModal(discord.ui.Modal, title="Staff Message"): + message_content = discord.ui.TextInput( + label="Message to Send", + style=discord.TextStyle.paragraph, + placeholder="Type the official message you want to send to the user here.", + max_length=1800 + ) + + def __init__(self, cog: "StaffMsg", target_user: discord.Member): + super().__init__() + self.cog = cog + self.target_user = target_user + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + return + + # --- Send DM to User --- + embed_to_user = discord.Embed( + title=f"A Message from the Staff of {guild.name}", + description=self.message_content.value, + color=await self.cog.bot.get_embed_color(interaction.channel) if interaction.channel else discord.Color.blurple() + ) + embed_to_user.set_footer(text="This is an official communication. Please do not reply to the bot.") + + try: + await self.target_user.send(embed=embed_to_user) + except discord.Forbidden: + await interaction.response.send_message( + f"I could not send a DM to {self.target_user.mention}. They may have DMs disabled.", + ephemeral=True + ) + return + except discord.HTTPException as e: + await interaction.response.send_message(f"An error occurred while sending the DM: {e}", ephemeral=True) + return + + # --- Log the Message --- + log_channel_id = await self.cog.config.guild(guild).log_channel() + if log_channel_id: + log_channel = guild.get_channel(log_channel_id) + if isinstance(log_channel, discord.TextChannel): + log_embed = discord.Embed( + title="Staff DM Sent", + description=self.message_content.value, + color=discord.Color.blue() + ) + log_embed.set_author(name=f"From: {interaction.user.name} ({interaction.user.id})", icon_url=interaction.user.display_avatar.url) + log_embed.add_field(name="To", value=f"{self.target_user.mention} ({self.target_user.id})") + + try: + log_message = await log_channel.send(embed=log_embed) + async with self.cog.config.guild(guild).sent_dms() as dms: + dms[str(log_message.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() + except discord.Forbidden: + pass + + await interaction.response.send_message(f"Your message has been successfully sent to {self.target_user.mention}.", ephemeral=True) + + +# --- Main Cog Class --- + +class StaffMsg(commands.Cog): + """A cog for staff to send official DMs to users.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567892, force_registration=True) + default_guild = { + "log_channel": None, + "authorized_role": None, + "sent_dms": {} # For DataManager + } + self.config.register_guild(**default_guild) + + async def cog_check(self, ctx: commands.Context) -> bool: + if not ctx.guild: + await ctx.send("This command can only be used in a server.", ephemeral=True) + return False + + auth_role_id = await self.config.guild(ctx.guild).authorized_role() + if not auth_role_id: + await ctx.send("The authorized role for this command has not been set.", ephemeral=True) + return False + + author = ctx.author + if not isinstance(author, discord.Member): + return False + + if author.guild_permissions.administrator: + return True + + auth_role = ctx.guild.get_role(auth_role_id) + if not auth_role or auth_role not in author.roles: + await ctx.send("You are not authorized to use this command.", ephemeral=True) + return False + + return True + + @commands.hybrid_command(name="smsg", aliases=["staff", "staffmsg"]) + @app_commands.describe(user="The user to send a DM to.", message="(Optional) The message to send. Opens a form if left blank.") + @app_commands.guild_only() + async def staff_message(self, ctx: commands.Context, user: discord.Member, *, message: Optional[str] = None): + """Send an official DM to a user. Opens a form if no message is provided.""" + + # This is a clever way to handle both slash and prefix commands. + # If the 'message' is None, it means it was likely a slash command + # or a prefix command with no text, so we open the modal. + if message is None: + if not ctx.interaction: + await ctx.send("Please provide a message to send.", ephemeral=True) + return + modal = MessageModal(self, user) + await ctx.interaction.response.send_modal(modal) + return + + guild = ctx.guild + if not guild: # Should be caught by cog_check but for type safety + return + + embed_to_user = discord.Embed( + title=f"A Message from the Staff of {guild.name}", + description=message, + color=await ctx.embed_color() + ) + embed_to_user.set_footer(text="This is an official communication. Please do not reply to the bot.") + + try: + await user.send(embed=embed_to_user) + except discord.Forbidden: + await ctx.send(f"I could not send a DM to {user.mention}. They may have DMs disabled.", ephemeral=True) + return + + log_channel_id = await self.config.guild(guild).log_channel() + if log_channel_id: + log_channel = guild.get_channel(log_channel_id) + if isinstance(log_channel, discord.TextChannel): + log_embed = discord.Embed( + title="Staff DM Sent", + description=message, + color=discord.Color.blue() + ) + log_embed.set_author(name=f"From: {ctx.author.name} ({ctx.author.id})", icon_url=ctx.author.display_avatar.url) + log_embed.add_field(name="To", value=f"{user.mention} ({user.id})") + try: + log_message = await log_channel.send(embed=log_embed) + async with self.config.guild(guild).sent_dms() as dms: + dms[str(log_message.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() + except discord.Forbidden: + pass + + await ctx.send(f"Your message has been sent to {user.mention}.", ephemeral=True) + + + @commands.group(aliases=["smset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def staffmsgset(self, ctx: commands.Context): + """Configure StaffMsg settings.""" + pass + + @staffmsgset.command(name="role") + async def set_auth_role(self, ctx: commands.Context, role: discord.Role): + """Set the role authorized to use the staff message commands.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).authorized_role.set(role.id) + await ctx.send(f"Authorized role has been set to {role.mention}") + + @staffmsgset.command(name="logchannel") + async def set_log_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where sent DMs will be logged.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).log_channel.set(channel.id) + await ctx.send(f"Log channel has been set to {channel.mention}") + +async def setup(bot): + await bot.add_cog(StaffMsg(bot)) + diff --git a/translator/commands.py b/translator/commands.py new file mode 100644 index 0000000..9225ef8 --- /dev/null +++ b/translator/commands.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +import discord +import json +from typing import Optional +from redbot.core import commands, app_commands +from redbot.core.utils.chat_formatting import box, pagify + +class CommandsMixin: + """This class holds all the commands for the cog.""" + + @commands.hybrid_command(aliases=["ts"]) + @app_commands.describe( + to_language="The language to translate to.", + text="The text to translate. For prefix commands, wrap multi-word text in quotes.", + from_language="[Optional] The language to translate from. Defaults to Common." + ) + async def translate(self, ctx: commands.Context, to_language: str, text: str, from_language: Optional[str] = None): + """Translates text from one language to another.""" + + from_lang_name = from_language if from_language else "common" + + from_matches = await self._find_language(from_lang_name) + if not from_matches: + return await ctx.send(f"Could not find the 'from' language: `{from_lang_name}`") + if len(from_matches) > 1: + possible = [f"`{self.all_languages[k]['name']}` (`{k}`)" for k in from_matches] + return await ctx.send(f"Multiple 'from' languages found for `{from_lang_name}`. Please be more specific:\n" + ", ".join(possible)) + from_lang_key = from_matches[0] + + to_matches = await self._find_language(to_language) + if not to_matches: + return await ctx.send(f"Could not find the 'to' language: `{to_language}`") + if len(to_matches) > 1: + possible = [f"`{self.all_languages[k]['name']}` (`{k}`)" for k in to_matches] + return await ctx.send(f"Multiple 'to' languages found for `{to_language}`. Please be more specific:\n" + ", ".join(possible)) + to_lang_key = to_matches[0] + + from_lang_obj = self.all_languages[from_lang_key] + to_lang_obj = self.all_languages[to_lang_key] + + try: + common_text = from_lang_obj['from_func'](text) + translated_text = to_lang_obj['to_func'](common_text) + except Exception as e: + await ctx.send(f"An error occurred during translation: `{e}`") + return + + webhook = None + if ctx.guild and ctx.guild.me.guild_permissions.manage_webhooks: + for wh in await ctx.channel.webhooks(): + if wh.name == "Translator Cog Webhook": + webhook = wh + break + if webhook is None: + webhook = await ctx.channel.create_webhook(name="Translator Cog Webhook") + + if webhook: + if ctx.interaction: + await ctx.interaction.response.defer(ephemeral=True) + await webhook.send(content=translated_text, username=ctx.author.display_name, avatar_url=ctx.author.display_avatar.url) + await ctx.interaction.followup.send("Translation sent.", ephemeral=True) + else: + if ctx.channel.permissions_for(ctx.guild.me).manage_messages: + try: + await ctx.message.delete() + except discord.Forbidden: + pass + await webhook.send(content=translated_text, username=ctx.author.display_name, avatar_url=ctx.author.display_avatar.url) + else: + embed = discord.Embed(title=f"Translation to {to_lang_obj['name']}", color=await ctx.embed_color()) + embed.add_field(name="Original Text", value=box(text), inline=False) + embed.add_field(name="Translated Text", value=box(translated_text), inline=False) + await ctx.send(embed=embed) + + @commands.hybrid_command() + async def languages(self, ctx: commands.Context): + """Lists all available languages.""" + sorted_langs = sorted(self.all_languages.values(), key=lambda x: x['name']) + lang_list = [f"* `{lang['name']}`" for lang in sorted_langs] + output = "Available Languages:\n" + "\n".join(lang_list) + pages = [box(page) for page in pagify(output, page_length=1000)] + await ctx.send_interactive(pages, box_lang="md") + + @commands.group(aliases=["px"], invoke_without_command=True) + async def proxy(self, ctx: commands.Context): + """Toggles your translation proxy on or off.""" + current_setting = await self.config.user(ctx.author).proxy_enabled() + new_setting = not current_setting + await self.config.user(ctx.author).proxy_enabled.set(new_setting) + status = "enabled" if new_setting else "disabled" + await ctx.send(f"Proxying is now `{status}`.") + + @proxy.command(name="list") + async def proxy_list(self, ctx: commands.Context): + """Shows your currently registered sonas.""" + sonas = await self.config.user(ctx.author).sonas() + if not sonas: + return await ctx.send("You have no sonas registered.") + + msg = "Your registered sonas:\n" + for name, data in sonas.items(): + display_name = data.get("display_name", name) + lang_name = self.all_languages.get(data['language'], {}).get('name', 'Unknown Language') + + if 'claws' in data: + start_claw, end_claw = data['claws'] + elif 'brackets' in data: # Fallback for old data + start_claw, end_claw = data['brackets'] + else: + continue + + claw_info = f"Starts with `{start_claw}`" if not end_claw else f"`{start_claw}` and `{end_claw}`" + msg += f" - **{display_name}** (Internal Name: `{name}`): Translates to `{lang_name}`. Claws: {claw_info}\n" + + for page in pagify(msg): + await ctx.send(page) + + @proxy.command(name="add", aliases=["+"]) + async def proxy_add(self, ctx: commands.Context, name: str, language: str, display_name: str, start_claw: str, end_claw: str = "", avatar: Optional[str] = None): + """Registers a new sona with a name and avatar.""" + matches = await self._find_language(language) + if not matches: + return await ctx.send(f"Language `{language}` not found.") + if len(matches) > 1: + possible = [f"`{self.all_languages[k]['name']}` (`{k}`)" for k in matches] + return await ctx.send(f"Multiple languages found for `{language}`. Please be more specific:\n" + ", ".join(possible)) + + lang_key = matches[0] + sona_key = name.lower() + + avatar_url = avatar + if not avatar_url and ctx.message.attachments: + avatar_url = ctx.message.attachments[0].url + + async with self.config.user(ctx.author).sonas() as sonas: + if sona_key in sonas: + return await ctx.send(f"A sona named `{name}` already exists.") + sonas[sona_key] = { + "display_name": display_name, + "avatar_url": avatar_url, + "language": lang_key, + "claws": [start_claw, end_claw] + } + + await ctx.send(f"Sona `{name}` registered as `{display_name}` to translate to `{self.all_languages[lang_key]['name']}`.") + + @proxy.command(name="remove", aliases=["-"]) + async def proxy_remove(self, ctx: commands.Context, *, name: str): + """Removes a sona.""" + sona_key = name.lower() + async with self.config.user(ctx.author).sonas() as sonas: + if sona_key not in sonas: + return await ctx.send(f"No sona named `{name}` found.") + del sonas[sona_key] + await ctx.send(f"Sona `{name}` has been removed.") + + @proxy.group() + async def sona(self, ctx: commands.Context): + """Commands for managing a sona's appearance.""" + pass + + @proxy.group(name="auto") + async def proxy_auto(self, ctx: commands.Context): + """Manage automatic translation in a channel.""" + pass + + @proxy_auto.command(name="set") + async def proxy_auto_set(self, ctx: commands.Context, sona_name: str): + """Sets a sona to automatically translate your messages in this channel.""" + sona_key = sona_name.lower() + sonas = await self.config.user(ctx.author).sonas() + if sona_key not in sonas: + return await ctx.send(f"No sona named `{sona_name}` found. Please register it first.") + + async with self.config.channel(ctx.channel).autotranslate_users() as autotranslate_users: + autotranslate_users[str(ctx.author.id)] = sona_key + + await ctx.send(f"Autotranslation enabled for you in this channel as **{sonas[sona_key]['display_name']}**.") + + @proxy_auto.command(name="off") + async def proxy_auto_off(self, ctx: commands.Context): + """Disables autotranslation for you in this channel.""" + async with self.config.channel(ctx.channel).autotranslate_users() as autotranslate_users: + if str(ctx.author.id) in autotranslate_users: + del autotranslate_users[str(ctx.author.id)] + await ctx.send("Autotranslation has been disabled for you in this channel.") + else: + await ctx.send("You do not have autotranslation enabled in this channel.") + + @sona.command(name="name") + async def sona_name(self, ctx: commands.Context, name: str, *, display_name: str): + """Changes the display name of a sona.""" + sona_key = name.lower() + async with self.config.user(ctx.author).sonas() as sonas: + if sona_key not in sonas: + return await ctx.send(f"No sona named `{name}` found.") + sonas[sona_key]["display_name"] = display_name + await ctx.send(f"Sona `{name}`'s display name has been changed to `{display_name}`.") + + @sona.command(name="avatar") + async def sona_avatar(self, ctx: commands.Context, name: str, url: Optional[str] = None): + """Changes the avatar of a sona. + + You can either provide a direct image URL or upload an image with the command. + """ + sona_key = name.lower() + + if not url and not ctx.message.attachments: + return await ctx.send("You must provide an image URL or upload an image.") + + avatar_url = url + if ctx.message.attachments: + avatar_url = ctx.message.attachments[0].url + + async with self.config.user(ctx.author).sonas() as sonas: + if sona_key not in sonas: + return await ctx.send(f"No sona named `{name}` found.") + sonas[sona_key]["avatar_url"] = avatar_url + + await ctx.send(f"Sona `{name}`'s avatar has been updated.") + + + @commands.group(aliases=["tset"]) + @commands.has_permissions(manage_guild=True) + async def translatorset(self, ctx: commands.Context): + """Admin commands for the Translator cog.""" + pass + + @translatorset.group(name="language", aliases=["lang"]) + async def translatorset_language(self, ctx: commands.Context): + """Manage custom languages.""" + pass + + @translatorset_language.command(name="add", aliases=["+"]) + async def translatorset_language_add(self, ctx: commands.Context, name: str, *, json_map: str): + """Adds a new custom language.""" + lang_key = name.lower() + if lang_key in self.all_languages: + return await ctx.send(f"A language with the key `{lang_key}` already exists.") + + try: + lang_map = json.loads(json_map) + if not isinstance(lang_map, dict): + raise ValueError("JSON map must be an object/dictionary.") + except (json.JSONDecodeError, ValueError) as e: + return await ctx.send(f"Invalid JSON map provided: `{e}`") + + new_lang_data = {'name': name.capitalize(), 'type': 'greedy', 'map': lang_map, 'is_custom': True} + + async with self.config.languages() as languages: + languages[lang_key] = new_lang_data + + await self._initialize_languages() + await ctx.send(f"Custom language `{name}` added successfully.") + + @translatorset_language.command(name="remove", aliases=["-"]) + async def translatorset_language_remove(self, ctx: commands.Context, name: str): + """Removes a custom language.""" + lang_key = name.lower() + current_data = await self.config.languages() + lang_obj = current_data.get(lang_key) + + if not lang_obj or not lang_obj.get('is_custom', False): + return await ctx.send(f"No custom language named `{name}` found.") + + async with self.config.languages() as languages: + if lang_key in languages: + del languages[lang_key] + + await self._initialize_languages() + await ctx.send(f"Custom language `{name}` removed.") + + @translatorset_language.command(name="listcustom") + async def translatorset_language_listcustom(self, ctx: commands.Context): + """Lists all custom-added languages.""" + custom_langs = [f"`{lang['name']}`" for lang in self.all_languages.values() if lang.get('is_custom')] + if not custom_langs: + return await ctx.send("There are no custom languages.") + await ctx.send("Custom Languages:\n" + ", ".join(custom_langs)) + + @translatorset_language.command(name="listbase") + async def translatorset_language_listbase(self, ctx: commands.Context): + """Lists all base (built-in) languages.""" + base_langs = [f"`{lang['name']}`" for lang in self.all_languages.values() if not lang.get('is_custom')] + await ctx.send("Base Languages:\n" + ", ".join(base_langs)) + diff --git a/translator/languages/__init__.py b/translator/languages/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/translator/languages/__init__.py @@ -0,0 +1 @@ + diff --git a/translator/languages/abyssal.py b/translator/languages/abyssal.py new file mode 100644 index 0000000..587db8c --- /dev/null +++ b/translator/languages/abyssal.py @@ -0,0 +1,8 @@ +MAP = {'a':'azg','b':'braz','c':'kraz','d':'dorg','e':'ezg','f':'fraz','g':'gor','h':'hath','i':'ix','j':'jraz','k':'kral','l':'laz','m':'maz','n':'naz','o':'oz','p':'praz','q':'qor','r':'raz','s':'saz','t':'taz','u':'uzg','v':'vraz','w':'waz','x':'xul','y':'yaz','z':'zaz'} + +def get_language(): + return { + 'name': 'Abyssal', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/angelic.py b/translator/languages/angelic.py new file mode 100644 index 0000000..2cbdbcb --- /dev/null +++ b/translator/languages/angelic.py @@ -0,0 +1,8 @@ +MAP = {'a':'adriel','b':'baraqiel','c':'camael','d':'divinus','e':'elohim','f':'fanuel','g':'gloria','h':'hesed','i':'israfel','j':'jophiel','k':'kyrie','l':'lux','m':'michael','n':'netzach','o':'ophaniel','p':'peniel','q':'qadish','r':'raphael','s':'seraph','t':'tiferet','u':'uriel','v':'virtues','w':'wele\'el','x':'xathanael','y':'yesod','z':'zadkiel'} + +def get_language(): + return { + 'name': 'Angelic', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/aquan.py b/translator/languages/aquan.py new file mode 100644 index 0000000..384103e --- /dev/null +++ b/translator/languages/aquan.py @@ -0,0 +1,9 @@ +MAP = {'a':'aqua','b':'blub','c':'\'cress','d':'drop','e':'eelee','f':'flow','g':'glur','h':'hydro','i':'ishi','j':'\'jyr','k':'\'kyr','l':'luu','m':'\'myr','n':'\'nyr','o':'oro','p':'ploop','q':'\'qyr','r':'\'ryp','s':'sh\'l','t':'tide','u':'\'urn','v':'\'vyr','w':'wash','x':'\'xyr','y':'\'yyr','z':'\'zyr'} + +def get_language(): + return { + 'name': 'Aquan', + 'type': 'greedy', + 'map': MAP + } + diff --git a/translator/languages/aquatic.py b/translator/languages/aquatic.py new file mode 100644 index 0000000..49d2850 --- /dev/null +++ b/translator/languages/aquatic.py @@ -0,0 +1,8 @@ +MAP = {'a':'abyss','b':'brine','c':'coral','d':'depth','e':'eel','f':'fin','g':'gurgle','h':'hydro','i':'ink','j':'jelly','k':'krill','l':'lagoon','m':'murk','n':'naut','o':'ocean','p':'pearl','q':'quatic','r':'reef','s':'salt','t':'tide','u':'urchin','v':'void','w':'wave','x':'xiph','y':'yacht','z':'zone'} + +def get_language(): + return { + 'name': 'Aquatic', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/arachnid.py b/translator/languages/arachnid.py new file mode 100644 index 0000000..659a7bd --- /dev/null +++ b/translator/languages/arachnid.py @@ -0,0 +1,8 @@ +MAP = {'a':'\'arr','b':'\'brach','c':'ch\'t','d':'\'drach','e':'\'err','f':'\'fune','g':'\'goss','h':'\'harr','i':'\'itt','j':'\'jarr','k':'klik\'','l':'\'lar','m':'\'marr','n':'\'narr','o':'\'orr','p':'\'parr','q':'\'qarr','r':'\'rarr','s':'skitter\'','t':'th\'k','u':'\'urr','v':'\'varr','w':'web\'','x':'\'xarr','y':'\'yarr','z':'\'zarr'} + +def get_language(): + return { + 'name': 'Arachnid', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/avain.py b/translator/languages/avain.py new file mode 100644 index 0000000..2f02d66 --- /dev/null +++ b/translator/languages/avain.py @@ -0,0 +1,8 @@ +MAP = {'a':'\'aara','b':'\'bree','c':'chir\'','d':'\'dree','e':'eek','f':'\'flutter','g':'gree\'','h':'hrooa','i':'ii\'','j':'\'jakk','k':'kree\'','l':'\'liri','m':'\'meeka','n':'\'neer','o':'\'oroo','p':'pip\'','q':'\'qree','r':'\'reea','s':'\'skraw','t':'tweet\'','u':'\'urr','v':'\'vree','w':'\'warble','x':'\'xee','y':'\'yari','z':'\'zeer'} + +def get_language(): + return { + 'name': 'Avian', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/celestial.py b/translator/languages/celestial.py new file mode 100644 index 0000000..dbb87b2 --- /dev/null +++ b/translator/languages/celestial.py @@ -0,0 +1,8 @@ +MAP = {'a':'\'ana','b':'\'bara','c':'\'cera','d':'\'dona','e':'\'elara','f':'\'fana','g':'\'gala','h':'\'hylia','i':'\'iana','j':'\'jana','k':'\'kana','l':'\'lora','m':'\'mara','n':'\'nara','o':'\'ora','p':'\'pera','q':'\'qana','r':'\'ria','s':'\'sera','t':'\'tara','u':'\'ura','v':'\'vara','w':'\'wana','x':'\'xara','y':'\'yana','z':'\'zara'} + +def get_language(): + return { + 'name': 'Celestial', + 'type': 'greedy', + 'map': MAP + } \ No newline at end of file diff --git a/translator/languages/common.py b/translator/languages/common.py new file mode 100644 index 0000000..36d8fec --- /dev/null +++ b/translator/languages/common.py @@ -0,0 +1,8 @@ +# This language is handled by its 'rule' type in the main cog. +# It doesn't need a map. + +def get_language(): + return { + 'name': 'Common', + 'type': 'rule' + } diff --git a/translator/languages/construct.py b/translator/languages/construct.py new file mode 100644 index 0000000..0884f70 --- /dev/null +++ b/translator/languages/construct.py @@ -0,0 +1,8 @@ +MAP = {'a':'auto','b':'bolt','c':'clank','d':'diode','e':'engine','f':'forge','g':'gear','h':'hydro','i':'iron','j':'joint','k':'kinetic','l':'link','m':'motor','n':'node','o':'optic','p':'piston','q':'quantum','r':'rivet','s':'servo','t':'titan','u':'unit','v':'volt','w':'whirr','x':'xenon','y':'yoke','z':'zinc'} + +def get_language(): + return { + 'name': 'Construct', + 'type': 'greedy', + 'map': MAP + } \ No newline at end of file diff --git a/translator/languages/devilish.py b/translator/languages/devilish.py new file mode 100644 index 0000000..8b7be89 --- /dev/null +++ b/translator/languages/devilish.py @@ -0,0 +1,8 @@ +MAP = {'a':'\'ayl','b':'\'baal','c':'\'cress','d':'\'drev','e':'\'eyl','f':'\'fane','g':'\'gyl','h':'hysh\'','i':'\'iyl','j':'\'jex','k':'\'krys','l':'\'lyl','m':'\'mal','n':'\'nyl','o':'\'oyl','p':'\'prax','q':'\'qyl','r':'\'ryl','s':'\'shayd','t':'\'trys','u':'\'uyl','v':'\'vyl','w':'\'wryl','x':'\'xyl','y':'\'yyl','z':'\'zyll'} + +def get_language(): + return { + 'name': 'Devilish', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/draconic.py b/translator/languages/draconic.py new file mode 100644 index 0000000..0dbc4ee --- /dev/null +++ b/translator/languages/draconic.py @@ -0,0 +1,8 @@ +MAP = {'a':'ax','b':'baxis','c':'caex','d':'drak','e':'ess','f':'faex','g':'gix','h':'heth','i':'ir','j':'jyss','k':'kex','l':'lix','m':'maex','n':'nex','o':'oth','p':'pex','q':'qexis','r':'rax','s':'syth','t':'thrax','u':'ur','v':'vyx','w':'wess','x':'xis','y':'yth','z':'zix'} + +def get_language(): + return { + 'name': 'Draconic', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/dwarvish.py b/translator/languages/dwarvish.py new file mode 100644 index 0000000..c637f3a --- /dev/null +++ b/translator/languages/dwarvish.py @@ -0,0 +1,8 @@ +MAP = {'a':'az','b':'bar','c':'krag','d':'dur','e':'ek','f':'fol','g':'grum','h':'hur','i':'in','j':'jor','k':'kaz','l':'lur','m':'mor','n':'nur','o':'ok','p':'por','q':'qur','r':'ruk','s':'son','t':'thor','u':'um','v':'val','w':'wor','x':'xor','z':'zul'} + +def get_language(): + return { + 'name': 'Dwarvish', + 'type': 'greedy', + 'map': MAP + } \ No newline at end of file diff --git a/translator/languages/elemental.py b/translator/languages/elemental.py new file mode 100644 index 0000000..7b4d44f --- /dev/null +++ b/translator/languages/elemental.py @@ -0,0 +1,8 @@ +MAP = {'a':'aer','b':'breeze','c':'cinder','d':'dust','e':'ember','f':'flow','g':'gust','h':'hail','i':'ignis','j':'jet','k':'kinetic','l':'lava','m':'mist','n':'nova','o':'ozone','p':'pyre','q':'quake','r':'rain','s':'stone','t':'terra','u':'umbra','v':'vapor','w':'wave','x':'xenon','y':'yon','z':'zephyr'} + +def get_language(): + return { + 'name': 'Elemental', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/elvish.py b/translator/languages/elvish.py new file mode 100644 index 0000000..deab2f4 --- /dev/null +++ b/translator/languages/elvish.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +MAP = {'a':'ael','b':'\'eth','c':'cal','d':'dor','e':'elen','f':'fae','g':'\'gan','h':'hîr','i':'ia','j':'yel','k':'\'ken','l':'lael','m':'mel','n':'nîn','o':'oia','p':'\'pes','q':'qen','r':'rae','s':'sil','t':'tâ','u':'ui','v':'vae','w':'win','x':'\'xal','y':'yl','z':'zîr'} + +def get_language(): + return { + 'name': 'Elvish', + 'type': 'greedy', + 'map': MAP + } + diff --git a/translator/languages/feline.py b/translator/languages/feline.py new file mode 100644 index 0000000..874073f --- /dev/null +++ b/translator/languages/feline.py @@ -0,0 +1,8 @@ +MAP = {'a':'meow','b':'brrt','c':'chrr','d':'drrt','e':'eek','f':'frrt','g':'grrowl','h':'hiss','i':'mii','j':'jrr','k':'krr','l':'lrr','m':'mrow','n':'nyah','o':'oww','p':'purr','q':'qrr','r':'rrr','s':'sss','t':'trill','u':'urr','v':'vrr','w':'wrr','x':'xrr','y':'yowl','z':'zzz'} + +def get_language(): + return { + 'name': 'Feline', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/fiendish.py b/translator/languages/fiendish.py new file mode 100644 index 0000000..ed470b3 --- /dev/null +++ b/translator/languages/fiendish.py @@ -0,0 +1,8 @@ +MAP = {'a':'az\'ael','b':'ba\'al','c':'cre\'z','d':'dre\'th','e':'esh\'','f':'fiir\'','g':'gre\'th','h':'ha\'el','i':'i\'z','j':'je\'th','k':'krez\'','l':'le\'th','m':'morn\'','n':'ne\'th','o':'o\'z','p':'pre\'th','q':'qe\'th','r':'re\'th','s':'se\'th','t':'te\'th','u':'u\'z','v':'ve\'th','w':'we\'th','x':'xith\'','y':'ye\'th','z':'zael\''} + +def get_language(): + return { + 'name': 'Fiendish', + 'type': 'greedy', + 'map': MAP + } \ No newline at end of file diff --git a/translator/languages/gnomish.py b/translator/languages/gnomish.py new file mode 100644 index 0000000..ed8700b --- /dev/null +++ b/translator/languages/gnomish.py @@ -0,0 +1,8 @@ +MAP = {'a':'akk','b':'bink','c':'clank','d':'dink','e':'enk','f':'fizz','g':'giz','h':'hink','i':'ink','j':'jink','k':'kink','l':'link','m':'mink','n':'nink','o':'onk','p':'sprok','q':'qink','r':'rink','s':'sprock','t':'tink','u':'unk','v':'vink','w':'whirr','x':'xink','y':'yink','z':'zink'} + +def get_language(): + return { + 'name': 'Gnomish', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/goblin.py b/translator/languages/goblin.py new file mode 100644 index 0000000..e2c162c --- /dev/null +++ b/translator/languages/goblin.py @@ -0,0 +1,8 @@ +MAP = {'a':'az','b':'bik','c':'clik','d':'dik','e':'ek','f':'fiz','g':'gib','h':'hik','i':'ik','j':'jik','k':'krik','l':'lik','m':'mik','n':'nik','o':'ok','p':'pik','q':'qik','r':'rik','s':'snik','t':'tik','u':'uk','v':'vik','w':'wik','x':'xik','y':'yik','z':'zik'} + +def get_language(): + return { + 'name': 'Goblin', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/halftongue.py b/translator/languages/halftongue.py new file mode 100644 index 0000000..12a0359 --- /dev/null +++ b/translator/languages/halftongue.py @@ -0,0 +1,8 @@ +MAP = {'a':'apple','b':'bramble','c':'crumpet','d':'dale','e':'elderberry','f':'fiddle','g':'garden','h':'hearth','i':'iris','j':'jam','k':'kettle','l':'lazy','m':'meadow','n':'nimble','o':'oats','p':'pudding','q':'quaint','r':'river','s':'sunny','t':'tater','u':'underhill','v':'vine','w':'willow','x':'extra','y':'yarn','z':'zesty'} + +def get_language(): + return { + 'name': 'Half-Tongue', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/herbilore.py b/translator/languages/herbilore.py new file mode 100644 index 0000000..a7a7883 --- /dev/null +++ b/translator/languages/herbilore.py @@ -0,0 +1,8 @@ +MAP = {'a':'aloe','b':'bark','c':'clover','d':'dand','e':'elder','f':'fern','g':'groot','h':'herb','i':'ivy','j':'juni','k':'kelp','l':'leaf','m':'moss','n':'nettle','o':'oak','p':'petal','q':'quin','r':'root','s':'sprout','t':'thyme','u':'ursi','v':'vine','w':'willow','x':'xylem','y':'yarrow','z':'zinni'} + +def get_language(): + return { + 'name': 'Herbilore', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/infernal.py b/translator/languages/infernal.py new file mode 100644 index 0000000..7e0ff2e --- /dev/null +++ b/translator/languages/infernal.py @@ -0,0 +1,8 @@ +MAP = {'a':'az\'','b':'\'baal','c':'\'krez','d':'\'drak','e':'ez\'','f':'\'fel','g':'\'gor','h':'\'hath','i':'iz\'','j':'\'jaz','k':'\'kraz','l':'\'laz','m':'\'mor','n':'\'naz','o':'oz\'','p':'\'paz','q':'\'qaz','r':'\'raz','s':'\'saz','t':'\'taz','u':'uz\'','v':'\'vaz','w':'\'waz','x':'\'xaz','y':'\'yaz','z':'\'zaz'} + +def get_language(): + return { + 'name': 'Infernal', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/kitsune.py b/translator/languages/kitsune.py new file mode 100644 index 0000000..af05411 --- /dev/null +++ b/translator/languages/kitsune.py @@ -0,0 +1,9 @@ +MAP = {'a':'ka','b':'be','c':'chi','d':'de','e':'e','f':'fu','g':'ga','h':'hi','i':'i','j':'ji','k':'ki','l':'ru','m':'ma','n':'na','o':'o','p':'pe','q':'kyu','r':'re','s':'sa','t':'to','u':'u','v':'ve','w':'wa','x':'za','y':'ya','z':'ze'} + +def get_language(): + return { + 'name': 'Kitsune', + 'type': 'greedy', + 'map': MAP, + 'separator': '' + } diff --git a/translator/languages/leet.py b/translator/languages/leet.py new file mode 100644 index 0000000..928228c --- /dev/null +++ b/translator/languages/leet.py @@ -0,0 +1,9 @@ +MAP = {'a':'4','b':'8','c':'(','d':')','e':'3','f':'|=','g':'6','h':'#','i':'1','j':']','k':'|<','l':'1','m':'/\\/\\','n':'/\\/','o':'0','p':'|D','q':'(,)','r':'|2','s':'5','t':'7','u':'|_|','v':'\\/','w':'\\/\\/','x':'><','y':'`/','z':'2'} + +def get_language(): + return { + 'name': 'Leet Speak', + 'type': 'special', + 'map': MAP + + } \ No newline at end of file diff --git a/translator/languages/lizardfolk.py b/translator/languages/lizardfolk.py new file mode 100644 index 0000000..8f51152 --- /dev/null +++ b/translator/languages/lizardfolk.py @@ -0,0 +1,8 @@ +MAP = {'a':'\'ax','b':'\'bax','c':'\'caz','d':'\'daz','e':'\'ex','f':'\'fax','g':'\'gaz','h':'h\'ss','i':'\'ix','j':'\'jax','k':'sk\'ex','l':'\'lax','m':'\'max','n':'\'nax','o':'\'ox','p':'\'pax','q':'\'qax','r':'\'rax','s':'s\'lith','t':'\'char','u':'\'ux','v':'\'vax','w':'\'wax','x':'\'xax','y':'\'yax','z':'\'zax'} + +def get_language(): + return { + 'name': 'Lizardfolk', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/morsecode.py b/translator/languages/morsecode.py new file mode 100644 index 0000000..7e740a9 --- /dev/null +++ b/translator/languages/morsecode.py @@ -0,0 +1,8 @@ +MAP = {'a':'.-','b':'-...','c':'-.-.','d':'-..','e':'.','f':'..-.','g':'--.','h':'....','i':'..','j':'.---','k':'-.-','l':'.-..','m':'--','n':'-.','o':'---','p':'.--.','q':'--.-','r':'.-.','s':'...','t':'-','u':'..-','v':'...-','w':'.--','x':'-..-','y':'-.--','z':'--..','1':'.----','2':'..---','3':'...--','4':'....-','5':'.....','6':'-....','7':'--...','8':'---..','9':'----.','0':'-----',' ':'/'} + +def get_language(): + return { + 'name': 'Morse Code', + 'type': 'special', + 'map': MAP + } diff --git a/translator/languages/myconid.py b/translator/languages/myconid.py new file mode 100644 index 0000000..ac84030 --- /dev/null +++ b/translator/languages/myconid.py @@ -0,0 +1,8 @@ +MAP = {'a':'agaric','b':'bolete','c':'cap','d':'decay','e':'enoki','f':'fungi','g':'gill','h':'hyphae','i':'indigo','j':'jelly','k':'kombu','l':'lichen','m':'myco\'','n':'nidur','o':'oyster','p':'puff','q':'quorn','r':'rhizo','s':'spore\'','t':'thallus','u':'umbra','v':'velvet','w':'wart','x':'xero','y':'yeast','z':'zoospore'} + +def get_language(): + return { + 'name': 'Myconid', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/ogrish.py b/translator/languages/ogrish.py new file mode 100644 index 0000000..aa2235a --- /dev/null +++ b/translator/languages/ogrish.py @@ -0,0 +1,8 @@ +MAP = {'a':'ug','b':'blud','c':'crag','d':'dug','e':'eeg','f':'fug','g':'gron','h':'hug','i':'ig','j':'jug','k':'krug','l':'lug','m':'mush','n':'nug','o':'og','p':'pug','q':'qug','r':'rug','s':'slog','t':'thok','u':'urk','v':'vog','w':'wug','x':'xug','y':'yug','z':'zug'} + +def get_language(): + return { + 'name': 'Ogrish', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/orcish.py b/translator/languages/orcish.py new file mode 100644 index 0000000..6478915 --- /dev/null +++ b/translator/languages/orcish.py @@ -0,0 +1,8 @@ +MAP = {'a':'agh','b':'bug','c':'karg','d':'dur','e':'egh','f':'fug','g':'grol','h':'hosh','i':'izg','j':'jug','k':'krunk','l':'lug','m':'mog','n':'nog','o':'ogg','p':'pug','q':'qug','r':'ruk','s':'snaga','t':'tusk','u':'uruk','v':'vug','w':'warg','x':'xug','y':'yag','z':'zug'} + +def get_language(): + return { + 'name': 'Orcish', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/piglatin.py b/translator/languages/piglatin.py new file mode 100644 index 0000000..c84af4a --- /dev/null +++ b/translator/languages/piglatin.py @@ -0,0 +1,8 @@ +# This language is handled by its 'rule' type in the main cog. +# It doesn't need a map. + +def get_language(): + return { + 'name': 'Pig Latin', + 'type': 'rule' + } diff --git a/translator/languages/ratfolk.py b/translator/languages/ratfolk.py new file mode 100644 index 0000000..056e3b0 --- /dev/null +++ b/translator/languages/ratfolk.py @@ -0,0 +1,8 @@ +MAP = {'a':'skree','b':'bite','c':'claw','d':'dark-thing','e':'eek','f':'filth','g':'gnaw','h':'hiss','i':'itch','j':'junk','k':'kill','l':'long-tail','m':'muck','n':'nest-thing','o':'rot-stink','p':'plague','q':'quick-quick','r':'rust','s':'skitter','t':'twitch','u':'under-thing','v':'vermin','w':'waste','x':'pox','y':'yes-yes','z':'zap-tail'} + +def get_language(): + return { + 'name': 'Ratfolk', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/scorpion.py b/translator/languages/scorpion.py new file mode 100644 index 0000000..9709bcc --- /dev/null +++ b/translator/languages/scorpion.py @@ -0,0 +1,8 @@ +MAP = {'a':'sk\'','b':'t\'k','c':'k\'ss','d':'d\'th','e':'e\'sk','f':'f\'t','g':'g\'th','h':'h\'k','i':'i\'s','j':'j\'t','k':'k\'t','l':'l\'k','m':'m\'k','n':'n\'t','o':'o\'s','p':'p\'k','q':'q\'t','r':'r\'k','s':'s\'k','t':'t\'s','u':'u\'s','v':'v\'t','w':'w\'k','x':'x\'s','y':'y\'k','z':'z\'t'} + +def get_language(): + return { + 'name': 'Scorpion', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/sinary.py b/translator/languages/sinary.py new file mode 100644 index 0000000..a98f147 --- /dev/null +++ b/translator/languages/sinary.py @@ -0,0 +1,10 @@ +MAP = {'a':'a!','b':'7b','c':'c#','d':'d4','e':'3e','f':'f^','g':'g&','h':'h8','i':'(i','j':'j0','k':'_k','l':'l2','m':'=m','n':'n+','o':'5o','p':'-p','q':'q{','r':'}r','s':'[s','t':'t]','u':'|u','v':':v','w':'"w','x':'x<','y':'>y','z':'?z'} + +def get_language(): + return { + 'name': 'Sinary', + 'type': 'generic', + 'map': MAP, + 'chunk_size': 2, + 'separator': '' + } diff --git a/translator/languages/spiritual.py b/translator/languages/spiritual.py new file mode 100644 index 0000000..793d417 --- /dev/null +++ b/translator/languages/spiritual.py @@ -0,0 +1,8 @@ +MAP = {'a':'\'aura','b':'\'breth','c':'\'ciel','d':'\'dion','e':'\'ethys','f':'\'fey','g':'\'glyn','h':'\'hymn','i':'\'ia','j':'\'jora','k':'\'kye','l':'\'lume','m':'\'mana','n':'\'nima','o':'\'omni','p':'\'pria','q':'\'qia','r':'\'reth','s':'\'seren','t':'\'thyme','u':'\'umbra','v':'\'vym','w':'\'wisp','x':'\'xia','y':'\'yara','z':'\'zion'} + +def get_language(): + return { + 'name': 'Spiritual', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/succubus.py b/translator/languages/succubus.py new file mode 100644 index 0000000..8058799 --- /dev/null +++ b/translator/languages/succubus.py @@ -0,0 +1,8 @@ +MAP = {'a':'ah\'','b':'\'bel','c':'\'chae','d':'\'des','e':'\'esh','f':'\'fey','g':'\'gis','h':'hah\'','i':'\'ish','j':'\'jo','k':'\'ka','l':'\'lis','m':'\'mah','n':'\'nah','o':'oh\'','p':'\'pah','q':'\'qia','r':'\'rah','s':'\'sha','t':'\'thae','u':'uh\'','v':'\'vi','w':'\'wah','x':'\'xi','y':'\'yah','z':'\'zah'} + +def get_language(): + return { + 'name': 'Succubus', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/sylvan.py b/translator/languages/sylvan.py new file mode 100644 index 0000000..9c63dab --- /dev/null +++ b/translator/languages/sylvan.py @@ -0,0 +1,8 @@ +MAP = {'a':'ani','b':'bri','c':'cae','d':'dae','e':'eni','f':'fae','g':'gra','h':'hae','i':'ia','j':'jae','k':'kae','l':'lor','m':'mae','n':'nem','o':'olo','p':'pae','q':'qae','r':'rae','s':'sae','t':'tae','u':'uni','v':'vae','w':'wae','x':'xae','y':'yae','z':'zae'} + +def get_language(): + return { + 'name': 'Sylvan', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/undercommon.py b/translator/languages/undercommon.py new file mode 100644 index 0000000..8fd20da --- /dev/null +++ b/translator/languages/undercommon.py @@ -0,0 +1,8 @@ +MAP = {'a':'velk','b':'xund','c':'k\'yorl','d':'d\'ruth','e':'e\'trorn','f':'faer','g':'gol','h':'h\'chak','i':'i\'lith','j':'j\'lar','k':'k\'lar','l':'lil','m':'m\'lar','n':'nind','o':'olath','p':'p\'lar','q':'qu\'ellar','r':'ril','s':'sorn','t':'\'lar','u':'uss','v':'v\'lar','w':'wyl','x':'x\'lar','y':'y\'lar','z':'z\'ress'} + +def get_language(): + return { + 'name': 'Undercommon', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/languages/uwu.py b/translator/languages/uwu.py new file mode 100644 index 0000000..521bcd1 --- /dev/null +++ b/translator/languages/uwu.py @@ -0,0 +1,8 @@ +# This language is handled by its 'rule' type in the main cog. +# It doesn't need a map. + +def get_language(): + return { + 'name': 'UwU', + 'type': 'rule' + } diff --git a/translator/languages/valspiren.py b/translator/languages/valspiren.py new file mode 100644 index 0000000..43b2ec9 --- /dev/null +++ b/translator/languages/valspiren.py @@ -0,0 +1,10 @@ +MAP = {'a':'ak','b':'ba','c':'ce','d':'di','e':'ek','f':'fo','g':'gu','h':'ha','i':'ik','j':'je','k':'ki','l':'lo','m':'mu','n':'na','o':'ok','p':'pe','q':'qi','r':'ro','s':'su','t':'ta','u':'uk','v':'ve','w':'wi','x':'xo','y':'yk','z':'zu'} + +def get_language(): + return { + 'name': 'Valspiren', + 'type': 'generic', + 'map': MAP, + 'chunk_size': 2, + 'separator': '' + } diff --git a/translator/languages/voidtouched.py b/translator/languages/voidtouched.py new file mode 100644 index 0000000..6f4b783 --- /dev/null +++ b/translator/languages/voidtouched.py @@ -0,0 +1,8 @@ +MAP = {'a':'a\'th','b':'b\'zoth','c':'c\'thun','d':'d\'gol','e':'e\'th','f':'f\'thagn','g':'g\'noth','h':'h\'zoth','i':'i\'th','j':'j\'th','k':'k\'th','l':'l\'th','m':'m\'th','n':'n\'th','o':'o\'th','p':'p\'th','q':'qor\'','r':'r\'lyeh','s':'s\'th','t':'t\'th','u':'u\'th','v':'v\'lath','w':'w\'th','x':'x\'thul','y':'y\'th','z':'zy\'th'} + +def get_language(): + return { + 'name': 'Voidtouched', + 'type': 'greedy', + 'map': MAP + } diff --git a/translator/logic.py b/translator/logic.py new file mode 100644 index 0000000..4d805b9 --- /dev/null +++ b/translator/logic.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +import re +import random +from redbot.core.utils.chat_formatting import box + +def reverse_map(m): return {v: k for k, v in m.items()} + +class TranslationLogicMixin: + """This class holds all the translation logic for the cog.""" + + def _escape_regex(self, s): + return re.escape(s) + + def _generic_translator(self, text, lang_map, char_separator): + # Regex to find custom emojis + emoji_regex = re.compile(r"") + + # Find all emojis and store them + emojis = emoji_regex.findall(text) + + # Replace emojis with a unique placeholder + placeholder = "||EMOJI||" + text_with_placeholders = emoji_regex.sub(placeholder, text) + + # Translate the text with placeholders + word_separator = ' ' if char_separator == '' else ' ' + words = text_with_placeholders.split(' ') + translated_words = [] + emoji_counter = 0 + + for word in words: + if placeholder in word: + # If a word contains a placeholder, it might be part of the emoji code that got split. + # We simply re-insert the emoji from our list. + translated_words.append(emojis[emoji_counter]) + emoji_counter += 1 + else: + translated_word = char_separator.join([lang_map.get(char.lower(), char) for char in word]) + translated_words.append(translated_word) + + return word_separator.join(translated_words) + + def _generic_decoder(self, text, reverse_map, chunk_size): + result = "" + text_no_space = text.replace(' ','') + for i in range(0, len(text_no_space), chunk_size): + chunk = text_no_space[i:i+chunk_size] + result += reverse_map.get(chunk, '?') + return result + + def _greedy_decoder(self, text, reverse_map): + syllables = sorted(reverse_map.keys(), key=len, reverse=True) + regex = re.compile('|'.join(map(self._escape_regex, syllables))) + matches = regex.findall(text.replace(' ', '')) + return "".join([reverse_map.get(m, '?') for m in matches]) + + def _leet_decoder(self, text, reverse_map): + decoded_text = text + sorted_keys = sorted(reverse_map.keys(), key=len, reverse=True) + for key in sorted_keys: + decoded_text = decoded_text.replace(key, reverse_map[key]) + return decoded_text + + def _morse_decoder(self, text, reverse_map): + words = text.split(' ') + decoded_words = [] + for word in words: + chars = word.split(' ') + decoded_words.append("".join([reverse_map.get(c, '?') for c in chars])) + return " ".join(decoded_words) + + def _pig_latin_translator(self, text): + vowels = "aeiou" + translated_words = [] + for word in text.split(' '): + if not word: continue + match = re.match(r"^([^a-zA-Z0-9]*)(.*?)([^a-zA-Z0-9]*)$", word) + leading_punct, clean_word, trailing_punct = match.groups() + + if not clean_word: + translated_words.append(word) + continue + + if clean_word and clean_word[0].lower() in vowels: + translated_word = clean_word + "way" + else: + first_vowel_index = -1 + for i, char in enumerate(clean_word): + if char.lower() in vowels: + first_vowel_index = i + break + if first_vowel_index == -1: + translated_word = clean_word + "ay" + else: + translated_word = clean_word[first_vowel_index:] + clean_word[:first_vowel_index] + "ay" + translated_words.append(leading_punct + translated_word + trailing_punct) + return " ".join(translated_words) + + def _pig_latin_decoder(self, text): + decoded_words = [] + for word in text.split(' '): + if not word: continue + match = re.match(r"^([^a-zA-Z0-9]*)(.*?)([^a-zA-Z0-9]*)$", word) + leading_punct, clean_word, trailing_punct = match.groups() + + if not clean_word: + decoded_words.append(word) + continue + + original_word = "" + if clean_word.lower().endswith("way"): + original_word = clean_word[:-3] + elif clean_word.lower().endswith("ay"): + base_word = clean_word[:-2] + last_consonant_block_index = -1 + for i in range(len(base_word) - 1, -1, -1): + if base_word[i].lower() not in "aeiou": + last_consonant_block_index = i + else: + break + if last_consonant_block_index != -1: + while last_consonant_block_index > 0 and base_word[last_consonant_block_index-1].lower() not in "aeiou": + last_consonant_block_index -= 1 + consonants = base_word[last_consonant_block_index:] + stem = base_word[:last_consonant_block_index] + original_word = consonants + stem + else: + original_word = base_word + else: + original_word = clean_word + decoded_words.append(leading_punct + original_word + trailing_punct) + return " ".join(decoded_words) + + def _uwu_translator(self, text): + text = text.lower() + text = text.replace('l', 'ww') + text = text.replace('r', 'w') + text = text.replace('na', 'nya').replace('ne', 'nye').replace('ni', 'nyi').replace('no', 'nyo').replace('nu', 'nyu') + text = text.replace('ove', 'uv') + words = text.split(' ') + uwu_words = [] + for word in words: + if len(word) > 3 and random.random() < 0.3: + uwu_words.append(f"{word[0]}-{word}") + else: + uwu_words.append(word) + text = " ".join(uwu_words) + if random.random() < 0.5: + emoticons = [' uwu', ' owo', ' >w<', ' ^-^', ' ;;'] + text += random.choice(emoticons) + return text + + def _uwu_decoder(self, text): + emoticons = [' uwu', ' owo', ' >w<', ' ^-^', ' ;;'] + for emo in emoticons: + if text.endswith(emo): + text = text[:-len(emo)] + text = re.sub(r'(\w)-(\w+)', r'\2', text) + text = text.replace('uv', 'ove') + text = text.replace('nyu', 'nu').replace('nyo', 'no').replace('nyi', 'ni').replace('nye', 'ne').replace('nya', 'na') + text = text.replace('ww', 'l') + text = text.replace('w', 'r') + return text + + async def _find_language(self, query: str): + """Finds languages by key or name, case-insensitively.""" + query = query.lower() + exact_key_match = [key for key in self.all_languages if key.lower() == query] + if exact_key_match: + return exact_key_match + + exact_name_match = [key for key, lang in self.all_languages.items() if lang['name'].lower() == query] + if exact_name_match: + return exact_name_match + + partial_matches = [ + key for key, lang in self.all_languages.items() + if query in key.lower() or query in lang['name'].lower() + ] + return partial_matches + + async def _auto_translate_to_common(self, text: str) -> list: + """Helper to find all possible translations for a given text.""" + possible_translations = [] + for lang_key, lang_obj in self.all_languages.items(): + if lang_key == 'common': + continue + try: + decoded_text = lang_obj['from_func'](text) + re_encoded_text = lang_obj['to_func'](decoded_text) + if re_encoded_text == text: + possible_translations.append(f"**As {lang_obj['name']}:**\n{box(decoded_text)}") + except Exception: + continue + return possible_translations + diff --git a/translator/translator.py b/translator/translator.py new file mode 100644 index 0000000..a8d209d --- /dev/null +++ b/translator/translator.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +import discord +import pkgutil +from redbot.core import commands, app_commands, Config +from redbot.core.utils.chat_formatting import box +from . import languages as lang_pkg +from .views import DismissView +from .logic import TranslationLogicMixin +from .commands import CommandsMixin + +def reverse_map(m): return {v: k for k, v in m.items()} + +class Translator(CommandsMixin, TranslationLogicMixin, commands.Cog): + """A cog for translating text into various languages.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=8675309, force_registration=True) + self.config.register_global(languages={}) + self.config.register_user(sonas={}, proxy_enabled=False) + self.config.register_channel(autotranslate_users={}) + + self.all_languages = {} + self.translate_to_common_context_menu = app_commands.ContextMenu( + name="Translate to Common", + callback=self.translate_to_common_context, + ) + + async def cog_load(self): + """Load all languages from config and build the runtime objects.""" + self.bot.tree.add_command(self.translate_to_common_context_menu) + await self._initialize_languages() + + async def cog_unload(self): + """Remove context menu on cog unload.""" + self.bot.tree.remove_command(self.translate_to_common_context_menu.name, type=self.translate_to_common_context_menu.type) + + def _load_base_languages_from_files(self): + """Dynamically loads all base languages from the languages/ subfolder.""" + base_languages_data = {} + for importer, modname, ispkg in pkgutil.iter_modules(lang_pkg.__path__, f"{lang_pkg.__name__}."): + if not ispkg: + try: + module = __import__(modname, fromlist=["get_language"]) + if hasattr(module, "get_language"): + lang_data = module.get_language() + lang_key = modname.split('.')[-1] + base_languages_data[lang_key] = lang_data + except Exception as e: + print(f"Error loading language module {modname}: {e}") + return base_languages_data + + def _build_runtime_languages(self, language_data): + """Builds the final language dict with functions from stored data.""" + runtime_langs = {} + for key, data in language_data.items(): + lang_type = data.get("type", "greedy") # Default to greedy for custom + + runtime_langs[key] = {"name": data["name"], "is_custom": data.get("is_custom", False), "type": lang_type} + + if lang_type == "rule": + if key == "common": + runtime_langs[key]['to_func'] = lambda text: text + runtime_langs[key]['from_func'] = lambda text: text + elif key == "piglatin": + runtime_langs[key]['to_func'] = self._pig_latin_translator + runtime_langs[key]['from_func'] = self._pig_latin_decoder + elif key == "uwu": + runtime_langs[key]['to_func'] = self._uwu_translator + runtime_langs[key]['from_func'] = self._uwu_decoder + + elif lang_type == "generic": + lang_map = data.get("map", {}) + chunk_size = data.get("chunk_size", 2) + char_separator = data.get("separator", "") + word_separator = ' ' if char_separator == '' else ' ' + runtime_langs[key]['to_func'] = lambda text, m=lang_map, s=char_separator: self._generic_translator(text, m, s) + runtime_langs[key]['from_func'] = lambda text, rm=reverse_map(lang_map), cs=chunk_size, ws=word_separator: " ".join([self._generic_decoder(word, rm, cs) for word in text.split(ws)]) + + elif lang_type == "special": + if key == "leet": + lang_map = data.get("map", {}) + runtime_langs[key]['to_func'] = lambda text, m=lang_map: self._generic_translator(text, m, "") + runtime_langs[key]['from_func'] = lambda text, rm=reverse_map(lang_map): self._leet_decoder(text, rm) + elif key == "morse": + lang_map = data.get("map", {}) + runtime_langs[key]['to_func'] = lambda text, m=lang_map: self._generic_translator(text, m, " ") + runtime_langs[key]['from_func'] = lambda text, rm=reverse_map(lang_map): self._morse_decoder(text, rm) + + else: # Greedy is the default + lang_map = data.get("map", {}) + char_separator = data.get("separator", " ") + word_separator = ' ' if char_separator == '' else ' ' + runtime_langs[key]['to_func'] = lambda text, m=lang_map, s=char_separator: self._generic_translator(text, m, s) + runtime_langs[key]['from_func'] = lambda text, rm=reverse_map(lang_map), ws=word_separator: " ".join([self._greedy_decoder(word, rm) for word in text.split(ws)]) + return runtime_langs + + async def _initialize_languages(self): + """Merge default and custom languages from config.""" + base_languages_data = self._load_base_languages_from_files() + + stored_languages_data = await self.config.languages() + if not stored_languages_data: + await self.config.languages.set(base_languages_data) + final_data = base_languages_data + else: + final_data = base_languages_data.copy() + for key, data in stored_languages_data.items(): + if data.get("is_custom"): + final_data[key] = data + await self.config.languages.set(final_data) + + self.all_languages = self._build_runtime_languages(final_data) + + async def translate_to_common_context(self, interaction: discord.Interaction, message: discord.Message): + """Translate a message to Common.""" + await interaction.response.defer(ephemeral=True) + + possible_translations = await self._auto_translate_to_common(message.content) + + if not possible_translations: + await interaction.followup.send("Could not find a valid translation for this message.", ephemeral=True) + else: + await interaction.followup.send("\n\n".join(possible_translations), ephemeral=True) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.author.bot or not message.guild: + return + + prefix = (await self.bot.get_prefix(message))[0] + if message.content.startswith(prefix): + return + + if message.reference and message.content.lower() == 'translate': + try: + replied_message = await message.channel.fetch_message(message.reference.message_id) + text_to_translate = replied_message.content + possible_translations = await self._auto_translate_to_common(text_to_translate) + + if not possible_translations: + response_msg = "Could not find a valid translation for the replied message." + else: + response_msg = "\n\n".join(possible_translations) + + view = DismissView(author=message.author) + sent_message = await message.reply( + response_msg, + view=view, + allowed_mentions=discord.AllowedMentions.none() + ) + view.message = sent_message + + if message.channel.permissions_for(message.guild.me).manage_messages: + await message.delete() + return + except Exception: + return + + user_settings = await self.config.user(message.author).all() + if not user_settings['sonas']: + return + + content = message.content + matched_sona = None + + for sona_data in user_settings['sonas'].values(): + if 'claws' in sona_data: + start_claw, end_claw = sona_data['claws'] + elif 'brackets' in sona_data: + start_claw, end_claw = sona_data['brackets'] + else: + continue + + if not content.startswith(start_claw): continue + if end_claw and end_claw != "": + if not content.endswith(end_claw) or len(content) < len(start_claw) + len(end_claw): + continue + matched_sona = sona_data + break + + if matched_sona: + if not user_settings['proxy_enabled']: + msg = ( + f"{message.author.mention}, it looks like you tried to proxy as **{matched_sona['display_name']}**, " + f"but your proxy is turned off. You can re-enable it with the `{prefix}proxy` command." + ) + try: + view = DismissView(author=message.author) + sent_message = await message.channel.send(msg, view=view, allowed_mentions=discord.AllowedMentions(users=True)) + view.message = sent_message + except discord.Forbidden: + pass + return + + if 'claws' in matched_sona: + start_claw, end_claw = matched_sona['claws'] + else: + start_claw, end_claw = matched_sona['brackets'] + + text_to_translate = content[len(start_claw):-len(end_claw)] if end_claw else content[len(start_claw):] + lang_key = matched_sona['language'] + + else: + autotranslate_users = await self.config.channel(message.channel).autotranslate_users() + user_id_str = str(message.author.id) + if user_id_str not in autotranslate_users: + return + + sona_key = autotranslate_users[user_id_str] + sonas = user_settings.get('sonas', {}) + if sona_key not in sonas: + return + + matched_sona = sonas[sona_key] + text_to_translate = content + lang_key = matched_sona['language'] + + to_lang_obj = self.all_languages.get(lang_key) + if not to_lang_obj: return + + try: + translated_text = to_lang_obj['to_func'](text_to_translate) + except Exception: + return + + webhook = None + if message.channel.permissions_for(message.guild.me).manage_webhooks: + webhooks = await message.channel.webhooks() + webhook = next((wh for wh in webhooks if wh.name == "Translator Cog Webhook"), None) + if webhook is None: + webhook = await message.channel.create_webhook(name="Translator Cog Webhook") + + if webhook: + if message.channel.permissions_for(message.guild.me).manage_messages: + try: + await message.delete() + except discord.Forbidden: + pass + + display_name = matched_sona.get("display_name", message.author.display_name) + avatar_url = matched_sona.get("avatar_url") or message.author.display_avatar.url + + await webhook.send( + content=translated_text, + username=display_name, + avatar_url=avatar_url + ) + diff --git a/translator/views.py b/translator/views.py new file mode 100644 index 0000000..2ccab3d --- /dev/null +++ b/translator/views.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +import discord + +class DismissView(discord.ui.View): + def __init__(self, author: discord.Member): + super().__init__(timeout=1800) # 30 minute timeout + self.author = author + self.message: discord.Message = None + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.author.id: + await interaction.response.send_message("You are not authorized to dismiss this message.", ephemeral=True) + return False + return True + + @discord.ui.button(label="Dismiss", style=discord.ButtonStyle.grey, emoji="??") + async def dismiss_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.message.delete() + self.stop() + + async def on_timeout(self): + if self.message: + try: + await self.message.delete() + except (discord.NotFound, discord.Forbidden): + pass # Message was already deleted or permissions are missing + self.stop() +